From 3e0e8dd1059ea56e599d985b3e4660f041a90d65 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 25 May 2022 21:57:55 +0200 Subject: [PATCH 001/947] Bump version to 2022.7.0dev0 (#72500) --- .github/workflows/ci.yaml | 2 +- homeassistant/const.py | 2 +- setup.cfg | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fb659cf21d2..6264c1379fd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,7 +22,7 @@ on: env: CACHE_VERSION: 9 PIP_CACHE_VERSION: 3 - HA_SHORT_VERSION: 2022.6 + HA_SHORT_VERSION: 2022.7 DEFAULT_PYTHON: 3.9 PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache diff --git a/homeassistant/const.py b/homeassistant/const.py index 74fe6adfdfd..7b91973a930 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -6,7 +6,7 @@ from typing import Final from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 -MINOR_VERSION: Final = 6 +MINOR_VERSION: Final = 7 PATCH_VERSION: Final = "0.dev0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" diff --git a/setup.cfg b/setup.cfg index 825a4407012..15db31bd306 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [metadata] -version = 2022.6.0.dev0 +version = 2022.7.0.dev0 url = https://www.home-assistant.io/ [options] From c181af92a2a2711ecdf7a80704c9e0152d1aa254 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 25 May 2022 13:00:48 -0700 Subject: [PATCH 002/947] Throw nest climate API errors as HomeAssistantErrors (#72474) --- homeassistant/components/nest/climate_sdm.py | 34 +++++++---- tests/components/nest/test_climate_sdm.py | 60 ++++++++++++++++++++ 2 files changed, 84 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index bbbf83501f7..8a56f78028b 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -6,6 +6,7 @@ from typing import Any, cast from google_nest_sdm.device import Device from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.device_traits import FanTrait, TemperatureTrait +from google_nest_sdm.exceptions import ApiException from google_nest_sdm.thermostat_traits import ( ThermostatEcoTrait, ThermostatHeatCoolTrait, @@ -30,6 +31,7 @@ from homeassistant.components.climate.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -294,7 +296,10 @@ class ThermostatEntity(ClimateEntity): hvac_mode = HVACMode.OFF api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] trait = self._device.traits[ThermostatModeTrait.NAME] - await trait.set_mode(api_mode) + try: + await trait.set_mode(api_mode) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -308,20 +313,26 @@ class ThermostatEntity(ClimateEntity): if ThermostatTemperatureSetpointTrait.NAME not in self._device.traits: return trait = self._device.traits[ThermostatTemperatureSetpointTrait.NAME] - if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: - if low_temp and high_temp: - await trait.set_range(low_temp, high_temp) - elif hvac_mode == HVACMode.COOL and temp: - await trait.set_cool(temp) - elif hvac_mode == HVACMode.HEAT and temp: - await trait.set_heat(temp) + try: + if self.preset_mode == PRESET_ECO or hvac_mode == HVACMode.HEAT_COOL: + if low_temp and high_temp: + await trait.set_range(low_temp, high_temp) + elif hvac_mode == HVACMode.COOL and temp: + await trait.set_cool(temp) + elif hvac_mode == HVACMode.HEAT and temp: + await trait.set_heat(temp) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" if preset_mode not in self.preset_modes: raise ValueError(f"Unsupported preset_mode '{preset_mode}'") trait = self._device.traits[ThermostatEcoTrait.NAME] - await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) + try: + await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" @@ -331,4 +342,7 @@ class ThermostatEntity(ClimateEntity): duration = None if fan_mode != FAN_OFF: duration = MAX_FAN_DURATION - await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration) + try: + await trait.set_timer(FAN_INV_MODE_MAP[fan_mode], duration=duration) + except ApiException as err: + raise HomeAssistantError(f"Error setting HVAC mode: {err}") from err diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index 5f3efa362b3..123742607ad 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -6,8 +6,10 @@ pubsub subscriber. """ from collections.abc import Awaitable, Callable +from http import HTTPStatus from typing import Any +import aiohttp from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.event import EventMessage import pytest @@ -41,6 +43,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .common import ( DEVICE_COMMAND, @@ -1380,3 +1383,60 @@ async def test_thermostat_invalid_set_preset_mode( # Preset is unchanged assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_NONE assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] + + +async def test_thermostat_hvac_mode_failure( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, +) -> None: + """Test setting an hvac_mode that is not supported.""" + create_device.create( + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatEco": { + "availableModes": ["MANUAL_ECO", "OFF"], + "mode": "OFF", + "heatCelsius": 15.0, + "coolCelsius": 28.0, + }, + } + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_hvac_mode(hass, HVAC_MODE_HEAT) + await hass.async_block_till_done() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_temperature( + hass, hvac_mode=HVAC_MODE_HEAT, temperature=25.0 + ) + await hass.async_block_till_done() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_fan_mode(hass, FAN_ON) + await hass.async_block_till_done() + + auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] + with pytest.raises(HomeAssistantError): + await common.async_set_preset_mode(hass, PRESET_ECO) + await hass.async_block_till_done() From 30edc039aee259c6a4f451f0e574f02e2b74eacc Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 26 May 2022 00:23:39 +0000 Subject: [PATCH 003/947] [ci skip] Translation update --- .../components/generic/translations/de.json | 2 ++ .../components/generic/translations/et.json | 2 ++ .../components/generic/translations/fr.json | 2 ++ .../components/generic/translations/hu.json | 2 ++ .../components/generic/translations/it.json | 2 ++ .../components/generic/translations/pl.json | 2 ++ .../generic/translations/pt-BR.json | 2 ++ .../components/ialarm_xr/translations/de.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/fr.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/hu.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/it.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/pl.json | 21 +++++++++++++++++++ .../ialarm_xr/translations/pt-BR.json | 21 +++++++++++++++++++ .../totalconnect/translations/de.json | 11 ++++++++++ .../totalconnect/translations/en.json | 11 ++++++++++ .../totalconnect/translations/fr.json | 11 ++++++++++ .../totalconnect/translations/hu.json | 11 ++++++++++ .../totalconnect/translations/it.json | 11 ++++++++++ .../totalconnect/translations/pl.json | 11 ++++++++++ .../totalconnect/translations/pt-BR.json | 11 ++++++++++ 20 files changed, 217 insertions(+) create mode 100644 homeassistant/components/ialarm_xr/translations/de.json create mode 100644 homeassistant/components/ialarm_xr/translations/fr.json create mode 100644 homeassistant/components/ialarm_xr/translations/hu.json create mode 100644 homeassistant/components/ialarm_xr/translations/it.json create mode 100644 homeassistant/components/ialarm_xr/translations/pl.json create mode 100644 homeassistant/components/ialarm_xr/translations/pt-BR.json diff --git a/homeassistant/components/generic/translations/de.json b/homeassistant/components/generic/translations/de.json index d4c5c268eed..0c14e95a683 100644 --- a/homeassistant/components/generic/translations/de.json +++ b/homeassistant/components/generic/translations/de.json @@ -15,6 +15,7 @@ "stream_no_video": "Stream enth\u00e4lt kein Video", "stream_not_permitted": "Beim Versuch, eine Verbindung zum Stream herzustellen, ist ein Vorgang nicht zul\u00e4ssig. Falsches RTSP-Transportprotokoll?", "stream_unauthorised": "Autorisierung beim Versuch, eine Verbindung zum Stream herzustellen, fehlgeschlagen", + "template_error": "Fehler beim Rendern der Vorlage. \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "timeout": "Zeit\u00fcberschreitung beim Laden der URL", "unable_still_load": "Es konnte kein g\u00fcltiges Bild von der Standbild-URL geladen werden (z. B. ung\u00fcltiger Host, URL oder Authentifizierungsfehler). \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "unknown": "Unerwarteter Fehler" @@ -57,6 +58,7 @@ "stream_no_video": "Stream enth\u00e4lt kein Video", "stream_not_permitted": "Beim Versuch, eine Verbindung zum Stream herzustellen, ist ein Vorgang nicht zul\u00e4ssig. Falsches RTSP-Transportprotokoll?", "stream_unauthorised": "Autorisierung beim Versuch, eine Verbindung zum Stream herzustellen, fehlgeschlagen", + "template_error": "Fehler beim Rendern der Vorlage. \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "timeout": "Zeit\u00fcberschreitung beim Laden der URL", "unable_still_load": "Es konnte kein g\u00fcltiges Bild von der Standbild-URL geladen werden (z. B. ung\u00fcltiger Host, URL oder Authentifizierungsfehler). \u00dcberpr\u00fcfe das Protokoll f\u00fcr weitere Informationen.", "unknown": "Unerwarteter Fehler" diff --git a/homeassistant/components/generic/translations/et.json b/homeassistant/components/generic/translations/et.json index 41e1881573e..a50e4d4aaa7 100644 --- a/homeassistant/components/generic/translations/et.json +++ b/homeassistant/components/generic/translations/et.json @@ -15,6 +15,7 @@ "stream_no_video": "Voos pole videot", "stream_not_permitted": "Vooga \u00fchenduse loomisel pole toiming lubatud. Vale RTSP transpordiprotokoll?", "stream_unauthorised": "Autoriseerimine eba\u00f5nnestus vooga \u00fchendamise ajal", + "template_error": "Viga malli renderdamisel. Lisateabe saamiseks vaata logi.", "timeout": "URL-i laadimise ajal\u00f5pp", "unable_still_load": "Pilti ei saa laadida URL-ist (nt kehtetu host, URL v\u00f5i autentimise t\u00f5rge). Lisateabe saamiseks vaata logi.", "unknown": "Ootamatu t\u00f5rge" @@ -57,6 +58,7 @@ "stream_no_video": "Voos pole videot", "stream_not_permitted": "Vooga \u00fchenduse loomisel pole toiming lubatud. Vale RTSP transpordiprotokoll?", "stream_unauthorised": "Autoriseerimine eba\u00f5nnestus vooga \u00fchendamise ajal", + "template_error": "Viga malli renderdamisel. Lisateabe saamiseks vaata logi.", "timeout": "URL-i laadimise ajal\u00f5pp", "unable_still_load": "Pilti ei saa laadida URL-ist (nt kehtetu host, URL v\u00f5i autentimise t\u00f5rge). Lisateabe saamiseks vaata logi.", "unknown": "Ootamatu t\u00f5rge" diff --git a/homeassistant/components/generic/translations/fr.json b/homeassistant/components/generic/translations/fr.json index 6215c579be7..0d517a846e7 100644 --- a/homeassistant/components/generic/translations/fr.json +++ b/homeassistant/components/generic/translations/fr.json @@ -15,6 +15,7 @@ "stream_no_video": "Le flux ne contient pas de vid\u00e9o", "stream_not_permitted": "Op\u00e9ration non autoris\u00e9e lors de la tentative de connexion au flux. Mauvais protocole de transport RTSP\u00a0?", "stream_unauthorised": "\u00c9chec de l'autorisation lors de la tentative de connexion au flux", + "template_error": "Erreur lors du rendu du mod\u00e8le. Consultez le journal pour plus d'informations.", "timeout": "D\u00e9lai d'attente expir\u00e9 lors du chargement de l'URL", "unable_still_load": "Impossible de charger une image valide depuis l'URL d'image fixe (cela pourrait \u00eatre d\u00fb \u00e0 un h\u00f4te ou \u00e0 une URL non valide, ou \u00e0 un \u00e9chec de l'authentification). Consultez le journal pour plus d'informations.", "unknown": "Erreur inattendue" @@ -57,6 +58,7 @@ "stream_no_video": "Le flux ne contient pas de vid\u00e9o", "stream_not_permitted": "Op\u00e9ration non autoris\u00e9e lors de la tentative de connexion au flux. Mauvais protocole de transport RTSP\u00a0?", "stream_unauthorised": "\u00c9chec de l'autorisation lors de la tentative de connexion au flux", + "template_error": "Erreur lors du rendu du mod\u00e8le. Consultez le journal pour plus d'informations.", "timeout": "D\u00e9lai d'attente expir\u00e9 lors du chargement de l'URL", "unable_still_load": "Impossible de charger une image valide depuis l'URL d'image fixe (cela pourrait \u00eatre d\u00fb \u00e0 un h\u00f4te ou \u00e0 une URL non valide, ou \u00e0 un \u00e9chec de l'authentification). Consultez le journal pour plus d'informations.", "unknown": "Erreur inattendue" diff --git a/homeassistant/components/generic/translations/hu.json b/homeassistant/components/generic/translations/hu.json index 59840b3195b..76992a1fde7 100644 --- a/homeassistant/components/generic/translations/hu.json +++ b/homeassistant/components/generic/translations/hu.json @@ -15,6 +15,7 @@ "stream_no_video": "Az adatfolyamban nincs vide\u00f3", "stream_not_permitted": "A m\u0171velet nem enged\u00e9lyezett, mik\u00f6zben megpr\u00f3b\u00e1l csatlakozni a folyamhoz. Rossz fajta RTSP protokoll?", "stream_unauthorised": "A hiteles\u00edt\u00e9s meghi\u00fasult, mik\u00f6zben megpr\u00f3b\u00e1lt csatlakozni az adatfolyamhoz", + "template_error": "Hiba t\u00f6rt\u00e9nt a sablon renderel\u00e9se k\u00f6zben. Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az URL bet\u00f6lt\u00e9se k\u00f6zben", "unable_still_load": "Nem siker\u00fclt \u00e9rv\u00e9nyes k\u00e9pet bet\u00f6lteni az \u00e1ll\u00f3k\u00e9p URL-c\u00edm\u00e9r\u0151l (pl. \u00e9rv\u00e9nytelen host, URL vagy hiteles\u00edt\u00e9si hiba). Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" @@ -57,6 +58,7 @@ "stream_no_video": "Az adatfolyamban nincs vide\u00f3", "stream_not_permitted": "A m\u0171velet nem enged\u00e9lyezett, mik\u00f6zben megpr\u00f3b\u00e1l csatlakozni a folyamhoz. Rossz fajta RTSP protokoll?", "stream_unauthorised": "A hiteles\u00edt\u00e9s meghi\u00fasult, mik\u00f6zben megpr\u00f3b\u00e1lt csatlakozni az adatfolyamhoz", + "template_error": "Hiba t\u00f6rt\u00e9nt a sablon renderel\u00e9se k\u00f6zben. Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az URL bet\u00f6lt\u00e9se k\u00f6zben", "unable_still_load": "Nem siker\u00fclt \u00e9rv\u00e9nyes k\u00e9pet bet\u00f6lteni az \u00e1ll\u00f3k\u00e9p URL-c\u00edm\u00e9r\u0151l (pl. \u00e9rv\u00e9nytelen host, URL vagy hiteles\u00edt\u00e9si hiba). Tov\u00e1bbi inform\u00e1ci\u00f3\u00e9rt tekintse \u00e1t a napl\u00f3t.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" diff --git a/homeassistant/components/generic/translations/it.json b/homeassistant/components/generic/translations/it.json index ff4b9822601..1cd63544700 100644 --- a/homeassistant/components/generic/translations/it.json +++ b/homeassistant/components/generic/translations/it.json @@ -15,6 +15,7 @@ "stream_no_video": "Il flusso non ha video", "stream_not_permitted": "Operazione non consentita durante il tentativo di connessione al . Protocollo di trasporto RTSP errato?", "stream_unauthorised": "Autorizzazione non riuscita durante il tentativo di connessione al flusso", + "template_error": "Errore durante l'esecuzione del modello. Esamina il registro per ulteriori informazioni.", "timeout": "Timeout durante il caricamento dell'URL", "unable_still_load": "Impossibile caricare un'immagine valida dall'URL dell'immagine fissa (ad es. host, URL non valido o errore di autenticazione). Esamina il registro per ulteriori informazioni.", "unknown": "Errore imprevisto" @@ -57,6 +58,7 @@ "stream_no_video": "Il flusso non ha video", "stream_not_permitted": "Operazione non consentita durante il tentativo di connessione al . Protocollo di trasporto RTSP errato?", "stream_unauthorised": "Autorizzazione non riuscita durante il tentativo di connessione al flusso", + "template_error": "Errore durante l'esecuzione del modello. Esamina il registro per ulteriori informazioni.", "timeout": "Timeout durante il caricamento dell'URL", "unable_still_load": "Impossibile caricare un'immagine valida dall'URL dell'immagine fissa (ad es. host, URL non valido o errore di autenticazione). Esamina il registro per ulteriori informazioni.", "unknown": "Errore imprevisto" diff --git a/homeassistant/components/generic/translations/pl.json b/homeassistant/components/generic/translations/pl.json index 3669fd78e3f..81817faf236 100644 --- a/homeassistant/components/generic/translations/pl.json +++ b/homeassistant/components/generic/translations/pl.json @@ -15,6 +15,7 @@ "stream_no_video": "Strumie\u0144 nie zawiera wideo", "stream_not_permitted": "Operacja nie jest dozwolona podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem. Z\u0142y protok\u00f3\u0142 transportowy RTSP?", "stream_unauthorised": "Autoryzacja nie powiod\u0142a si\u0119 podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem", + "template_error": "B\u0142\u0105d renderowania szablonu. Przejrzyj log, aby uzyska\u0107 wi\u0119cej informacji.", "timeout": "Przekroczono limit czasu podczas \u0142adowania adresu URL", "unable_still_load": "Nie mo\u017cna za\u0142adowa\u0107 prawid\u0142owego obrazu z adresu URL nieruchomego obrazu (np. nieprawid\u0142owy host, adres URL lub b\u0142\u0105d uwierzytelniania). Przejrzyj logi, aby uzyska\u0107 wi\u0119cej informacji.", "unknown": "Nieoczekiwany b\u0142\u0105d" @@ -57,6 +58,7 @@ "stream_no_video": "Strumie\u0144 nie zawiera wideo", "stream_not_permitted": "Operacja nie jest dozwolona podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem. Z\u0142y protok\u00f3\u0142 transportowy RTSP?", "stream_unauthorised": "Autoryzacja nie powiod\u0142a si\u0119 podczas pr\u00f3by po\u0142\u0105czenia ze strumieniem", + "template_error": "B\u0142\u0105d renderowania szablonu. Przejrzyj log, aby uzyska\u0107 wi\u0119cej informacji.", "timeout": "Przekroczono limit czasu podczas \u0142adowania adresu URL", "unable_still_load": "Nie mo\u017cna za\u0142adowa\u0107 prawid\u0142owego obrazu z adresu URL nieruchomego obrazu (np. nieprawid\u0142owy host, adres URL lub b\u0142\u0105d uwierzytelniania). Przejrzyj logi, aby uzyska\u0107 wi\u0119cej informacji.", "unknown": "Nieoczekiwany b\u0142\u0105d" diff --git a/homeassistant/components/generic/translations/pt-BR.json b/homeassistant/components/generic/translations/pt-BR.json index ea1ede92f22..1a61cdeac97 100644 --- a/homeassistant/components/generic/translations/pt-BR.json +++ b/homeassistant/components/generic/translations/pt-BR.json @@ -15,6 +15,7 @@ "stream_no_video": "A stream n\u00e3o tem v\u00eddeo", "stream_not_permitted": "Opera\u00e7\u00e3o n\u00e3o permitida ao tentar se conectar a stream. Protocolo RTSP errado?", "stream_unauthorised": "Falha na autoriza\u00e7\u00e3o ao tentar se conectar a stream", + "template_error": "Erro ao renderizar o modelo. Revise o registro para obter mais informa\u00e7\u00f5es.", "timeout": "Tempo limite ao carregar a URL", "unable_still_load": "N\u00e3o foi poss\u00edvel carregar uma imagem v\u00e1lida do URL da imagem est\u00e1tica (por exemplo, host inv\u00e1lido, URL ou falha de autentica\u00e7\u00e3o). Revise o log para obter mais informa\u00e7\u00f5es.", "unknown": "Erro inesperado" @@ -57,6 +58,7 @@ "stream_no_video": "A stream n\u00e3o tem v\u00eddeo", "stream_not_permitted": "Opera\u00e7\u00e3o n\u00e3o permitida ao tentar se conectar a stream. Protocolo RTSP errado?", "stream_unauthorised": "Falha na autoriza\u00e7\u00e3o ao tentar se conectar a stream", + "template_error": "Erro ao renderizar o modelo. Revise o registro para obter mais informa\u00e7\u00f5es.", "timeout": "Tempo limite ao carregar a URL", "unable_still_load": "N\u00e3o foi poss\u00edvel carregar uma imagem v\u00e1lida do URL da imagem est\u00e1tica (por exemplo, host inv\u00e1lido, URL ou falha de autentica\u00e7\u00e3o). Revise o log para obter mais informa\u00e7\u00f5es.", "unknown": "Erro inesperado" diff --git a/homeassistant/components/ialarm_xr/translations/de.json b/homeassistant/components/ialarm_xr/translations/de.json new file mode 100644 index 00000000000..ccda778dcf6 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Passwort", + "port": "Port", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/fr.json b/homeassistant/components/ialarm_xr/translations/fr.json new file mode 100644 index 00000000000..dec09505d3e --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te", + "password": "Mot de passe", + "port": "Port", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/hu.json b/homeassistant/components/ialarm_xr/translations/hu.json new file mode 100644 index 00000000000..015de9b4bf6 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "host": "C\u00edm", + "password": "Jelsz\u00f3", + "port": "Port", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/it.json b/homeassistant/components/ialarm_xr/translations/it.json new file mode 100644 index 00000000000..52fb89a1d54 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Password", + "port": "Porta", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/pl.json b/homeassistant/components/ialarm_xr/translations/pl.json new file mode 100644 index 00000000000..137973010ca --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "host": "Nazwa hosta lub adres IP", + "password": "Has\u0142o", + "port": "Port", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/pt-BR.json b/homeassistant/components/ialarm_xr/translations/pt-BR.json new file mode 100644 index 00000000000..f18e4820eac --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falhou ao se conectar", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Senha", + "port": "Porta", + "username": "Nome de usu\u00e1rio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/de.json b/homeassistant/components/totalconnect/translations/de.json index 890bbb753d9..17b8ddca010 100644 --- a/homeassistant/components/totalconnect/translations/de.json +++ b/homeassistant/components/totalconnect/translations/de.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Automatische Umgehung bei schwacher Batterie" + }, + "description": "Automatische Umgehung von Zonen, sobald sie einen niedrigen Batteriestand melden.", + "title": "TotalConnect-Optionen" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/en.json b/homeassistant/components/totalconnect/translations/en.json index 8df2b00a936..f19b6196552 100644 --- a/homeassistant/components/totalconnect/translations/en.json +++ b/homeassistant/components/totalconnect/translations/en.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Auto bypass low battery" + }, + "description": "Automatically bypass zones the moment they report a low battery.", + "title": "TotalConnect Options" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/fr.json b/homeassistant/components/totalconnect/translations/fr.json index fcda553a018..d06fe595890 100644 --- a/homeassistant/components/totalconnect/translations/fr.json +++ b/homeassistant/components/totalconnect/translations/fr.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Contournement automatique en cas de batterie faible" + }, + "description": "Contourner automatiquement les zones d\u00e8s qu'elles signalent une batterie faible.", + "title": "Options TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/hu.json b/homeassistant/components/totalconnect/translations/hu.json index 3bb2b4136c9..0b62895ddcd 100644 --- a/homeassistant/components/totalconnect/translations/hu.json +++ b/homeassistant/components/totalconnect/translations/hu.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Automatikus kiiktat\u00e1s alacsony akkumul\u00e1torral" + }, + "description": "Automatikusan kiiktatja a z\u00f3n\u00e1kat abban a pillanatban, amikor lemer\u00fclt akkumul\u00e1tort jelentenek.", + "title": "TotalConnect be\u00e1ll\u00edt\u00e1sok" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/it.json b/homeassistant/components/totalconnect/translations/it.json index 32f0815d380..13a95fa9cff 100644 --- a/homeassistant/components/totalconnect/translations/it.json +++ b/homeassistant/components/totalconnect/translations/it.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Bypass automatico della batteria scarica" + }, + "description": "Esclusione automatica delle zone nel momento in cui segnalano una batteria scarica.", + "title": "Opzioni TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/pl.json b/homeassistant/components/totalconnect/translations/pl.json index d3927212c82..07645e24ac5 100644 --- a/homeassistant/components/totalconnect/translations/pl.json +++ b/homeassistant/components/totalconnect/translations/pl.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Automatyczne obej\u015bcie niskiego poziomu baterii" + }, + "description": "Automatycznie pomijaj strefy, kt\u00f3re zg\u0142osz\u0105 niski poziom na\u0142adowania baterii.", + "title": "Opcje TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/pt-BR.json b/homeassistant/components/totalconnect/translations/pt-BR.json index 1ffeb1337ec..0ead8fba802 100644 --- a/homeassistant/components/totalconnect/translations/pt-BR.json +++ b/homeassistant/components/totalconnect/translations/pt-BR.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Ignoar autom\u00e1ticamente bateria fraca" + }, + "description": "Desative zonas automaticamente no momento em que relatam uma bateria fraca.", + "title": "Op\u00e7\u00f5es do TotalConnect" + } + } } } \ No newline at end of file From 1ac71455cbcaf05d8b9b13d69b9bd7372c92111c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 26 May 2022 02:54:49 +0200 Subject: [PATCH 004/947] Move remaining metadata to pyproject (#72469) --- .core_files.yaml | 2 +- .pre-commit-config.yaml | 4 ++-- CODEOWNERS | 1 + pyproject.toml | 32 ++++++++++++++++++++++++++++++-- requirements_test.txt | 1 + script/gen_requirements_all.py | 14 +++++++++----- script/hassfest/codeowners.py | 1 + script/hassfest/metadata.py | 21 +++++++++++++-------- script/version_bump.py | 8 ++++---- setup.cfg | 30 ------------------------------ 10 files changed, 62 insertions(+), 52 deletions(-) diff --git a/.core_files.yaml b/.core_files.yaml index 2928d450ce2..55b543a333e 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -132,7 +132,7 @@ requirements: &requirements - homeassistant/package_constraints.txt - script/pip_check - requirements*.txt - - setup.cfg + - pyproject.toml any: - *base_platforms diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 539308c08f1..ff00ce07e0c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -106,7 +106,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(homeassistant/.+/manifest\.json|setup\.cfg|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$ + files: ^(homeassistant/.+/manifest\.json|pyproject\.toml|\.pre-commit-config\.yaml|script/gen_requirements_all\.py)$ - id: hassfest name: hassfest entry: script/run-in-env.sh python3 -m script.hassfest @@ -120,7 +120,7 @@ repos: pass_filenames: false language: script types: [text] - files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|setup\.cfg)$ + files: ^(script/hassfest/metadata\.py|homeassistant/const\.py$|pyproject\.toml)$ - id: hassfest-mypy-config name: hassfest-mypy-config entry: script/run-in-env.sh python3 -m script.hassfest -p mypy_config diff --git a/CODEOWNERS b/CODEOWNERS index 98d60fbfcb7..3baeb6dda68 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -6,6 +6,7 @@ # Home Assistant Core setup.cfg @home-assistant/core +pyproject.toml @home-assistant/core /homeassistant/*.py @home-assistant/core /homeassistant/helpers/ @home-assistant/core /homeassistant/util/ @home-assistant/core diff --git a/pyproject.toml b/pyproject.toml index e0db5324d02..60551dae997 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" +version = "2022.7.0.dev0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" authors = [ - {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} + {name = "The Home Assistant Authors", email = "hello@home-assistant.io"} ] keywords = ["home", "automation"] classifiers = [ @@ -21,7 +22,34 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Topic :: Home Automation", ] -dynamic = ["version", "requires-python", "dependencies"] +requires-python = ">=3.9.0" +dependencies = [ + "aiohttp==3.8.1", + "astral==2.2", + "async_timeout==4.0.2", + "attrs==21.2.0", + "atomicwrites==1.4.0", + "awesomeversion==22.5.1", + "bcrypt==3.1.7", + "certifi>=2021.5.30", + "ciso8601==2.2.0", + # When bumping httpx, please check the version pins of + # httpcore, anyio, and h11 in gen_requirements_all + "httpx==0.22.0", + "ifaddr==0.1.7", + "jinja2==3.1.2", + "PyJWT==2.4.0", + # PyJWT has loose dependency. We want the latest one. + "cryptography==36.0.2", + "pip>=21.0,<22.2", + "python-slugify==4.0.1", + "pyyaml==6.0", + "requests==2.27.1", + "typing-extensions>=3.10.0.2,<5.0", + "voluptuous==0.13.1", + "voluptuous-serialize==2.5.0", + "yarl==1.7.2", +] [project.urls] "Source Code" = "https://github.com/home-assistant/core" diff --git a/requirements_test.txt b/requirements_test.txt index 7e4e29de339..e888d715cd4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -28,6 +28,7 @@ pytest==7.1.1 requests_mock==1.9.2 respx==0.19.0 stdlib-list==0.7.0 +tomli==2.0.1;python_version<"3.11" tqdm==4.49.0 types-atomicwrites==1.4.1 types-croniter==1.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 369045e5124..a7b26d297c9 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 """Generate an updated requirements_all.txt.""" -import configparser import difflib import importlib import os @@ -12,6 +11,11 @@ import sys from homeassistant.util.yaml.loader import load_yaml from script.hassfest.model import Integration +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + COMMENT_REQUIREMENTS = ( "Adafruit_BBIO", "avea", # depends on bluepy @@ -166,10 +170,10 @@ def explore_module(package, explore_children): def core_requirements(): - """Gather core requirements out of setup.cfg.""" - parser = configparser.ConfigParser() - parser.read("setup.cfg") - return parser["options"]["install_requires"].strip().split("\n") + """Gather core requirements out of pyproject.toml.""" + with open("pyproject.toml", "rb") as fp: + data = tomllib.load(fp) + return data["project"]["dependencies"] def gather_recursive_requirements(domain, seen=None): diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py index d2ee6182f22..5511bc8a518 100644 --- a/script/hassfest/codeowners.py +++ b/script/hassfest/codeowners.py @@ -12,6 +12,7 @@ BASE = """ # Home Assistant Core setup.cfg @home-assistant/core +pyproject.toml @home-assistant/core /homeassistant/*.py @home-assistant/core /homeassistant/helpers/ @home-assistant/core /homeassistant/util/ @home-assistant/core diff --git a/script/hassfest/metadata.py b/script/hassfest/metadata.py index ab5ba3f036d..48459eacb72 100644 --- a/script/hassfest/metadata.py +++ b/script/hassfest/metadata.py @@ -1,31 +1,36 @@ """Package metadata validation.""" -import configparser +import sys from homeassistant.const import REQUIRED_PYTHON_VER, __version__ from .model import Config, Integration +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + def validate(integrations: dict[str, Integration], config: Config) -> None: """Validate project metadata keys.""" - metadata_path = config.root / "setup.cfg" - parser = configparser.ConfigParser() - parser.read(metadata_path) + metadata_path = config.root / "pyproject.toml" + with open(metadata_path, "rb") as fp: + data = tomllib.load(fp) try: - if parser["metadata"]["version"] != __version__: + if data["project"]["version"] != __version__: config.add_error( - "metadata", f"'metadata.version' value does not match '{__version__}'" + "metadata", f"'project.version' value does not match '{__version__}'" ) except KeyError: config.add_error("metadata", "No 'metadata.version' key found!") required_py_version = f">={'.'.join(map(str, REQUIRED_PYTHON_VER))}" try: - if parser["options"]["python_requires"] != required_py_version: + if data["project"]["requires-python"] != required_py_version: config.add_error( "metadata", - f"'options.python_requires' value doesn't match '{required_py_version}", + f"'project.requires-python' value doesn't match '{required_py_version}", ) except KeyError: config.add_error("metadata", "No 'options.python_requires' key found!") diff --git a/script/version_bump.py b/script/version_bump.py index d714c5183b7..f7dc37b5e22 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -121,13 +121,13 @@ def write_version(version): def write_version_metadata(version: Version) -> None: - """Update setup.cfg file with new version.""" - with open("setup.cfg") as fp: + """Update pyproject.toml file with new version.""" + with open("pyproject.toml", encoding="utf8") as fp: content = fp.read() - content = re.sub(r"(version\W+=\W).+\n", f"\\g<1>{version}\n", content, count=1) + content = re.sub(r"(version\W+=\W).+\n", f'\\g<1>"{version}"\n', content, count=1) - with open("setup.cfg", "w") as fp: + with open("pyproject.toml", "w", encoding="utf8") as fp: fp.write(content) diff --git a/setup.cfg b/setup.cfg index 15db31bd306..dbf815a56e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,36 +1,6 @@ [metadata] -version = 2022.7.0.dev0 url = https://www.home-assistant.io/ -[options] -python_requires = >=3.9.0 -install_requires = - aiohttp==3.8.1 - astral==2.2 - async_timeout==4.0.2 - attrs==21.2.0 - atomicwrites==1.4.0 - awesomeversion==22.5.1 - bcrypt==3.1.7 - certifi>=2021.5.30 - ciso8601==2.2.0 - # When bumping httpx, please check the version pins of - # httpcore, anyio, and h11 in gen_requirements_all - httpx==0.22.0 - ifaddr==0.1.7 - jinja2==3.1.2 - PyJWT==2.4.0 - # PyJWT has loose dependency. We want the latest one. - cryptography==36.0.2 - pip>=21.0,<22.2 - python-slugify==4.0.1 - pyyaml==6.0 - requests==2.27.1 - typing-extensions>=3.10.0.2,<5.0 - voluptuous==0.13.1 - voluptuous-serialize==2.5.0 - yarl==1.7.2 - [flake8] exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build max-complexity = 25 From bfa7693d18bc9467ed9e16ca9971758ffef81b04 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 May 2022 15:17:08 -1000 Subject: [PATCH 005/947] Fixes for logbook filtering and add it to the live stream (#72501) --- homeassistant/components/logbook/processor.py | 32 ++- .../components/logbook/queries/__init__.py | 12 +- .../components/logbook/queries/all.py | 18 +- .../components/logbook/queries/common.py | 42 +--- .../components/logbook/queries/devices.py | 7 +- .../components/logbook/queries/entities.py | 9 +- homeassistant/components/recorder/filters.py | 100 +++++++--- homeassistant/components/recorder/history.py | 4 +- homeassistant/components/recorder/models.py | 40 +++- tests/components/logbook/test_init.py | 5 +- .../components/logbook/test_websocket_api.py | 185 ++++++++++++++++++ 11 files changed, 340 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 03506695700..ea6002cc62c 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -173,12 +173,6 @@ class EventProcessor: self.filters, self.context_id, ) - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug( - "Literal statement: %s", - stmt.compile(compile_kwargs={"literal_binds": True}), - ) - with session_scope(hass=self.hass) as session: return self.humanify(yield_rows(session.execute(stmt))) @@ -214,20 +208,16 @@ def _humanify( include_entity_name = logbook_run.include_entity_name format_time = logbook_run.format_time - def _keep_row(row: Row | EventAsRow, event_type: str) -> bool: + def _keep_row(row: EventAsRow) -> bool: """Check if the entity_filter rejects a row.""" assert entities_filter is not None - if entity_id := _row_event_data_extract(row, ENTITY_ID_JSON_EXTRACT): + if entity_id := row.entity_id: return entities_filter(entity_id) - - if event_type in external_events: - # If the entity_id isn't described, use the domain that describes - # the event for filtering. - domain: str | None = external_events[event_type][0] - else: - domain = _row_event_data_extract(row, DOMAIN_JSON_EXTRACT) - - return domain is not None and entities_filter(f"{domain}._") + if entity_id := row.data.get(ATTR_ENTITY_ID): + return entities_filter(entity_id) + if domain := row.data.get(ATTR_DOMAIN): + return entities_filter(f"{domain}._") + return True # Process rows for row in rows: @@ -236,12 +226,12 @@ def _humanify( continue event_type = row.event_type if event_type == EVENT_CALL_SERVICE or ( - event_type is not PSUEDO_EVENT_STATE_CHANGED - and entities_filter is not None - and not _keep_row(row, event_type) + entities_filter + # We literally mean is EventAsRow not a subclass of EventAsRow + and type(row) is EventAsRow # pylint: disable=unidiomatic-typecheck + and not _keep_row(row) ): continue - if event_type is PSUEDO_EVENT_STATE_CHANGED: entity_id = row.entity_id assert entity_id is not None diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 3672f1e761c..3c027823612 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -27,8 +27,16 @@ def statement_for_request( # No entities: logbook sends everything for the timeframe # limited by the context_id and the yaml configured filter if not entity_ids and not device_ids: - entity_filter = filters.entity_filter() if filters else None - return all_stmt(start_day, end_day, event_types, entity_filter, context_id) + states_entity_filter = filters.states_entity_filter() if filters else None + events_entity_filter = filters.events_entity_filter() if filters else None + return all_stmt( + start_day, + end_day, + event_types, + states_entity_filter, + events_entity_filter, + context_id, + ) # sqlalchemy caches object quoting, the # json quotable ones must be a different diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index da17c7bddeb..d321578f545 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -22,7 +22,8 @@ def all_stmt( start_day: dt, end_day: dt, event_types: tuple[str, ...], - entity_filter: ClauseList | None = None, + states_entity_filter: ClauseList | None = None, + events_entity_filter: ClauseList | None = None, context_id: str | None = None, ) -> StatementLambdaElement: """Generate a logbook query for all entities.""" @@ -37,12 +38,17 @@ def all_stmt( _states_query_for_context_id(start_day, end_day, context_id), legacy_select_events_context_id(start_day, end_day, context_id), ) - elif entity_filter is not None: - stmt += lambda s: s.union_all( - _states_query_for_all(start_day, end_day).where(entity_filter) - ) else: - stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + if events_entity_filter is not None: + stmt += lambda s: s.where(events_entity_filter) + + if states_entity_filter is not None: + stmt += lambda s: s.union_all( + _states_query_for_all(start_day, end_day).where(states_entity_filter) + ) + else: + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + stmt += lambda s: s.order_by(Events.time_fired) return stmt diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 237fde3f653..6049d6beb81 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -1,22 +1,20 @@ """Queries for logbook.""" from __future__ import annotations -from collections.abc import Callable from datetime import datetime as dt -import json -from typing import Any import sqlalchemy -from sqlalchemy import JSON, select, type_coerce -from sqlalchemy.orm import Query, aliased +from sqlalchemy import select +from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN from homeassistant.components.recorder.models import ( - JSON_VARIENT_CAST, - JSONB_VARIENT_CAST, + OLD_FORMAT_ATTRS_JSON, + OLD_STATE, + SHARED_ATTRS_JSON, EventData, Events, StateAttributes, @@ -30,36 +28,6 @@ CONTINUOUS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in CONTINUOUS_DOMAINS] UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":' UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%" -OLD_STATE = aliased(States, name="old_state") - - -class JSONLiteral(JSON): # type: ignore[misc] - """Teach SA how to literalize json.""" - - def literal_processor(self, dialect: str) -> Callable[[Any], str]: - """Processor to convert a value to JSON.""" - - def process(value: Any) -> str: - """Dump json.""" - return json.dumps(value) - - return process - - -EVENT_DATA_JSON = type_coerce( - EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) -OLD_FORMAT_EVENT_DATA_JSON = type_coerce( - Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) - -SHARED_ATTRS_JSON = type_coerce( - StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) -OLD_FORMAT_ATTRS_JSON = type_coerce( - States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) - PSUEDO_EVENT_STATE_CHANGED = None # Since we don't store event_types and None diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 5e7827b87a0..64a6477017e 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -4,24 +4,21 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt -from sqlalchemy import Column, lambda_stmt, select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import Events, States +from homeassistant.components.recorder.models import DEVICE_ID_IN_EVENT, Events, States from .common import ( - EVENT_DATA_JSON, select_events_context_id_subquery, select_events_context_only, select_events_without_states, select_states_context_only, ) -DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] - def _select_device_id_context_ids_sub_query( start_day: dt, diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 844890c23a9..4fb211688f3 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -5,20 +5,20 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import Column, lambda_stmt, select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect from homeassistant.components.recorder.models import ( + ENTITY_ID_IN_EVENT, ENTITY_ID_LAST_UPDATED_INDEX, + OLD_ENTITY_ID_IN_EVENT, Events, States, ) from .common import ( - EVENT_DATA_JSON, - OLD_FORMAT_EVENT_DATA_JSON, apply_states_filters, select_events_context_id_subquery, select_events_context_only, @@ -27,9 +27,6 @@ from .common import ( select_states_context_only, ) -ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] -OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] - def _select_entities_context_ids_sub_query( start_day: dt, diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index adc746379e6..7f1d0bc597f 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -1,14 +1,18 @@ """Provide pre-made queries on top of the recorder component.""" from __future__ import annotations -from sqlalchemy import not_, or_ +from collections.abc import Callable, Iterable +import json +from typing import Any + +from sqlalchemy import Column, not_, or_ from sqlalchemy.sql.elements import ClauseList from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.typing import ConfigType -from .models import States +from .models import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States DOMAIN = "history" HISTORY_FILTERS = "history_filters" @@ -59,50 +63,84 @@ class Filters: or self.included_entity_globs ) - def entity_filter(self) -> ClauseList: - """Generate the entity filter query.""" + def _generate_filter_for_columns( + self, columns: Iterable[Column], encoder: Callable[[Any], Any] + ) -> ClauseList: includes = [] if self.included_domains: - includes.append( - or_( - *[ - States.entity_id.like(f"{domain}.%") - for domain in self.included_domains - ] - ).self_group() - ) + includes.append(_domain_matcher(self.included_domains, columns, encoder)) if self.included_entities: - includes.append(States.entity_id.in_(self.included_entities)) - for glob in self.included_entity_globs: - includes.append(_glob_to_like(glob)) + includes.append(_entity_matcher(self.included_entities, columns, encoder)) + if self.included_entity_globs: + includes.append( + _globs_to_like(self.included_entity_globs, columns, encoder) + ) excludes = [] if self.excluded_domains: - excludes.append( - or_( - *[ - States.entity_id.like(f"{domain}.%") - for domain in self.excluded_domains - ] - ).self_group() - ) + excludes.append(_domain_matcher(self.excluded_domains, columns, encoder)) if self.excluded_entities: - excludes.append(States.entity_id.in_(self.excluded_entities)) - for glob in self.excluded_entity_globs: - excludes.append(_glob_to_like(glob)) + excludes.append(_entity_matcher(self.excluded_entities, columns, encoder)) + if self.excluded_entity_globs: + excludes.append( + _globs_to_like(self.excluded_entity_globs, columns, encoder) + ) if not includes and not excludes: return None if includes and not excludes: - return or_(*includes) + return or_(*includes).self_group() if not includes and excludes: - return not_(or_(*excludes)) + return not_(or_(*excludes).self_group()) - return or_(*includes) & not_(or_(*excludes)) + return or_(*includes).self_group() & not_(or_(*excludes).self_group()) + + def states_entity_filter(self) -> ClauseList: + """Generate the entity filter query.""" + + def _encoder(data: Any) -> Any: + """Nothing to encode for states since there is no json.""" + return data + + return self._generate_filter_for_columns((States.entity_id,), _encoder) + + def events_entity_filter(self) -> ClauseList: + """Generate the entity filter query.""" + _encoder = json.dumps + return or_( + (ENTITY_ID_IN_EVENT == _encoder(None)) + & (OLD_ENTITY_ID_IN_EVENT == _encoder(None)), + self._generate_filter_for_columns( + (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder + ).self_group(), + ) -def _glob_to_like(glob_str: str) -> ClauseList: +def _globs_to_like( + glob_strs: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] +) -> ClauseList: """Translate glob to sql.""" - return States.entity_id.like(glob_str.translate(GLOB_TO_SQL_CHARS)) + return or_( + column.like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) + for glob_str in glob_strs + for column in columns + ) + + +def _entity_matcher( + entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] +) -> ClauseList: + return or_( + column.in_([encoder(entity_id) for entity_id in entity_ids]) + for column in columns + ) + + +def _domain_matcher( + domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] +) -> ClauseList: + return or_( + column.like(encoder(f"{domain}.%")) for domain in domains for column in columns + ) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 845a2af62bf..7e8e97eafd4 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -236,7 +236,7 @@ def _significant_states_stmt( else: stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.entity_filter() + entity_filter = filters.states_entity_filter() stmt += lambda q: q.filter(entity_filter) stmt += lambda q: q.filter(States.last_updated > start_time) @@ -528,7 +528,7 @@ def _get_states_for_all_stmt( ) stmt += _ignore_domains_filter if filters and filters.has_config: - entity_filter = filters.entity_filter() + entity_filter = filters.states_entity_filter() stmt += lambda q: q.filter(entity_filter) if join_attributes: stmt += lambda q: q.outerjoin( diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 90c2e5e5616..dff8edde79f 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,6 +1,7 @@ """Models for SQLAlchemy.""" from __future__ import annotations +from collections.abc import Callable from datetime import datetime, timedelta import json import logging @@ -9,6 +10,7 @@ from typing import Any, TypedDict, cast, overload import ciso8601 from fnvhash import fnv1a_32 from sqlalchemy import ( + JSON, BigInteger, Boolean, Column, @@ -22,11 +24,12 @@ from sqlalchemy import ( String, Text, distinct, + type_coerce, ) from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.row import Row from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import declarative_base, relationship +from sqlalchemy.orm import aliased, declarative_base, relationship from sqlalchemy.orm.session import Session from homeassistant.components.websocket_api.const import ( @@ -119,6 +122,21 @@ DOUBLE_TYPE = ( .with_variant(oracle.DOUBLE_PRECISION(), "oracle") .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") ) + + +class JSONLiteral(JSON): # type: ignore[misc] + """Teach SA how to literalize json.""" + + def literal_processor(self, dialect: str) -> Callable[[Any], str]: + """Processor to convert a value to JSON.""" + + def process(value: Any) -> str: + """Dump json.""" + return json.dumps(value) + + return process + + EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} @@ -612,6 +630,26 @@ class StatisticsRuns(Base): # type: ignore[misc,valid-type] ) +EVENT_DATA_JSON = type_coerce( + EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) +OLD_FORMAT_EVENT_DATA_JSON = type_coerce( + Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) + +SHARED_ATTRS_JSON = type_coerce( + StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) +OLD_FORMAT_ATTRS_JSON = type_coerce( + States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) + +ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] +OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] +DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] +OLD_STATE = aliased(States, name="old_state") + + @overload def process_timestamp(ts: None) -> None: ... diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 101fb74e690..2903f29f5dc 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -510,7 +510,7 @@ async def test_exclude_described_event(hass, hass_client, recorder_mock): return { "name": "Test Name", "message": "tested a message", - "entity_id": event.data.get(ATTR_ENTITY_ID), + "entity_id": event.data[ATTR_ENTITY_ID], } def async_describe_events(hass, async_describe_event): @@ -2003,13 +2003,12 @@ async def test_include_events_domain_glob(hass, hass_client, recorder_mock): ) await async_recorder_block_till_done(hass) - # Should get excluded by domain hass.bus.async_fire( logbook.EVENT_LOGBOOK_ENTRY, { logbook.ATTR_NAME: "Alarm", logbook.ATTR_MESSAGE: "is triggered", - logbook.ATTR_DOMAIN: "switch", + logbook.ATTR_ENTITY_ID: "switch.any", }, ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 8706ccf7617..02fea4f980f 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -14,16 +14,21 @@ from homeassistant.components.logbook import websocket_api from homeassistant.components.script import EVENT_SCRIPT_STARTED from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.const import ( + ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME, ATTR_UNIT_OF_MEASUREMENT, + CONF_DOMAINS, + CONF_ENTITIES, + CONF_EXCLUDE, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, ) from homeassistant.core import Event, HomeAssistant, State from homeassistant.helpers import device_registry +from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -457,6 +462,186 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): assert isinstance(results[3]["when"], float) +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream with excluded entities.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "automation", "script") + ] + ) + await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: ["light.exc"], + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: "*.excluded", + } + }, + }, + ) + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + state: State = hass.states.get("binary_sensor.is_light") + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": "off", + "when": state.last_updated.timestamp(), + } + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + hass.states.async_set("light.alpha", "on") + hass.states.async_set("light.alpha", "off") + alpha_off_state: State = hass.states.get("light.alpha") + hass.states.async_set("light.zulu", "on", {"color": "blue"}) + hass.states.async_set("light.zulu", "off", {"effect": "help"}) + zulu_off_state: State = hass.states.get("light.zulu") + hass.states.async_set( + "light.zulu", "on", {"effect": "help", "color": ["blue", "green"]} + ) + zulu_on_state: State = hass.states.get("light.zulu") + await hass.async_block_till_done() + + hass.states.async_remove("light.zulu") + await hass.async_block_till_done() + + hass.states.async_set("light.zulu", "on", {"effect": "help", "color": "blue"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "light.alpha", + "state": "off", + "when": alpha_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "off", + "when": zulu_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "on", + "when": zulu_on_state.last_updated.timestamp(), + }, + ] + + await async_wait_recording_done(hass) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.excluded"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + { + ATTR_NAME: "Mock automation switch matching entity", + ATTR_ENTITY_ID: "switch.match_domain", + }, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation switch matching domain", ATTR_DOMAIN: "switch"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation matches nothing"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "light.keep"}, + ) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation matches nothing", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "light.keep", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( hass, recorder_mock, hass_ws_client From 2863c7ee5b2c98fef80aedf17806ba4ac79c0d63 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 26 May 2022 04:31:17 +0200 Subject: [PATCH 006/947] Adjust config-flow type hints in sonarr (#72412) * Adjust config-flow type hints in sonarr * Use mapping for reauth * Update init --- .../components/sonarr/config_flow.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index 0bdcca6c033..8e34d7a7ed4 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Sonarr.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -10,7 +11,7 @@ from aiopyarr.sonarr_client import SonarrClient import voluptuous as vol import yarl -from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult @@ -28,7 +29,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -async def validate_input(hass: HomeAssistant, data: dict) -> None: +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -52,27 +53,28 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 - def __init__(self): + def __init__(self) -> None: """Initialize the flow.""" - self.entry = None + self.entry: ConfigEntry | None = None @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> SonarrOptionsFlowHandler: """Get the options flow for this handler.""" return SonarrOptionsFlowHandler(config_entry) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, user_input: dict[str, str] | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm reauth dialog.""" if user_input is None: + assert self.entry is not None return self.async_show_form( step_id="reauth_confirm", description_placeholders={"url": self.entry.data[CONF_URL]}, @@ -95,7 +97,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL try: - await validate_input(self.hass, user_input) + await _validate_input(self.hass, user_input) except ArrAuthenticationException: errors = {"base": "invalid_auth"} except ArrException: @@ -120,19 +122,20 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _async_reauth_update_entry(self, data: dict) -> FlowResult: + async def _async_reauth_update_entry(self, data: dict[str, Any]) -> FlowResult: """Update existing config entry.""" + assert self.entry is not None self.hass.config_entries.async_update_entry(self.entry, data=data) await self.hass.config_entries.async_reload(self.entry.entry_id) return self.async_abort(reason="reauth_successful") - def _get_user_data_schema(self) -> dict[str, Any]: + def _get_user_data_schema(self) -> dict[vol.Marker, type]: """Get the data schema to display user form.""" if self.entry: return {vol.Required(CONF_API_KEY): str} - data_schema: dict[str, Any] = { + data_schema: dict[vol.Marker, type] = { vol.Required(CONF_URL): str, vol.Required(CONF_API_KEY): str, } @@ -148,11 +151,13 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): class SonarrOptionsFlowHandler(OptionsFlow): """Handle Sonarr client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict[str, int] | None = None): + async def async_step_init( + self, user_input: dict[str, int] | None = None + ) -> FlowResult: """Manage Sonarr options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) From 3537fa1dab21828d65aee1809d88172081ae7f51 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 25 May 2022 17:02:21 -1000 Subject: [PATCH 007/947] Fix flux_led taking a long time to recover after offline (#72507) --- homeassistant/components/flux_led/__init__.py | 21 +++++- .../components/flux_led/config_flow.py | 14 +++- homeassistant/components/flux_led/const.py | 2 + .../components/flux_led/coordinator.py | 5 +- .../components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/flux_led/test_init.py | 70 ++++++++++++++++++- 8 files changed, 110 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py index 17dc28a5edf..e6c1393154a 100644 --- a/homeassistant/components/flux_led/__init__.py +++ b/homeassistant/components/flux_led/__init__.py @@ -15,7 +15,10 @@ from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STARTED, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.event import ( async_track_time_change, async_track_time_interval, @@ -27,6 +30,7 @@ from .const import ( DISCOVER_SCAN_TIMEOUT, DOMAIN, FLUX_LED_DISCOVERY, + FLUX_LED_DISCOVERY_SIGNAL, FLUX_LED_EXCEPTIONS, SIGNAL_STATE_UPDATED, STARTUP_SCAN_TIMEOUT, @@ -196,6 +200,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # to avoid a race condition where the add_update_listener is not # in place in time for the check in async_update_entry_from_discovery entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + async def _async_handle_discovered_device() -> None: + """Handle device discovery.""" + # Force a refresh if the device is now available + if not coordinator.last_update_success: + coordinator.force_next_update = True + await coordinator.async_refresh() + + entry.async_on_unload( + async_dispatcher_connect( + hass, + FLUX_LED_DISCOVERY_SIGNAL.format(entry_id=entry.entry_id), + _async_handle_discovered_device, + ) + ) return True diff --git a/homeassistant/components/flux_led/config_flow.py b/homeassistant/components/flux_led/config_flow.py index dfb6ff4a174..61395d744b3 100644 --- a/homeassistant/components/flux_led/config_flow.py +++ b/homeassistant/components/flux_led/config_flow.py @@ -21,6 +21,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import DiscoveryInfoType from . import async_wifi_bulb_for_host @@ -31,6 +32,7 @@ from .const import ( DEFAULT_EFFECT_SPEED, DISCOVER_SCAN_TIMEOUT, DOMAIN, + FLUX_LED_DISCOVERY_SIGNAL, FLUX_LED_EXCEPTIONS, TRANSITION_GRADUAL, TRANSITION_JUMP, @@ -109,12 +111,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): and ":" in entry.unique_id and mac_matches_by_one(entry.unique_id, mac) ): - if async_update_entry_from_discovery( - self.hass, entry, device, None, allow_update_mac + if ( + async_update_entry_from_discovery( + self.hass, entry, device, None, allow_update_mac + ) + or entry.state == config_entries.ConfigEntryState.SETUP_RETRY ): self.hass.async_create_task( self.hass.config_entries.async_reload(entry.entry_id) ) + else: + async_dispatcher_send( + self.hass, + FLUX_LED_DISCOVERY_SIGNAL.format(entry_id=entry.entry_id), + ) raise AbortFlow("already_configured") async def _async_handle_discovery(self) -> FlowResult: diff --git a/homeassistant/components/flux_led/const.py b/homeassistant/components/flux_led/const.py index 7fa841ec77f..db545aa1e68 100644 --- a/homeassistant/components/flux_led/const.py +++ b/homeassistant/components/flux_led/const.py @@ -74,3 +74,5 @@ EFFECT_SPEED_SUPPORT_MODES: Final = {ColorMode.RGB, ColorMode.RGBW, ColorMode.RG CONF_CUSTOM_EFFECT_COLORS: Final = "custom_effect_colors" CONF_CUSTOM_EFFECT_SPEED_PCT: Final = "custom_effect_speed_pct" CONF_CUSTOM_EFFECT_TRANSITION: Final = "custom_effect_transition" + +FLUX_LED_DISCOVERY_SIGNAL = "flux_led_discovery_{entry_id}" diff --git a/homeassistant/components/flux_led/coordinator.py b/homeassistant/components/flux_led/coordinator.py index 5f2c3c097c0..5a7b3c89216 100644 --- a/homeassistant/components/flux_led/coordinator.py +++ b/homeassistant/components/flux_led/coordinator.py @@ -30,6 +30,7 @@ class FluxLedUpdateCoordinator(DataUpdateCoordinator): self.device = device self.title = entry.title self.entry = entry + self.force_next_update = False super().__init__( hass, _LOGGER, @@ -45,6 +46,8 @@ class FluxLedUpdateCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> None: """Fetch all device and sensor data from api.""" try: - await self.device.async_update() + await self.device.async_update(force=self.force_next_update) except FLUX_LED_EXCEPTIONS as ex: raise UpdateFailed(ex) from ex + finally: + self.force_next_update = False diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index d2eb4e1e2e0..7ccd708f89b 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/flux_led", - "requirements": ["flux_led==0.28.29"], + "requirements": ["flux_led==0.28.30"], "quality_scale": "platinum", "codeowners": ["@icemanch", "@bdraco"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index fbcada246a7..054f38972db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -657,7 +657,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.2 # homeassistant.components.flux_led -flux_led==0.28.29 +flux_led==0.28.30 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f347a7ea082..f35d8da8ec8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -466,7 +466,7 @@ fjaraskupan==1.0.2 flipr-api==1.4.2 # homeassistant.components.flux_led -flux_led==0.28.29 +flux_led==0.28.30 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/tests/components/flux_led/test_init.py b/tests/components/flux_led/test_init.py index b0a2c5dd33b..3504dbf3bea 100644 --- a/tests/components/flux_led/test_init.py +++ b/tests/components/flux_led/test_init.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest +from homeassistant import config_entries from homeassistant.components import flux_led from homeassistant.components.flux_led.const import ( CONF_REMOTE_ACCESS_ENABLED, @@ -19,6 +20,8 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STARTED, + STATE_ON, + STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -27,6 +30,7 @@ from homeassistant.util.dt import utcnow from . import ( DEFAULT_ENTRY_TITLE, + DHCP_DISCOVERY, FLUX_DISCOVERY, FLUX_DISCOVERY_PARTIAL, IP_ADDRESS, @@ -113,6 +117,70 @@ async def test_config_entry_retry(hass: HomeAssistant) -> None: assert config_entry.state == ConfigEntryState.SETUP_RETRY +async def test_config_entry_retry_right_away_on_discovery(hass: HomeAssistant) -> None: + """Test discovery makes the config entry reload if its in a retry state.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data={CONF_HOST: IP_ADDRESS}, unique_id=MAC_ADDRESS + ) + config_entry.add_to_hass(hass) + with _patch_discovery(no_device=True), _patch_wifibulb(no_device=True): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + with _patch_discovery(), _patch_wifibulb(): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + +async def test_coordinator_retry_right_away_on_discovery_already_setup( + hass: HomeAssistant, +) -> None: + """Test discovery makes the coordinator force poll if its already setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: IP_ADDRESS, CONF_NAME: DEFAULT_ENTRY_TITLE}, + unique_id=MAC_ADDRESS, + ) + config_entry.add_to_hass(hass) + bulb = _mocked_bulb() + with _patch_discovery(), _patch_wifibulb(device=bulb): + await async_setup_component(hass, flux_led.DOMAIN, {flux_led.DOMAIN: {}}) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + + entity_id = "light.bulb_rgbcw_ddeeff" + entity_registry = er.async_get(hass) + assert entity_registry.async_get(entity_id).unique_id == MAC_ADDRESS + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + now = utcnow() + bulb.async_update = AsyncMock(side_effect=RuntimeError) + async_fire_time_changed(hass, now + timedelta(seconds=50)) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state.state == STATE_UNAVAILABLE + bulb.async_update = AsyncMock() + + with _patch_discovery(), _patch_wifibulb(): + await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=DHCP_DISCOVERY, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.state == STATE_ON + + @pytest.mark.parametrize( "discovery,title", [ From c1f62d03a0460955e7a01311289a07330abcfb29 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Thu, 26 May 2022 01:12:43 -0300 Subject: [PATCH 008/947] Fix bond device state with v3 firmwares (#72516) --- homeassistant/components/bond/__init__.py | 2 +- homeassistant/components/bond/button.py | 2 +- homeassistant/components/bond/config_flow.py | 2 +- homeassistant/components/bond/cover.py | 2 +- homeassistant/components/bond/entity.py | 10 +++-- homeassistant/components/bond/fan.py | 2 +- homeassistant/components/bond/light.py | 2 +- homeassistant/components/bond/manifest.json | 4 +- homeassistant/components/bond/switch.py | 2 +- homeassistant/components/bond/utils.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bond/common.py | 2 +- tests/components/bond/test_button.py | 2 +- tests/components/bond/test_cover.py | 2 +- tests/components/bond/test_entity.py | 42 +++++++++++++++----- tests/components/bond/test_fan.py | 2 +- tests/components/bond/test_init.py | 2 +- tests/components/bond/test_light.py | 2 +- tests/components/bond/test_switch.py | 2 +- 20 files changed, 59 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 062c1d844c4..557e68272c2 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp import ClientError, ClientResponseError, ClientTimeout -from bond_api import Bond, BPUPSubscriptions, start_bpup +from bond_async import Bond, BPUPSubscriptions, start_bpup from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 0152bedde23..0465e4c51fe 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import logging from typing import Any -from bond_api import Action, BPUPSubscriptions +from bond_async import Action, BPUPSubscriptions from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index d3a7b4adf72..6eba9897468 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -6,7 +6,7 @@ import logging from typing import Any from aiohttp import ClientConnectionError, ClientResponseError -from bond_api import Bond +from bond_async import Bond import voluptuous as vol from homeassistant import config_entries, exceptions diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index a50f7b93bbb..3938de0d4bd 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from bond_api import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, BPUPSubscriptions, DeviceType from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 583f1cd96f7..832e9b5d464 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -8,7 +8,7 @@ import logging from typing import Any from aiohttp import ClientError -from bond_api import BPUPSubscriptions +from bond_async import BPUPSubscriptions from homeassistant.const import ( ATTR_HW_VERSION, @@ -156,9 +156,13 @@ class BondEntity(Entity): self._apply_state(state) @callback - def _async_bpup_callback(self, state: dict) -> None: + def _async_bpup_callback(self, json_msg: dict) -> None: """Process a state change from BPUP.""" - self._async_state_callback(state) + topic = json_msg["t"] + if topic != f"devices/{self._device_id}/state": + return + + self._async_state_callback(json_msg["b"]) self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 9acc7874657..f2f6b15f923 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -6,7 +6,7 @@ import math from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_api import Action, BPUPSubscriptions, DeviceType, Direction +from bond_async import Action, BPUPSubscriptions, DeviceType, Direction import voluptuous as vol from homeassistant.components.fan import ( diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index c0c3fc428b8..55084f37b03 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -5,7 +5,7 @@ import logging from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_api import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, BPUPSubscriptions, DeviceType import voluptuous as vol from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 187602057c0..52e9dd1763f 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,10 +3,10 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-api==0.1.18"], + "requirements": ["bond-async==0.1.20"], "zeroconf": ["_bond._tcp.local."], "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "quality_scale": "platinum", "iot_class": "local_push", - "loggers": ["bond_api"] + "loggers": ["bond_async"] } diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index 01c224d8307..da0b19dd9ff 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_api import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, BPUPSubscriptions, DeviceType import voluptuous as vol from homeassistant.components.switch import SwitchEntity diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index fc78c5758c1..cba213d9450 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast from aiohttp import ClientResponseError -from bond_api import Action, Bond +from bond_async import Action, Bond from homeassistant.util.async_ import gather_with_concurrency diff --git a/requirements_all.txt b/requirements_all.txt index 054f38972db..2404e4331a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ blockchain==1.4.4 # bluepy==1.3.0 # homeassistant.components.bond -bond-api==0.1.18 +bond-async==0.1.20 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f35d8da8ec8..0153a1b3323 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -318,7 +318,7 @@ blebox_uniapi==1.3.3 blinkpy==0.19.0 # homeassistant.components.bond -bond-api==0.1.18 +bond-async==0.1.20 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 9c53c0afb8b..4b45a4016c0 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -8,7 +8,7 @@ from typing import Any from unittest.mock import MagicMock, patch from aiohttp.client_exceptions import ClientResponseError -from bond_api import DeviceType +from bond_async import DeviceType from homeassistant import core from homeassistant.components.bond.const import DOMAIN as BOND_DOMAIN diff --git a/tests/components/bond/test_button.py b/tests/components/bond/test_button.py index ee6e98b8462..4411b25657b 100644 --- a/tests/components/bond/test_button.py +++ b/tests/components/bond/test_button.py @@ -1,6 +1,6 @@ """Tests for the Bond button device.""" -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType from homeassistant import core from homeassistant.components.bond.button import STEP_SIZE diff --git a/tests/components/bond/test_cover.py b/tests/components/bond/test_cover.py index ca467d4a38d..ccb44402a3e 100644 --- a/tests/components/bond/test_cover.py +++ b/tests/components/bond/test_cover.py @@ -1,7 +1,7 @@ """Tests for the Bond cover device.""" from datetime import timedelta -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType from homeassistant import core from homeassistant.components.cover import ( diff --git a/tests/components/bond/test_entity.py b/tests/components/bond/test_entity.py index 122e9c2f04e..9245f4513ed 100644 --- a/tests/components/bond/test_entity.py +++ b/tests/components/bond/test_entity.py @@ -3,7 +3,8 @@ import asyncio from datetime import timedelta from unittest.mock import patch -from bond_api import BPUPSubscriptions, DeviceType +from bond_async import BPUPSubscriptions, DeviceType +from bond_async.bpup import BPUP_ALIVE_TIMEOUT from homeassistant import core from homeassistant.components import fan @@ -44,24 +45,47 @@ async def test_bpup_goes_offline_and_recovers_same_entity(hass: core.HomeAssista bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 3, "direction": 0}, } ) await hass.async_block_till_done() assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + # Send a message for the wrong device to make sure its ignored + # we should never get this callback + bpup_subs.notify( + { + "s": 200, + "t": "devices/other-device-id/state", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + + # Test we ignore messages for the wrong topic + bpup_subs.notify( + { + "s": 200, + "t": "devices/test-device-id/other_topic", + "b": {"power": 1, "speed": 1, "direction": 0}, + } + ) + await hass.async_block_till_done() + assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 100 + bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 1, "direction": 0}, } ) await hass.async_block_till_done() assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 - bpup_subs.last_message_time = 0 + bpup_subs.last_message_time = -BPUP_ALIVE_TIMEOUT with patch_bond_device_state(side_effect=asyncio.TimeoutError): async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) await hass.async_block_till_done() @@ -75,7 +99,7 @@ async def test_bpup_goes_offline_and_recovers_same_entity(hass: core.HomeAssista bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 2, "direction": 0}, } ) @@ -106,7 +130,7 @@ async def test_bpup_goes_offline_and_recovers_different_entity( bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 3, "direction": 0}, } ) @@ -116,14 +140,14 @@ async def test_bpup_goes_offline_and_recovers_different_entity( bpup_subs.notify( { "s": 200, - "t": "bond/test-device-id/update", + "t": "devices/test-device-id/state", "b": {"power": 1, "speed": 1, "direction": 0}, } ) await hass.async_block_till_done() assert hass.states.get("fan.name_1").attributes[fan.ATTR_PERCENTAGE] == 33 - bpup_subs.last_message_time = 0 + bpup_subs.last_message_time = -BPUP_ALIVE_TIMEOUT with patch_bond_device_state(side_effect=asyncio.TimeoutError): async_fire_time_changed(hass, utcnow() + timedelta(seconds=230)) await hass.async_block_till_done() @@ -133,7 +157,7 @@ async def test_bpup_goes_offline_and_recovers_different_entity( bpup_subs.notify( { "s": 200, - "t": "bond/not-this-device-id/update", + "t": "devices/not-this-device-id/state", "b": {"power": 1, "speed": 2, "direction": 0}, } ) diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 061e94595bf..7c860e68efc 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from unittest.mock import call -from bond_api import Action, DeviceType, Direction +from bond_async import Action, DeviceType, Direction import pytest from homeassistant import core diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 88615d98122..03eb490b65e 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -3,7 +3,7 @@ import asyncio from unittest.mock import MagicMock, Mock from aiohttp import ClientConnectionError, ClientResponseError -from bond_api import DeviceType +from bond_async import DeviceType import pytest from homeassistant.components.bond.const import DOMAIN diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index 6556c25efe2..c7d8f195423 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -1,7 +1,7 @@ """Tests for the Bond light device.""" from datetime import timedelta -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType import pytest from homeassistant import core diff --git a/tests/components/bond/test_switch.py b/tests/components/bond/test_switch.py index 619eac69e71..b63bad2d431 100644 --- a/tests/components/bond/test_switch.py +++ b/tests/components/bond/test_switch.py @@ -1,7 +1,7 @@ """Tests for the Bond switch device.""" from datetime import timedelta -from bond_api import Action, DeviceType +from bond_async import Action, DeviceType import pytest from homeassistant import core From 3a998f1d4608aacb25d12d00d02766dd1157d5f1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 26 May 2022 03:03:43 -0400 Subject: [PATCH 009/947] Update node statistics for zwave_js device diagnostics dump (#72509) --- homeassistant/components/zwave_js/diagnostics.py | 4 +++- tests/components/zwave_js/test_diagnostics.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/diagnostics.py b/homeassistant/components/zwave_js/diagnostics.py index 4e1abe37b1b..3372b0eeec0 100644 --- a/homeassistant/components/zwave_js/diagnostics.py +++ b/homeassistant/components/zwave_js/diagnostics.py @@ -155,6 +155,8 @@ async def async_get_device_diagnostics( node = driver.controller.nodes[node_id] entities = get_device_entities(hass, node, device) assert client.version + node_state = redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)) + node_state["statistics"] = node.statistics.data return { "versionInfo": { "driverVersion": client.version.driver_version, @@ -163,5 +165,5 @@ async def async_get_device_diagnostics( "maxSchemaVersion": client.version.max_schema_version, }, "entities": entities, - "state": redact_node_state(async_redact_data(node.data, KEYS_TO_REDACT)), + "state": node_state, } diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index 3fe3bdfeb89..3ac3f32b45a 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -92,7 +92,16 @@ async def test_device_diagnostics( assert len(diagnostics_data["entities"]) == len( list(async_discover_node_values(multisensor_6, device, {device.id: set()})) ) - assert diagnostics_data["state"] == multisensor_6.data + assert diagnostics_data["state"] == { + **multisensor_6.data, + "statistics": { + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "commandsRX": 0, + "commandsTX": 0, + "timeoutResponse": 0, + }, + } async def test_device_diagnostics_error(hass, integration): From e8feecf50b2886e07ec9671ff0b1ffc81d0b6f0f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 26 May 2022 09:58:04 +0200 Subject: [PATCH 010/947] Fix androidtv type hint (#72513) --- homeassistant/components/androidtv/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py index 8a34b8aa858..4b203fc3757 100644 --- a/homeassistant/components/androidtv/__init__.py +++ b/homeassistant/components/androidtv/__init__.py @@ -60,7 +60,7 @@ def get_androidtv_mac(dev_props: dict[str, Any]) -> str | None: def _setup_androidtv( - hass: HomeAssistant, config: dict[str, Any] + hass: HomeAssistant, config: Mapping[str, Any] ) -> tuple[str, PythonRSASigner | None, str]: """Generate an ADB key (if needed) and load it.""" adbkey: str = config.get( From 48cc3638fa08f591eebabedf72dd36610c6c3d0d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 26 May 2022 13:17:08 +0200 Subject: [PATCH 011/947] Cleanup unused function return values (#72512) --- homeassistant/components/shelly/__init__.py | 5 ++--- homeassistant/components/zwave_js/api.py | 11 ++++------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 4551fee5590..012f692c579 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -766,14 +766,13 @@ class RpcDeviceWrapper(update_coordinator.DataUpdateCoordinator): self.device.firmware_version, new_version, ) - result = None try: async with async_timeout.timeout(AIOSHELLY_DEVICE_TIMEOUT_SEC): - result = await self.device.trigger_ota_update(beta=beta) + await self.device.trigger_ota_update(beta=beta) except (asyncio.TimeoutError, OSError) as err: LOGGER.exception("Error while perform ota update: %s", err) - LOGGER.debug("Result of OTA update call: %s", result) + LOGGER.debug("OTA update call successful") async def shutdown(self) -> None: """Shutdown the wrapper.""" diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 5d81dc46803..c2e03981686 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1290,11 +1290,8 @@ async def websocket_remove_failed_node( connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [controller.on("node removed", node_removed)] - result = await controller.async_remove_failed_node(node.node_id) - connection.send_result( - msg[ID], - result, - ) + await controller.async_remove_failed_node(node.node_id) + connection.send_result(msg[ID]) @websocket_api.require_admin @@ -1469,8 +1466,8 @@ async def websocket_refresh_node_info( node.on("interview failed", forward_event), ] - result = await node.async_refresh_info() - connection.send_result(msg[ID], result) + await node.async_refresh_info() + connection.send_result(msg[ID]) @websocket_api.require_admin From 576fc9dc643fdb98be58ca7e3e56e7c3ecc52a02 Mon Sep 17 00:00:00 2001 From: j-a-n Date: Thu, 26 May 2022 13:23:49 +0200 Subject: [PATCH 012/947] Fix Moehlenhoff Alpha2 set_target_temperature and set_heat_area_mode (#72533) Fix set_target_temperature and set_heat_area_mode --- .../components/moehlenhoff_alpha2/__init__.py | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 93ddaa781ab..86306a56033 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -98,7 +98,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): update_data = {"T_TARGET": target_temperature} is_cooling = self.get_cooling() - heat_area_mode = self.data[heat_area_id]["HEATAREA_MODE"] + heat_area_mode = self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] if heat_area_mode == 1: if is_cooling: update_data["T_COOL_DAY"] = target_temperature @@ -116,7 +116,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): raise HomeAssistantError( "Failed to set target temperature, communication error with alpha2 base" ) from http_err - self.data[heat_area_id].update(update_data) + self.data["heat_areas"][heat_area_id].update(update_data) for update_callback in self._listeners: update_callback() @@ -141,25 +141,25 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): "Failed to set heat area mode, communication error with alpha2 base" ) from http_err - self.data[heat_area_id]["HEATAREA_MODE"] = heat_area_mode + self.data["heat_areas"][heat_area_id]["HEATAREA_MODE"] = heat_area_mode is_cooling = self.get_cooling() if heat_area_mode == 1: if is_cooling: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_COOL_DAY" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_COOL_DAY"] else: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_HEAT_DAY" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_HEAT_DAY"] elif heat_area_mode == 2: if is_cooling: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_COOL_NIGHT" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_COOL_NIGHT"] else: - self.data[heat_area_id]["T_TARGET"] = self.data[heat_area_id][ - "T_HEAT_NIGHT" - ] + self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ + "heat_areas" + ][heat_area_id]["T_HEAT_NIGHT"] for update_callback in self._listeners: update_callback() From 33784446f63b7396c88601f48b2f11685d63f13b Mon Sep 17 00:00:00 2001 From: Tom Barbette Date: Thu, 26 May 2022 18:04:22 +0200 Subject: [PATCH 013/947] Add nmbs canceled attribute (#57113) * nmbs: Add canceled attribute If a train is canceled, change the state to canceled and also add an attribute that can be matched. Personnaly I look for the attribute and add a "line-through" CSS style to show my train was canceled. I discovered this was not displayed the hard way :) Signed-off-by: Tom Barbette * Update homeassistant/components/nmbs/sensor.py canceled must be compared as an int, as suggested by @MartinHjelmare Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/nmbs/sensor.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 00624748aba..fdb03652756 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -215,10 +215,9 @@ class NMBSSensor(SensorEntity): delay = get_delay_in_minutes(self._attrs["departure"]["delay"]) departure = get_time_until(self._attrs["departure"]["time"]) + canceled = int(self._attrs["departure"]["canceled"]) attrs = { - "departure": f"In {departure} minutes", - "departure_minutes": departure, "destination": self._station_to, "direction": self._attrs["departure"]["direction"]["name"], "platform_arriving": self._attrs["arrival"]["platform"], @@ -227,6 +226,15 @@ class NMBSSensor(SensorEntity): ATTR_ATTRIBUTION: "https://api.irail.be/", } + if canceled != 1: + attrs["departure"] = f"In {departure} minutes" + attrs["departure_minutes"] = departure + attrs["canceled"] = False + else: + attrs["departure"] = None + attrs["departure_minutes"] = None + attrs["canceled"] = True + if self._show_on_map and self.station_coordinates: attrs[ATTR_LATITUDE] = self.station_coordinates[0] attrs[ATTR_LONGITUDE] = self.station_coordinates[1] From f82ec4d233e6ba34acda3708f8fc6921ba725342 Mon Sep 17 00:00:00 2001 From: rappenze Date: Thu, 26 May 2022 20:52:30 +0200 Subject: [PATCH 014/947] Address issues from late review in fibaro config flow tests (#72553) --- tests/components/fibaro/test_config_flow.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/tests/components/fibaro/test_config_flow.py b/tests/components/fibaro/test_config_flow.py index 6f3e035a2f7..f056f484a58 100644 --- a/tests/components/fibaro/test_config_flow.py +++ b/tests/components/fibaro/test_config_flow.py @@ -53,7 +53,12 @@ async def test_config_flow_user_initiated_success(hass): login_mock = Mock() login_mock.get.return_value = Mock(status=True) - with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + with patch( + "fiblary3.client.v4.client.Client.login", login_mock, create=True + ), patch( + "homeassistant.components.fibaro.async_setup_entry", + return_value=True, + ): result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -182,7 +187,12 @@ async def test_config_flow_import(hass): """Test for importing config from configuration.yaml.""" login_mock = Mock() login_mock.get.return_value = Mock(status=True) - with patch("fiblary3.client.v4.client.Client.login", login_mock, create=True): + with patch( + "fiblary3.client.v4.client.Client.login", login_mock, create=True + ), patch( + "homeassistant.components.fibaro.async_setup_entry", + return_value=True, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, From 828fcd0a48ec81d422b9554306d5fb8b24045ab7 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 26 May 2022 15:17:44 -0400 Subject: [PATCH 015/947] Fix jitter in nzbget uptime sensor (#72518) --- homeassistant/components/nzbget/sensor.py | 30 +++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 9e5bd6e4ac9..a1097389020 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -1,7 +1,7 @@ """Monitor the NZBGet API.""" from __future__ import annotations -from datetime import timedelta +from datetime import datetime, timedelta import logging from homeassistant.components.sensor import ( @@ -105,15 +105,16 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): description: SensorEntityDescription, ) -> None: """Initialize a new NZBGet sensor.""" - self.entity_description = description - self._attr_unique_id = f"{entry_id}_{description.key}" - super().__init__( coordinator=coordinator, entry_id=entry_id, name=f"{entry_name} {description.name}", ) + self.entity_description = description + self._attr_unique_id = f"{entry_id}_{description.key}" + self._native_value: datetime | None = None + @property def native_value(self): """Return the state of the sensor.""" @@ -122,14 +123,17 @@ class NZBGetSensor(NZBGetEntity, SensorEntity): if value is None: _LOGGER.warning("Unable to locate value for %s", sensor_type) - return None - - if "DownloadRate" in sensor_type and value > 0: + self._native_value = None + elif "DownloadRate" in sensor_type and value > 0: # Convert download rate from Bytes/s to MBytes/s - return round(value / 2**20, 2) + self._native_value = round(value / 2**20, 2) + elif "UpTimeSec" in sensor_type and value > 0: + uptime = utcnow().replace(microsecond=0) - timedelta(seconds=value) + if not isinstance(self._attr_native_value, datetime) or abs( + uptime - self._attr_native_value + ) > timedelta(seconds=5): + self._native_value = uptime + else: + self._native_value = value - if "UpTimeSec" in sensor_type and value > 0: - uptime = utcnow() - timedelta(seconds=value) - return uptime.replace(microsecond=0) - - return value + return self._native_value From d1578aacf20a67036823a1b9094b64a09a38b856 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 May 2022 21:41:17 +0200 Subject: [PATCH 016/947] Improve raspberry_pi tests (#72557) --- .../components/raspberry_pi/test_hardware.py | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/tests/components/raspberry_pi/test_hardware.py b/tests/components/raspberry_pi/test_hardware.py index 748972c8d60..a4e938079d3 100644 --- a/tests/components/raspberry_pi/test_hardware.py +++ b/tests/components/raspberry_pi/test_hardware.py @@ -1,25 +1,41 @@ """Test the Raspberry Pi hardware platform.""" +from unittest.mock import patch + import pytest -from homeassistant.components.hassio import DATA_OS_INFO from homeassistant.components.raspberry_pi.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from tests.common import MockModule, mock_integration +from tests.common import MockConfigEntry, MockModule, mock_integration async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: """Test we can get the board info.""" mock_integration(hass, MockModule("hassio")) - hass.data[DATA_OS_INFO] = {"board": "rpi"} - assert await async_setup_component(hass, DOMAIN, {}) + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Raspberry Pi", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.raspberry_pi.get_os_info", + return_value={"board": "rpi"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() client = await hass_ws_client(hass) - await client.send_json({"id": 1, "type": "hardware/info"}) - msg = await client.receive_json() + with patch( + "homeassistant.components.raspberry_pi.hardware.get_os_info", + return_value={"board": "rpi"}, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() assert msg["id"] == 1 assert msg["success"] @@ -43,14 +59,30 @@ async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: async def test_hardware_info_fail(hass: HomeAssistant, hass_ws_client, os_info) -> None: """Test async_info raises if os_info is not as expected.""" mock_integration(hass, MockModule("hassio")) - hass.data[DATA_OS_INFO] = os_info - assert await async_setup_component(hass, DOMAIN, {}) + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Raspberry Pi", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.raspberry_pi.get_os_info", + return_value={"board": "rpi"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() client = await hass_ws_client(hass) - await client.send_json({"id": 1, "type": "hardware/info"}) - msg = await client.receive_json() + with patch( + "homeassistant.components.raspberry_pi.hardware.get_os_info", + return_value=os_info, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() assert msg["id"] == 1 assert msg["success"] From b91a1c1b0aac1f60063dab2ccafb35a2cfd58820 Mon Sep 17 00:00:00 2001 From: jack5mikemotown <72000916+jack5mikemotown@users.noreply.github.com> Date: Thu, 26 May 2022 16:01:23 -0400 Subject: [PATCH 017/947] Fix Google Assistant brightness calculation (#72514) Co-authored-by: Paulus Schoutsen --- homeassistant/components/google_assistant/trait.py | 4 ++-- tests/components/google_assistant/test_google_assistant.py | 2 +- tests/components/google_assistant/test_smart_home.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 20191c61668..42fc43197ea 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -249,7 +249,7 @@ class BrightnessTrait(_Trait): if domain == light.DOMAIN: brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS) if brightness is not None: - response["brightness"] = int(100 * (brightness / 255)) + response["brightness"] = round(100 * (brightness / 255)) else: response["brightness"] = 0 @@ -1948,7 +1948,7 @@ class VolumeTrait(_Trait): level = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) if level is not None: # Convert 0.0-1.0 to 0-100 - response["currentVolume"] = int(level * 100) + response["currentVolume"] = round(level * 100) muted = self.state.attributes.get(media_player.ATTR_MEDIA_VOLUME_MUTED) if muted is not None: diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 8bf0e5573b2..e8a2603cae3 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -225,7 +225,7 @@ async def test_query_request(hass_fixture, assistant_client, auth_header): assert len(devices) == 4 assert devices["light.bed_light"]["on"] is False assert devices["light.ceiling_lights"]["on"] is True - assert devices["light.ceiling_lights"]["brightness"] == 70 + assert devices["light.ceiling_lights"]["brightness"] == 71 assert devices["light.ceiling_lights"]["color"]["temperatureK"] == 2631 assert devices["light.kitchen_lights"]["color"]["spectrumHsv"] == { "hue": 345, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index c3bbd9336f4..4b11910999a 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -385,7 +385,7 @@ async def test_query_message(hass): "light.another_light": { "on": True, "online": True, - "brightness": 30, + "brightness": 31, "color": { "spectrumHsv": { "hue": 180, @@ -1510,7 +1510,7 @@ async def test_query_recover(hass, caplog): "payload": { "devices": { "light.bad": {"online": False}, - "light.good": {"on": True, "online": True, "brightness": 19}, + "light.good": {"on": True, "online": True, "brightness": 20}, } }, } From d092861926a090350c8d3208c62f598388ddfe94 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 26 May 2022 22:02:39 +0200 Subject: [PATCH 018/947] Move manual configuration of MQTT device_tracker to the integration key (#72493) --- homeassistant/components/mqtt/__init__.py | 4 +- .../mqtt/device_tracker/__init__.py | 10 ++- .../mqtt/device_tracker/schema_discovery.py | 26 +++++-- tests/components/mqtt/test_device_tracker.py | 69 +++++++++++++------ 4 files changed, 82 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index df5d52c443a..e8847375584 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -159,6 +159,7 @@ PLATFORMS = [ Platform.BUTTON, Platform.CAMERA, Platform.CLIMATE, + Platform.DEVICE_TRACKER, Platform.COVER, Platform.FAN, Platform.HUMIDIFIER, @@ -196,17 +197,18 @@ PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( vol.Optional(Platform.CAMERA.value): cv.ensure_list, vol.Optional(Platform.CLIMATE.value): cv.ensure_list, vol.Optional(Platform.COVER.value): cv.ensure_list, + vol.Optional(Platform.DEVICE_TRACKER.value): cv.ensure_list, vol.Optional(Platform.FAN.value): cv.ensure_list, vol.Optional(Platform.HUMIDIFIER.value): cv.ensure_list, vol.Optional(Platform.LIGHT.value): cv.ensure_list, vol.Optional(Platform.LOCK.value): cv.ensure_list, + vol.Optional(Platform.NUMBER.value): cv.ensure_list, vol.Optional(Platform.SCENE.value): cv.ensure_list, vol.Optional(Platform.SELECT.value): cv.ensure_list, vol.Optional(Platform.SIREN.value): cv.ensure_list, vol.Optional(Platform.SENSOR.value): cv.ensure_list, vol.Optional(Platform.SWITCH.value): cv.ensure_list, vol.Optional(Platform.VACUUM.value): cv.ensure_list, - vol.Optional(Platform.NUMBER.value): cv.ensure_list, } ) diff --git a/homeassistant/components/mqtt/device_tracker/__init__.py b/homeassistant/components/mqtt/device_tracker/__init__.py index 03574e6554b..bcd5bbd4ee1 100644 --- a/homeassistant/components/mqtt/device_tracker/__init__.py +++ b/homeassistant/components/mqtt/device_tracker/__init__.py @@ -1,7 +1,15 @@ """Support for tracking MQTT enabled devices.""" +import voluptuous as vol + +from homeassistant.components import device_tracker + +from ..mixins import warn_for_legacy_schema from .schema_discovery import async_setup_entry_from_discovery from .schema_yaml import PLATFORM_SCHEMA_YAML, async_setup_scanner_from_yaml -PLATFORM_SCHEMA = PLATFORM_SCHEMA_YAML +# Configuring MQTT Device Trackers under the device_tracker platform key is deprecated in HA Core 2022.6 +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA_YAML, warn_for_legacy_schema(device_tracker.DOMAIN) +) async_setup_scanner = async_setup_scanner_from_yaml async_setup_entry = async_setup_entry_from_discovery diff --git a/homeassistant/components/mqtt/device_tracker/schema_discovery.py b/homeassistant/components/mqtt/device_tracker/schema_discovery.py index a7b597d0689..aa7506bd5e3 100644 --- a/homeassistant/components/mqtt/device_tracker/schema_discovery.py +++ b/homeassistant/components/mqtt/device_tracker/schema_discovery.py @@ -1,4 +1,5 @@ -"""Support for tracking MQTT enabled devices identified through discovery.""" +"""Support for tracking MQTT enabled devices.""" +import asyncio import functools import voluptuous as vol @@ -22,13 +23,18 @@ from .. import MqttValueTemplate, subscription from ... import mqtt from ..const import CONF_QOS, CONF_STATE_TOPIC from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper +from ..mixins import ( + MQTT_ENTITY_COMMON_SCHEMA, + MqttEntity, + async_get_platform_config_from_yaml, + async_setup_entry_helper, +) CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_SOURCE_TYPE = "source_type" -PLATFORM_SCHEMA_DISCOVERY = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, @@ -37,11 +43,21 @@ PLATFORM_SCHEMA_DISCOVERY = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_DISCOVERY.extend({}, extra=vol.REMOVE_EXTRA) +DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) async def async_setup_entry_from_discovery(hass, config_entry, async_add_entities): - """Set up MQTT device tracker dynamically through MQTT discovery.""" + """Set up MQTT device tracker configuration.yaml and dynamically through MQTT discovery.""" + # load and initialize platform config from configuration.yaml + await asyncio.gather( + *( + _async_setup_entity(hass, async_add_entities, config, config_entry) + for config in await async_get_platform_config_from_yaml( + hass, device_tracker.DOMAIN, PLATFORM_SCHEMA_MODERN + ) + ) + ) + # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index c85fcef7dc4..020fbad6166 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -1,22 +1,17 @@ -"""The tests for the MQTT device tracker platform.""" +"""The tests for the MQTT device tracker platform using configuration.yaml.""" from unittest.mock import patch -import pytest - from homeassistant.components.device_tracker.const import DOMAIN, SOURCE_TYPE_BLUETOOTH from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.setup import async_setup_component +from .test_common import help_test_setup_manual_entity_from_yaml + from tests.common import async_fire_mqtt_message -@pytest.fixture(autouse=True) -def setup_comp(hass, mqtt_mock): - """Set up mqtt component.""" - pass - - -async def test_ensure_device_tracker_platform_validation(hass): +# Deprecated in HA Core 2022.6 +async def test_legacy_ensure_device_tracker_platform_validation(hass, mqtt_mock): """Test if platform validation was done.""" async def mock_setup_scanner(hass, config, see, discovery_info=None): @@ -37,7 +32,8 @@ async def test_ensure_device_tracker_platform_validation(hass): assert mock_sp.call_count == 1 -async def test_new_message(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_new_message(hass, mock_device_tracker_conf, mqtt_mock): """Test new message.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -53,7 +49,10 @@ async def test_new_message(hass, mock_device_tracker_conf): assert hass.states.get(entity_id).state == location -async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_single_level_wildcard_topic( + hass, mock_device_tracker_conf, mqtt_mock +): """Test single level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -72,7 +71,10 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): assert hass.states.get(entity_id).state == location -async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_multi_level_wildcard_topic( + hass, mock_device_tracker_conf, mqtt_mock +): """Test multi level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -91,7 +93,10 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): assert hass.states.get(entity_id).state == location -async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_single_level_wildcard_topic_not_matching( + hass, mock_device_tracker_conf, mqtt_mock +): """Test not matching single level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -110,7 +115,10 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke assert hass.states.get(entity_id) is None -async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_multi_level_wildcard_topic_not_matching( + hass, mock_device_tracker_conf, mqtt_mock +): """Test not matching multi level wildcard topic.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -129,8 +137,9 @@ async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker assert hass.states.get(entity_id) is None -async def test_matching_custom_payload_for_home_and_not_home( - hass, mock_device_tracker_conf +# Deprecated in HA Core 2022.6 +async def test_legacy_matching_custom_payload_for_home_and_not_home( + hass, mock_device_tracker_conf, mqtt_mock ): """Test custom payload_home sets state to home and custom payload_not_home sets state to not_home.""" dev_id = "paulus" @@ -161,8 +170,9 @@ async def test_matching_custom_payload_for_home_and_not_home( assert hass.states.get(entity_id).state == STATE_NOT_HOME -async def test_not_matching_custom_payload_for_home_and_not_home( - hass, mock_device_tracker_conf +# Deprecated in HA Core 2022.6 +async def test_legacy_not_matching_custom_payload_for_home_and_not_home( + hass, mock_device_tracker_conf, mqtt_mock ): """Test not matching payload does not set state to home or not_home.""" dev_id = "paulus" @@ -191,7 +201,8 @@ async def test_not_matching_custom_payload_for_home_and_not_home( assert hass.states.get(entity_id).state != STATE_NOT_HOME -async def test_matching_source_type(hass, mock_device_tracker_conf): +# Deprecated in HA Core 2022.6 +async def test_legacy_matching_source_type(hass, mock_device_tracker_conf, mqtt_mock): """Test setting source type.""" dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" @@ -215,3 +226,21 @@ async def test_matching_source_type(hass, mock_device_tracker_conf): async_fire_mqtt_message(hass, topic, location) await hass.async_block_till_done() assert hass.states.get(entity_id).attributes["source_type"] == SOURCE_TYPE_BLUETOOTH + + +async def test_setup_with_modern_schema( + hass, caplog, tmp_path, mock_device_tracker_conf +): + """Test setup using the modern schema.""" + dev_id = "jan" + entity_id = f"{DOMAIN}.{dev_id}" + topic = "/location/jan" + + hass.config.components = {"zone"} + config = {"name": dev_id, "state_topic": topic} + + await help_test_setup_manual_entity_from_yaml( + hass, caplog, tmp_path, DOMAIN, config + ) + + assert hass.states.get(entity_id) is not None From ff3374b4e0c0552aca84ba823db37c854ec66b25 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 May 2022 13:06:34 -0700 Subject: [PATCH 019/947] Use modern WS API for auth integration + add auth provider type to refresh token info (#72552) --- homeassistant/components/auth/__init__.py | 141 +++++++++------------- tests/components/auth/test_init.py | 11 +- 2 files changed, 63 insertions(+), 89 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 30b36a40f32..1dc483eec6e 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -148,43 +148,6 @@ from homeassistant.util import dt as dt_util from . import indieauth, login_flow, mfa_setup_flow DOMAIN = "auth" -WS_TYPE_CURRENT_USER = "auth/current_user" -SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_CURRENT_USER} -) - -WS_TYPE_LONG_LIVED_ACCESS_TOKEN = "auth/long_lived_access_token" -SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_LONG_LIVED_ACCESS_TOKEN, - vol.Required("lifespan"): int, # days - vol.Required("client_name"): str, - vol.Optional("client_icon"): str, - } -) - -WS_TYPE_REFRESH_TOKENS = "auth/refresh_tokens" -SCHEMA_WS_REFRESH_TOKENS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_REFRESH_TOKENS} -) - -WS_TYPE_DELETE_REFRESH_TOKEN = "auth/delete_refresh_token" -SCHEMA_WS_DELETE_REFRESH_TOKEN = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_DELETE_REFRESH_TOKEN, - vol.Required("refresh_token_id"): str, - } -) - -WS_TYPE_SIGN_PATH = "auth/sign_path" -SCHEMA_WS_SIGN_PATH = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - { - vol.Required("type"): WS_TYPE_SIGN_PATH, - vol.Required("path"): str, - vol.Optional("expires", default=30): int, - } -) - RESULT_TYPE_CREDENTIALS = "credentials" @@ -204,27 +167,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.http.register_view(LinkUserView(retrieve_result)) hass.http.register_view(OAuth2AuthorizeCallbackView()) - websocket_api.async_register_command( - hass, WS_TYPE_CURRENT_USER, websocket_current_user, SCHEMA_WS_CURRENT_USER - ) - websocket_api.async_register_command( - hass, - WS_TYPE_LONG_LIVED_ACCESS_TOKEN, - websocket_create_long_lived_access_token, - SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN, - ) - websocket_api.async_register_command( - hass, WS_TYPE_REFRESH_TOKENS, websocket_refresh_tokens, SCHEMA_WS_REFRESH_TOKENS - ) - websocket_api.async_register_command( - hass, - WS_TYPE_DELETE_REFRESH_TOKEN, - websocket_delete_refresh_token, - SCHEMA_WS_DELETE_REFRESH_TOKEN, - ) - websocket_api.async_register_command( - hass, WS_TYPE_SIGN_PATH, websocket_sign_path, SCHEMA_WS_SIGN_PATH - ) + websocket_api.async_register_command(hass, websocket_current_user) + websocket_api.async_register_command(hass, websocket_create_long_lived_access_token) + websocket_api.async_register_command(hass, websocket_refresh_tokens) + websocket_api.async_register_command(hass, websocket_delete_refresh_token) + websocket_api.async_register_command(hass, websocket_sign_path) await login_flow.async_setup(hass, store_result) await mfa_setup_flow.async_setup(hass) @@ -476,6 +423,7 @@ def _create_auth_code_store(): return store_result, retrieve_result +@websocket_api.websocket_command({vol.Required("type"): "auth/current_user"}) @websocket_api.ws_require_user() @websocket_api.async_response async def websocket_current_user( @@ -513,6 +461,14 @@ async def websocket_current_user( ) +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/long_lived_access_token", + vol.Required("lifespan"): int, # days + vol.Required("client_name"): str, + vol.Optional("client_icon"): str, + } +) @websocket_api.ws_require_user() @websocket_api.async_response async def websocket_create_long_lived_access_token( @@ -530,13 +486,13 @@ async def websocket_create_long_lived_access_token( try: access_token = hass.auth.async_create_access_token(refresh_token) except InvalidAuthError as exc: - return websocket_api.error_message( - msg["id"], websocket_api.const.ERR_UNAUTHORIZED, str(exc) - ) + connection.send_error(msg["id"], websocket_api.const.ERR_UNAUTHORIZED, str(exc)) + return - connection.send_message(websocket_api.result_message(msg["id"], access_token)) + connection.send_result(msg["id"], access_token) +@websocket_api.websocket_command({vol.Required("type"): "auth/refresh_tokens"}) @websocket_api.ws_require_user() @callback def websocket_refresh_tokens( @@ -544,27 +500,38 @@ def websocket_refresh_tokens( ): """Return metadata of users refresh tokens.""" current_id = connection.refresh_token_id - connection.send_message( - websocket_api.result_message( - msg["id"], - [ - { - "id": refresh.id, - "client_id": refresh.client_id, - "client_name": refresh.client_name, - "client_icon": refresh.client_icon, - "type": refresh.token_type, - "created_at": refresh.created_at, - "is_current": refresh.id == current_id, - "last_used_at": refresh.last_used_at, - "last_used_ip": refresh.last_used_ip, - } - for refresh in connection.user.refresh_tokens.values() - ], + + tokens = [] + for refresh in connection.user.refresh_tokens.values(): + if refresh.credential: + auth_provider_type = refresh.credential.auth_provider_type + else: + auth_provider_type = None + + tokens.append( + { + "id": refresh.id, + "client_id": refresh.client_id, + "client_name": refresh.client_name, + "client_icon": refresh.client_icon, + "type": refresh.token_type, + "created_at": refresh.created_at, + "is_current": refresh.id == current_id, + "last_used_at": refresh.last_used_at, + "last_used_ip": refresh.last_used_ip, + "auth_provider_type": auth_provider_type, + } ) - ) + + connection.send_result(msg["id"], tokens) +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/delete_refresh_token", + vol.Required("refresh_token_id"): str, + } +) @websocket_api.ws_require_user() @websocket_api.async_response async def websocket_delete_refresh_token( @@ -574,15 +541,21 @@ async def websocket_delete_refresh_token( refresh_token = connection.user.refresh_tokens.get(msg["refresh_token_id"]) if refresh_token is None: - return websocket_api.error_message( - msg["id"], "invalid_token_id", "Received invalid token" - ) + connection.send_error(msg["id"], "invalid_token_id", "Received invalid token") + return await hass.auth.async_remove_refresh_token(refresh_token) - connection.send_message(websocket_api.result_message(msg["id"], {})) + connection.send_result(msg["id"], {}) +@websocket_api.websocket_command( + { + vol.Required("type"): "auth/sign_path", + vol.Required("path"): str, + vol.Optional("expires", default=30): int, + } +) @websocket_api.ws_require_user() @callback def websocket_sign_path( diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index f6d0695d97d..ef231950bd9 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -193,7 +193,7 @@ async def test_ws_current_user(hass, hass_ws_client, hass_access_token): user = refresh_token.user client = await hass_ws_client(hass, hass_access_token) - await client.send_json({"id": 5, "type": auth.WS_TYPE_CURRENT_USER}) + await client.send_json({"id": 5, "type": "auth/current_user"}) result = await client.receive_json() assert result["success"], result @@ -410,7 +410,7 @@ async def test_ws_long_lived_access_token(hass, hass_ws_client, hass_access_toke await ws_client.send_json( { "id": 5, - "type": auth.WS_TYPE_LONG_LIVED_ACCESS_TOKEN, + "type": "auth/long_lived_access_token", "client_name": "GPS Logger", "lifespan": 365, } @@ -434,7 +434,7 @@ async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token): ws_client = await hass_ws_client(hass, hass_access_token) - await ws_client.send_json({"id": 5, "type": auth.WS_TYPE_REFRESH_TOKENS}) + await ws_client.send_json({"id": 5, "type": "auth/refresh_tokens"}) result = await ws_client.receive_json() assert result["success"], result @@ -450,6 +450,7 @@ async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token): assert token["is_current"] is True assert token["last_used_at"] == refresh_token.last_used_at.isoformat() assert token["last_used_ip"] == refresh_token.last_used_ip + assert token["auth_provider_type"] == "homeassistant" async def test_ws_delete_refresh_token( @@ -468,7 +469,7 @@ async def test_ws_delete_refresh_token( await ws_client.send_json( { "id": 5, - "type": auth.WS_TYPE_DELETE_REFRESH_TOKEN, + "type": "auth/delete_refresh_token", "refresh_token_id": refresh_token.id, } ) @@ -490,7 +491,7 @@ async def test_ws_sign_path(hass, hass_ws_client, hass_access_token): await ws_client.send_json( { "id": 5, - "type": auth.WS_TYPE_SIGN_PATH, + "type": "auth/sign_path", "path": "/api/hello", "expires": 20, } From 0cca73fb238906cb2646d764ad92db3480d0ab49 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 26 May 2022 22:15:44 +0200 Subject: [PATCH 020/947] Add hardkernel hardware integration (#72489) * Add hardkernel hardware integration * Remove debug prints * Improve tests * Improve test coverage --- CODEOWNERS | 2 + .../components/hardkernel/__init__.py | 22 +++++ .../components/hardkernel/config_flow.py | 22 +++++ homeassistant/components/hardkernel/const.py | 3 + .../components/hardkernel/hardware.py | 39 ++++++++ .../components/hardkernel/manifest.json | 9 ++ script/hassfest/manifest.py | 1 + tests/components/hardkernel/__init__.py | 1 + .../components/hardkernel/test_config_flow.py | 58 ++++++++++++ tests/components/hardkernel/test_hardware.py | 89 +++++++++++++++++++ tests/components/hardkernel/test_init.py | 72 +++++++++++++++ 11 files changed, 318 insertions(+) create mode 100644 homeassistant/components/hardkernel/__init__.py create mode 100644 homeassistant/components/hardkernel/config_flow.py create mode 100644 homeassistant/components/hardkernel/const.py create mode 100644 homeassistant/components/hardkernel/hardware.py create mode 100644 homeassistant/components/hardkernel/manifest.json create mode 100644 tests/components/hardkernel/__init__.py create mode 100644 tests/components/hardkernel/test_config_flow.py create mode 100644 tests/components/hardkernel/test_hardware.py create mode 100644 tests/components/hardkernel/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 3baeb6dda68..259fbc77aab 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -417,6 +417,8 @@ build.json @home-assistant/supervisor /tests/components/guardian/ @bachya /homeassistant/components/habitica/ @ASMfreaK @leikoilja /tests/components/habitica/ @ASMfreaK @leikoilja +/homeassistant/components/hardkernel/ @home-assistant/core +/tests/components/hardkernel/ @home-assistant/core /homeassistant/components/hardware/ @home-assistant/core /tests/components/hardware/ @home-assistant/core /homeassistant/components/harmony/ @ehendrix23 @bramkragten @bdraco @mkeesey @Aohzan diff --git a/homeassistant/components/hardkernel/__init__.py b/homeassistant/components/hardkernel/__init__.py new file mode 100644 index 00000000000..6dfe30b9e75 --- /dev/null +++ b/homeassistant/components/hardkernel/__init__.py @@ -0,0 +1,22 @@ +"""The Hardkernel integration.""" +from __future__ import annotations + +from homeassistant.components.hassio import get_os_info +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Hardkernel config entry.""" + if (os_info := get_os_info(hass)) is None: + # The hassio integration has not yet fetched data from the supervisor + raise ConfigEntryNotReady + + board: str + if (board := os_info.get("board")) is None or not board.startswith("odroid"): + # Not running on a Hardkernel board, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + return True diff --git a/homeassistant/components/hardkernel/config_flow.py b/homeassistant/components/hardkernel/config_flow.py new file mode 100644 index 00000000000..b0445fae231 --- /dev/null +++ b/homeassistant/components/hardkernel/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for the Hardkernel integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class HardkernelConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Hardkernel.""" + + VERSION = 1 + + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="Hardkernel", data={}) diff --git a/homeassistant/components/hardkernel/const.py b/homeassistant/components/hardkernel/const.py new file mode 100644 index 00000000000..2850f3d4ebb --- /dev/null +++ b/homeassistant/components/hardkernel/const.py @@ -0,0 +1,3 @@ +"""Constants for the Hardkernel integration.""" + +DOMAIN = "hardkernel" diff --git a/homeassistant/components/hardkernel/hardware.py b/homeassistant/components/hardkernel/hardware.py new file mode 100644 index 00000000000..804f105f2ed --- /dev/null +++ b/homeassistant/components/hardkernel/hardware.py @@ -0,0 +1,39 @@ +"""The Hardkernel hardware platform.""" +from __future__ import annotations + +from homeassistant.components.hardware.models import BoardInfo, HardwareInfo +from homeassistant.components.hassio import get_os_info +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +from .const import DOMAIN + +BOARD_NAMES = { + "odroid-c2": "Hardkernel Odroid-C2", + "odroid-c4": "Hardkernel Odroid-C4", + "odroid-n2": "Home Assistant Blue / Hardkernel Odroid-N2", + "odroid-xu4": "Hardkernel Odroid-XU4", +} + + +@callback +def async_info(hass: HomeAssistant) -> HardwareInfo: + """Return board info.""" + if (os_info := get_os_info(hass)) is None: + raise HomeAssistantError + board: str + if (board := os_info.get("board")) is None: + raise HomeAssistantError + if not board.startswith("odroid"): + raise HomeAssistantError + + return HardwareInfo( + board=BoardInfo( + hassio_board_id=board, + manufacturer=DOMAIN, + model=board, + revision=None, + ), + name=BOARD_NAMES.get(board, f"Unknown hardkernel Odroid model '{board}'"), + url=None, + ) diff --git a/homeassistant/components/hardkernel/manifest.json b/homeassistant/components/hardkernel/manifest.json new file mode 100644 index 00000000000..366ca245191 --- /dev/null +++ b/homeassistant/components/hardkernel/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "hardkernel", + "name": "Hardkernel", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/hardkernel", + "dependencies": ["hardware", "hassio"], + "codeowners": ["@home-assistant/core"], + "integration_type": "hardware" +} diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index c478d16cf0f..7f2e8e0d477 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -52,6 +52,7 @@ NO_IOT_CLASS = [ "downloader", "ffmpeg", "frontend", + "hardkernel", "hardware", "history", "homeassistant", diff --git a/tests/components/hardkernel/__init__.py b/tests/components/hardkernel/__init__.py new file mode 100644 index 00000000000..d63b70d5cc5 --- /dev/null +++ b/tests/components/hardkernel/__init__.py @@ -0,0 +1 @@ +"""Tests for the Hardkernel integration.""" diff --git a/tests/components/hardkernel/test_config_flow.py b/tests/components/hardkernel/test_config_flow.py new file mode 100644 index 00000000000..f74b4a4e658 --- /dev/null +++ b/tests/components/hardkernel/test_config_flow.py @@ -0,0 +1,58 @@ +"""Test the Hardkernel config flow.""" +from unittest.mock import patch + +from homeassistant.components.hardkernel.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test the config flow.""" + mock_integration(hass, MockModule("hassio")) + + with patch( + "homeassistant.components.hardkernel.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Hardkernel" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == {} + assert config_entry.title == "Hardkernel" + + +async def test_config_flow_single_entry(hass: HomeAssistant) -> None: + """Test only a single entry is allowed.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.hardkernel.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + mock_setup_entry.assert_not_called() diff --git a/tests/components/hardkernel/test_hardware.py b/tests/components/hardkernel/test_hardware.py new file mode 100644 index 00000000000..1c71959719c --- /dev/null +++ b/tests/components/hardkernel/test_hardware.py @@ -0,0 +1,89 @@ +"""Test the Hardkernel hardware platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.hardkernel.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get the board info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "odroid-n2"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.hardkernel.hardware.get_os_info", + return_value={"board": "odroid-n2"}, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == { + "hardware": [ + { + "board": { + "hassio_board_id": "odroid-n2", + "manufacturer": "hardkernel", + "model": "odroid-n2", + "revision": None, + }, + "name": "Home Assistant Blue / Hardkernel Odroid-N2", + "url": None, + } + ] + } + + +@pytest.mark.parametrize("os_info", [None, {"board": None}, {"board": "other"}]) +async def test_hardware_info_fail(hass: HomeAssistant, hass_ws_client, os_info) -> None: + """Test async_info raises if os_info is not as expected.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "odroid-n2"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.hardkernel.hardware.get_os_info", + return_value=os_info, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == {"hardware": []} diff --git a/tests/components/hardkernel/test_init.py b/tests/components/hardkernel/test_init.py new file mode 100644 index 00000000000..f202777f530 --- /dev/null +++ b/tests/components/hardkernel/test_init.py @@ -0,0 +1,72 @@ +"""Test the Hardkernel integration.""" +from unittest.mock import patch + +from homeassistant.components.hardkernel.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_setup_entry(hass: HomeAssistant) -> None: + """Test setup of a config entry.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "odroid-n2"}, + ) as mock_get_os_info: + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + +async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: + """Test setup of a config entry with wrong board type.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value={"board": "generic-x86-64"}, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + +async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: + """Test setup of a config entry when hassio has not fetched os_info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Hardkernel", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.hardkernel.get_os_info", + return_value=None, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY From 86570fba19892927edd73bdc915da52ae89796ad Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 May 2022 13:57:00 -0700 Subject: [PATCH 021/947] Convert media player enqueue to an enum (#72406) --- .../components/bluesound/media_player.py | 14 +------ homeassistant/components/heos/media_player.py | 16 +++++--- .../components/media_player/__init__.py | 37 ++++++++++++++++- .../media_player/reproduce_state.py | 3 +- .../components/media_player/services.yaml | 16 ++++++++ .../components/sonos/media_player.py | 9 ++--- .../components/squeezebox/media_player.py | 16 ++++---- tests/components/media_player/test_init.py | 40 +++++++++++++++++++ .../media_player/test_reproduce_state.py | 4 -- 9 files changed, 119 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 32f74743972..4fe89d84cf1 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -24,10 +24,7 @@ from homeassistant.components.media_player import ( from homeassistant.components.media_player.browse_media import ( async_process_play_media_url, ) -from homeassistant.components.media_player.const import ( - ATTR_MEDIA_ENQUEUE, - MEDIA_TYPE_MUSIC, -) +from homeassistant.components.media_player.const import MEDIA_TYPE_MUSIC from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, @@ -1023,11 +1020,7 @@ class BluesoundPlayer(MediaPlayerEntity): return await self.send_bluesound_command(f"Play?seek={float(position)}") async def async_play_media(self, media_type, media_id, **kwargs): - """ - Send the play_media command to the media player. - - If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. - """ + """Send the play_media command to the media player.""" if self.is_grouped and not self.is_master: return @@ -1039,9 +1032,6 @@ class BluesoundPlayer(MediaPlayerEntity): url = f"Play?url={media_id}" - if kwargs.get(ATTR_MEDIA_ENQUEUE): - return await self.send_bluesound_command(url) - return await self.send_bluesound_command(url) async def async_volume_up(self): diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index dabe79afb03..29a9b2b2a18 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -12,6 +12,7 @@ from typing_extensions import ParamSpec from homeassistant.components import media_source from homeassistant.components.media_player import ( + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, ) @@ -73,6 +74,14 @@ CONTROL_TO_SUPPORT = { heos_const.CONTROL_PLAY_NEXT: MediaPlayerEntityFeature.NEXT_TRACK, } +HA_HEOS_ENQUEUE_MAP = { + None: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, + MediaPlayerEnqueue.ADD: heos_const.ADD_QUEUE_ADD_TO_END, + MediaPlayerEnqueue.REPLACE: heos_const.ADD_QUEUE_REPLACE_AND_PLAY, + MediaPlayerEnqueue.NEXT: heos_const.ADD_QUEUE_PLAY_NEXT, + MediaPlayerEnqueue.PLAY: heos_const.ADD_QUEUE_PLAY_NOW, +} + _LOGGER = logging.getLogger(__name__) @@ -222,11 +231,8 @@ class HeosMediaPlayer(MediaPlayerEntity): playlist = next((p for p in playlists if p.name == media_id), None) if not playlist: raise ValueError(f"Invalid playlist '{media_id}'") - add_queue_option = ( - heos_const.ADD_QUEUE_ADD_TO_END - if kwargs.get(ATTR_MEDIA_ENQUEUE) - else heos_const.ADD_QUEUE_REPLACE_AND_PLAY - ) + add_queue_option = HA_HEOS_ENQUEUE_MAP.get(kwargs.get(ATTR_MEDIA_ENQUEUE)) + await self._player.add_to_queue(playlist, add_queue_option) return diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index bf006e2bd4e..f71f3fc2a1f 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -147,6 +147,19 @@ ENTITY_IMAGE_CACHE = {CACHE_IMAGES: collections.OrderedDict(), CACHE_MAXSIZE: 16 SCAN_INTERVAL = dt.timedelta(seconds=10) +class MediaPlayerEnqueue(StrEnum): + """Enqueue types for playing media.""" + + # add given media item to end of the queue + ADD = "add" + # play the given media item next, keep queue + NEXT = "next" + # play the given media item now, keep queue + PLAY = "play" + # play the given media item now, clear queue + REPLACE = "replace" + + class MediaPlayerDeviceClass(StrEnum): """Device class for media players.""" @@ -169,7 +182,9 @@ DEVICE_CLASS_RECEIVER = MediaPlayerDeviceClass.RECEIVER.value MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, - vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean, + vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Any( + cv.boolean, vol.Coerce(MediaPlayerEnqueue) + ), vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict, } @@ -350,10 +365,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "async_select_sound_mode", [MediaPlayerEntityFeature.SELECT_SOUND_MODE], ) + + # Remove in Home Assistant 2022.9 + def _rewrite_enqueue(value): + """Rewrite the enqueue value.""" + if ATTR_MEDIA_ENQUEUE not in value: + pass + elif value[ATTR_MEDIA_ENQUEUE] is True: + value[ATTR_MEDIA_ENQUEUE] = MediaPlayerEnqueue.ADD + _LOGGER.warning( + "Playing media with enqueue set to True is deprecated. Use 'add' instead" + ) + elif value[ATTR_MEDIA_ENQUEUE] is False: + value[ATTR_MEDIA_ENQUEUE] = MediaPlayerEnqueue.PLAY + _LOGGER.warning( + "Playing media with enqueue set to False is deprecated. Use 'play' instead" + ) + + return value + component.async_register_entity_service( SERVICE_PLAY_MEDIA, vol.All( cv.make_entity_service_schema(MEDIA_PLAYER_PLAY_MEDIA_SCHEMA), + _rewrite_enqueue, _rename_keys( media_type=ATTR_MEDIA_CONTENT_TYPE, media_id=ATTR_MEDIA_CONTENT_ID, diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index 586ac61b4e1..bdfc0bf3acb 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -27,7 +27,6 @@ from .const import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, @@ -118,7 +117,7 @@ async def _async_reproduce_states( if features & MediaPlayerEntityFeature.PLAY_MEDIA: await call_service( SERVICE_PLAY_MEDIA, - [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_ENQUEUE], + [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID], ) already_playing = True diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 2e8585d0127..b2a8ac40262 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -151,6 +151,22 @@ play_media: selector: text: + enqueue: + name: Enqueue + description: If the content should be played now or be added to the queue. + required: false + selector: + select: + options: + - label: "Play now" + value: "play" + - label: "Play next" + value: "next" + - label: "Add to queue" + value: "add" + - label: "Play now and clear queue" + value: "replace" + select_source: name: Select source description: Send the media player the command to change input source. diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 95834938953..e2a63a86b06 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -18,6 +18,7 @@ import voluptuous as vol from homeassistant.components import media_source, spotify from homeassistant.components.media_player import ( + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, async_process_play_media_url, @@ -537,8 +538,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. - - If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue. """ if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) @@ -573,7 +572,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) if result.shuffle: self.set_shuffle(True) - if kwargs.get(ATTR_MEDIA_ENQUEUE): + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: plex_plugin.add_to_queue(result.media) else: soco.clear_queue() @@ -583,7 +582,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): share_link = self.coordinator.share_link if share_link.is_share_link(media_id): - if kwargs.get(ATTR_MEDIA_ENQUEUE): + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: share_link.add_share_link_to_queue(media_id) else: soco.clear_queue() @@ -593,7 +592,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url(self.hass, media_id) - if kwargs.get(ATTR_MEDIA_ENQUEUE): + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: soco.add_uri_to_queue(media_id) else: soco.play_uri(media_id, force_radio=is_radio) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index bd1f29f4e69..eda742281ee 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -10,6 +10,7 @@ import voluptuous as vol from homeassistant.components import media_source from homeassistant.components.media_player import ( + MediaPlayerEnqueue, MediaPlayerEntity, MediaPlayerEntityFeature, ) @@ -469,16 +470,17 @@ class SqueezeBoxEntity(MediaPlayerEntity): await self._player.async_set_power(True) async def async_play_media(self, media_type, media_id, **kwargs): - """ - Send the play_media command to the media player. - - If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the current playlist. - """ - cmd = "play" + """Send the play_media command to the media player.""" index = None - if kwargs.get(ATTR_MEDIA_ENQUEUE): + enqueue: MediaPlayerEnqueue | None = kwargs.get(ATTR_MEDIA_ENQUEUE) + + if enqueue == MediaPlayerEnqueue.ADD: cmd = "add" + elif enqueue == MediaPlayerEnqueue.NEXT: + cmd = "insert" + else: + cmd = "play" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index aa5e1b164f4..cb095cbcfe0 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -4,6 +4,8 @@ import base64 from http import HTTPStatus from unittest.mock import patch +import pytest + from homeassistant.components import media_player from homeassistant.components.media_player.browse_media import BrowseMedia from homeassistant.components.websocket_api.const import TYPE_RESULT @@ -251,3 +253,41 @@ async def test_group_members_available_when_off(hass): state = hass.states.get("media_player.bedroom") assert state.state == STATE_OFF assert "group_members" in state.attributes + + +@pytest.mark.parametrize( + "input,expected", + ( + (True, media_player.MediaPlayerEnqueue.ADD), + (False, media_player.MediaPlayerEnqueue.PLAY), + ("play", media_player.MediaPlayerEnqueue.PLAY), + ("next", media_player.MediaPlayerEnqueue.NEXT), + ("add", media_player.MediaPlayerEnqueue.ADD), + ("replace", media_player.MediaPlayerEnqueue.REPLACE), + ), +) +async def test_enqueue_rewrite(hass, input, expected): + """Test that group_members are still available when media_player is off.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + # Fake group support for DemoYoutubePlayer + with patch( + "homeassistant.components.demo.media_player.DemoYoutubePlayer.play_media", + ) as mock_play_media: + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media_content_type": "music", + "media_content_id": "1234", + "enqueue": input, + }, + blocking=True, + ) + + assert len(mock_play_media.mock_calls) == 1 + assert mock_play_media.mock_calls[0][2]["enqueue"] == expected diff --git a/tests/components/media_player/test_reproduce_state.py b/tests/components/media_player/test_reproduce_state.py index f1a243337e1..f880130d4bd 100644 --- a/tests/components/media_player/test_reproduce_state.py +++ b/tests/components/media_player/test_reproduce_state.py @@ -6,7 +6,6 @@ from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, - ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, @@ -253,7 +252,6 @@ async def test_play_media(hass): value_1 = "dummy_1" value_2 = "dummy_2" - value_3 = "dummy_3" await async_reproduce_states( hass, @@ -275,7 +273,6 @@ async def test_play_media(hass): { ATTR_MEDIA_CONTENT_TYPE: value_1, ATTR_MEDIA_CONTENT_ID: value_2, - ATTR_MEDIA_ENQUEUE: value_3, }, ) ], @@ -294,5 +291,4 @@ async def test_play_media(hass): "entity_id": ENTITY_1, ATTR_MEDIA_CONTENT_TYPE: value_1, ATTR_MEDIA_CONTENT_ID: value_2, - ATTR_MEDIA_ENQUEUE: value_3, } From d8295e2fad5c0ba9f9a6c8b3ecbd6c0b6481422b Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 26 May 2022 19:20:05 -0400 Subject: [PATCH 022/947] Add logbook entries for zwave_js events (#72508) * Add logbook entries for zwave_js events * Fix test * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/zwave_js/logbook.py Co-authored-by: Martin Hjelmare * black * Remove value updated event Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/__init__.py | 6 +- homeassistant/components/zwave_js/logbook.py | 115 +++++++++++++++ tests/components/zwave_js/test_events.py | 2 +- tests/components/zwave_js/test_logbook.py | 132 ++++++++++++++++++ 4 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/zwave_js/logbook.py create mode 100644 tests/components/zwave_js/test_logbook.py diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 5c583d8321f..4f5756361c8 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -297,8 +297,8 @@ async def setup_driver( # noqa: C901 if not disc_info.assumed_state: return value_updates_disc_info[disc_info.primary_value.value_id] = disc_info - # If this is the first time we found a value we want to watch for updates, - # return early + # If this is not the first time we found a value we want to watch for updates, + # return early because we only need one listener for all values. if len(value_updates_disc_info) != 1: return # add listener for value updated events @@ -503,7 +503,7 @@ async def setup_driver( # noqa: C901 elif isinstance(notification, PowerLevelNotification): event_data.update( { - ATTR_COMMAND_CLASS_NAME: "Power Level", + ATTR_COMMAND_CLASS_NAME: "Powerlevel", ATTR_TEST_NODE_ID: notification.test_node_id, ATTR_STATUS: notification.status, ATTR_ACKNOWLEDGED_FRAMES: notification.acknowledged_frames, diff --git a/homeassistant/components/zwave_js/logbook.py b/homeassistant/components/zwave_js/logbook.py new file mode 100644 index 00000000000..1fe1ff79ec6 --- /dev/null +++ b/homeassistant/components/zwave_js/logbook.py @@ -0,0 +1,115 @@ +"""Describe Z-Wave JS logbook events.""" +from __future__ import annotations + +from collections.abc import Callable + +from zwave_js_server.const import CommandClass + +from homeassistant.components.logbook.const import ( + LOGBOOK_ENTRY_MESSAGE, + LOGBOOK_ENTRY_NAME, +) +from homeassistant.const import ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .const import ( + ATTR_COMMAND_CLASS, + ATTR_COMMAND_CLASS_NAME, + ATTR_DATA_TYPE, + ATTR_DIRECTION, + ATTR_EVENT_LABEL, + ATTR_EVENT_TYPE, + ATTR_LABEL, + ATTR_VALUE, + DOMAIN, + ZWAVE_JS_NOTIFICATION_EVENT, + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, +) + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + dev_reg = dr.async_get(hass) + + @callback + def async_describe_zwave_js_notification_event( + event: Event, + ) -> dict[str, str]: + """Describe Z-Wave JS notification event.""" + device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] + # Z-Wave JS devices always have a name + device_name = device.name_by_user or device.name + assert device_name + + command_class = event.data[ATTR_COMMAND_CLASS] + command_class_name = event.data[ATTR_COMMAND_CLASS_NAME] + + data: dict[str, str] = {LOGBOOK_ENTRY_NAME: device_name} + prefix = f"fired {command_class_name} CC 'notification' event" + + if command_class == CommandClass.NOTIFICATION: + label = event.data[ATTR_LABEL] + event_label = event.data[ATTR_EVENT_LABEL] + return { + **data, + LOGBOOK_ENTRY_MESSAGE: f"{prefix} '{label}': '{event_label}'", + } + + if command_class == CommandClass.ENTRY_CONTROL: + event_type = event.data[ATTR_EVENT_TYPE] + data_type = event.data[ATTR_DATA_TYPE] + return { + **data, + LOGBOOK_ENTRY_MESSAGE: ( + f"{prefix} for event type '{event_type}' with data type " + f"'{data_type}'" + ), + } + + if command_class == CommandClass.SWITCH_MULTILEVEL: + event_type = event.data[ATTR_EVENT_TYPE] + direction = event.data[ATTR_DIRECTION] + return { + **data, + LOGBOOK_ENTRY_MESSAGE: ( + f"{prefix} for event type '{event_type}': '{direction}'" + ), + } + + return {**data, LOGBOOK_ENTRY_MESSAGE: prefix} + + @callback + def async_describe_zwave_js_value_notification_event( + event: Event, + ) -> dict[str, str]: + """Describe Z-Wave JS value notification event.""" + device = dev_reg.devices[event.data[ATTR_DEVICE_ID]] + # Z-Wave JS devices always have a name + device_name = device.name_by_user or device.name + assert device_name + + command_class = event.data[ATTR_COMMAND_CLASS_NAME] + label = event.data[ATTR_LABEL] + value = event.data[ATTR_VALUE] + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: ( + f"fired {command_class} CC 'value notification' event for '{label}': " + f"'{value}'" + ), + } + + async_describe_event( + DOMAIN, ZWAVE_JS_NOTIFICATION_EVENT, async_describe_zwave_js_notification_event + ) + async_describe_event( + DOMAIN, + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, + async_describe_zwave_js_value_notification_event, + ) diff --git a/tests/components/zwave_js/test_events.py b/tests/components/zwave_js/test_events.py index 72da1fcb915..19f38d4aa57 100644 --- a/tests/components/zwave_js/test_events.py +++ b/tests/components/zwave_js/test_events.py @@ -312,7 +312,7 @@ async def test_power_level_notification(hass, hank_binary_switch, integration, c node.receive_event(event) await hass.async_block_till_done() assert len(events) == 1 - assert events[0].data["command_class_name"] == "Power Level" + assert events[0].data["command_class_name"] == "Powerlevel" assert events[0].data["command_class"] == 115 assert events[0].data["test_node_id"] == 1 assert events[0].data["status"] == 0 diff --git a/tests/components/zwave_js/test_logbook.py b/tests/components/zwave_js/test_logbook.py new file mode 100644 index 00000000000..eb02c1bbdcf --- /dev/null +++ b/tests/components/zwave_js/test_logbook.py @@ -0,0 +1,132 @@ +"""The tests for Z-Wave JS logbook.""" +from zwave_js_server.const import CommandClass + +from homeassistant.components.zwave_js.const import ( + ZWAVE_JS_NOTIFICATION_EVENT, + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, +) +from homeassistant.components.zwave_js.helpers import get_device_id +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from tests.components.logbook.common import MockRow, mock_humanify + + +async def test_humanifying_zwave_js_notification_event( + hass, client, lock_schlage_be469, integration +): + """Test humanifying Z-Wave JS notification events.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, lock_schlage_be469)} + ) + assert device + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.NOTIFICATION.value, + "command_class_name": "Notification", + "label": "label", + "event_label": "event_label", + }, + ), + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.ENTRY_CONTROL.value, + "command_class_name": "Entry Control", + "event_type": 1, + "data_type": 2, + }, + ), + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.SWITCH_MULTILEVEL.value, + "command_class_name": "Multilevel Switch", + "event_type": 1, + "direction": "up", + }, + ), + MockRow( + ZWAVE_JS_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.POWERLEVEL.value, + "command_class_name": "Powerlevel", + }, + ), + ], + ) + + assert events[0]["name"] == "Touchscreen Deadbolt" + assert events[0]["domain"] == "zwave_js" + assert ( + events[0]["message"] + == "fired Notification CC 'notification' event 'label': 'event_label'" + ) + + assert events[1]["name"] == "Touchscreen Deadbolt" + assert events[1]["domain"] == "zwave_js" + assert ( + events[1]["message"] + == "fired Entry Control CC 'notification' event for event type '1' with data type '2'" + ) + + assert events[2]["name"] == "Touchscreen Deadbolt" + assert events[2]["domain"] == "zwave_js" + assert ( + events[2]["message"] + == "fired Multilevel Switch CC 'notification' event for event type '1': 'up'" + ) + + assert events[3]["name"] == "Touchscreen Deadbolt" + assert events[3]["domain"] == "zwave_js" + assert events[3]["message"] == "fired Powerlevel CC 'notification' event" + + +async def test_humanifying_zwave_js_value_notification_event( + hass, client, lock_schlage_be469, integration +): + """Test humanifying Z-Wave JS value notification events.""" + dev_reg = dr.async_get(hass) + device = dev_reg.async_get_device( + identifiers={get_device_id(client.driver, lock_schlage_be469)} + ) + assert device + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZWAVE_JS_VALUE_NOTIFICATION_EVENT, + { + "device_id": device.id, + "command_class": CommandClass.SCENE_ACTIVATION.value, + "command_class_name": "Scene Activation", + "label": "Scene ID", + "value": "001", + }, + ), + ], + ) + + assert events[0]["name"] == "Touchscreen Deadbolt" + assert events[0]["domain"] == "zwave_js" + assert ( + events[0]["message"] + == "fired Scene Activation CC 'value notification' event for 'Scene ID': '001'" + ) From 5e52b11050c04d94b02286d260075dfd78d19d98 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 26 May 2022 19:21:50 -0400 Subject: [PATCH 023/947] Add additional data to zwave_js device statistics WS API (#72520) * Add additional data to zwave_js device statistics WS API * Rename variables * fix logic * correct typehint * Update homeassistant/components/zwave_js/api.py Co-authored-by: Martin Hjelmare * black Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/api.py | 36 +++++++++-- tests/components/zwave_js/test_api.py | 80 ++++++++++++++++++++---- 2 files changed, 100 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index c2e03981686..e922571f4b1 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -71,6 +71,7 @@ from .const import ( from .helpers import ( async_enable_statistics, async_get_node_from_device_id, + get_device_id, update_data_collection_preference, ) @@ -2107,15 +2108,42 @@ async def websocket_subscribe_controller_statistics( ) -def _get_node_statistics_dict(statistics: NodeStatistics) -> dict[str, int]: +def _get_node_statistics_dict( + hass: HomeAssistant, statistics: NodeStatistics +) -> dict[str, Any]: """Get dictionary of node statistics.""" - return { + dev_reg = dr.async_get(hass) + + def _convert_node_to_device_id(node: Node) -> str: + """Convert a node to a device id.""" + driver = node.client.driver + assert driver + device = dev_reg.async_get_device({get_device_id(driver, node)}) + assert device + return device.id + + data: dict = { "commands_tx": statistics.commands_tx, "commands_rx": statistics.commands_rx, "commands_dropped_tx": statistics.commands_dropped_tx, "commands_dropped_rx": statistics.commands_dropped_rx, "timeout_response": statistics.timeout_response, + "rtt": statistics.rtt, + "rssi": statistics.rssi, + "lwr": statistics.lwr.as_dict() if statistics.lwr else None, + "nlwr": statistics.nlwr.as_dict() if statistics.nlwr else None, } + for key in ("lwr", "nlwr"): + if not data[key]: + continue + for key_2 in ("repeaters", "route_failed_between"): + if not data[key][key_2]: + continue + data[key][key_2] = [ + _convert_node_to_device_id(node) for node in data[key][key_2] + ] + + return data @websocket_api.require_admin @@ -2151,7 +2179,7 @@ async def websocket_subscribe_node_statistics( "event": event["event"], "source": "node", "node_id": node.node_id, - **_get_node_statistics_dict(statistics), + **_get_node_statistics_dict(hass, statistics), }, ) ) @@ -2167,7 +2195,7 @@ async def websocket_subscribe_node_statistics( "event": "statistics updated", "source": "node", "nodeId": node.node_id, - **_get_node_statistics_dict(node.statistics), + **_get_node_statistics_dict(hass, node.statistics), }, ) ) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e59a923ff44..7f64ed6d87d 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3768,18 +3768,26 @@ async def test_subscribe_controller_statistics( async def test_subscribe_node_statistics( - hass, multisensor_6, integration, client, hass_ws_client + hass, + multisensor_6, + wallmote_central_scene, + zen_31, + integration, + client, + hass_ws_client, ): """Test the subscribe_node_statistics command.""" entry = integration ws_client = await hass_ws_client(hass) - device = get_device(hass, multisensor_6) + multisensor_6_device = get_device(hass, multisensor_6) + zen_31_device = get_device(hass, zen_31) + wallmote_central_scene_device = get_device(hass, wallmote_central_scene) await ws_client.send_json( { ID: 1, TYPE: "zwave_js/subscribe_node_statistics", - DEVICE_ID: device.id, + DEVICE_ID: multisensor_6_device.id, } ) @@ -3797,6 +3805,10 @@ async def test_subscribe_node_statistics( "commands_dropped_tx": 0, "commands_dropped_rx": 0, "timeout_response": 0, + "rtt": None, + "rssi": None, + "lwr": None, + "nlwr": None, } # Fire statistics updated @@ -3808,10 +3820,32 @@ async def test_subscribe_node_statistics( "nodeId": multisensor_6.node_id, "statistics": { "commandsTX": 1, - "commandsRX": 1, - "commandsDroppedTX": 1, - "commandsDroppedRX": 1, - "timeoutResponse": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [wallmote_central_scene.node_id], + "repeaterRSSI": [1], + "routeFailedBetween": [ + zen_31.node_id, + multisensor_6.node_id, + ], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [127], + "routeFailedBetween": [ + multisensor_6.node_id, + zen_31.node_id, + ], + }, }, }, ) @@ -3822,10 +3856,32 @@ async def test_subscribe_node_statistics( "source": "node", "node_id": multisensor_6.node_id, "commands_tx": 1, - "commands_rx": 1, - "commands_dropped_tx": 1, - "commands_dropped_rx": 1, - "timeout_response": 1, + "commands_rx": 2, + "commands_dropped_tx": 3, + "commands_dropped_rx": 4, + "timeout_response": 5, + "rtt": 6, + "rssi": 7, + "lwr": { + "protocol_data_rate": 1, + "rssi": 1, + "repeaters": [wallmote_central_scene_device.id], + "repeater_rssi": [1], + "route_failed_between": [ + zen_31_device.id, + multisensor_6_device.id, + ], + }, + "nlwr": { + "protocol_data_rate": 2, + "rssi": 2, + "repeaters": [], + "repeater_rssi": [127], + "route_failed_between": [ + multisensor_6_device.id, + zen_31_device.id, + ], + }, } # Test sending command with improper entry ID fails @@ -3849,7 +3905,7 @@ async def test_subscribe_node_statistics( { ID: 4, TYPE: "zwave_js/subscribe_node_statistics", - DEVICE_ID: device.id, + DEVICE_ID: multisensor_6_device.id, } ) msg = await ws_client.receive_json() From 26d7c3cff81dcf5c88c1fa10d6688e9073af8d0d Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 27 May 2022 00:24:01 +0000 Subject: [PATCH 024/947] [ci skip] Translation update --- .../alarm_control_panel/translations/sv.json | 3 +++ .../components/generic/translations/ca.json | 2 ++ .../components/generic/translations/id.json | 2 ++ .../components/generic/translations/ja.json | 2 ++ .../components/generic/translations/tr.json | 2 ++ .../components/ialarm_xr/translations/ca.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/et.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/id.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/ja.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/nl.json | 21 +++++++++++++++++++ .../components/ialarm_xr/translations/tr.json | 21 +++++++++++++++++++ .../totalconnect/translations/ca.json | 7 +++++++ .../totalconnect/translations/et.json | 11 ++++++++++ .../totalconnect/translations/id.json | 10 +++++++++ .../totalconnect/translations/ja.json | 11 ++++++++++ .../totalconnect/translations/nl.json | 7 +++++++ .../totalconnect/translations/tr.json | 11 ++++++++++ 17 files changed, 194 insertions(+) create mode 100644 homeassistant/components/ialarm_xr/translations/ca.json create mode 100644 homeassistant/components/ialarm_xr/translations/et.json create mode 100644 homeassistant/components/ialarm_xr/translations/id.json create mode 100644 homeassistant/components/ialarm_xr/translations/ja.json create mode 100644 homeassistant/components/ialarm_xr/translations/nl.json create mode 100644 homeassistant/components/ialarm_xr/translations/tr.json diff --git a/homeassistant/components/alarm_control_panel/translations/sv.json b/homeassistant/components/alarm_control_panel/translations/sv.json index 1f375eb5f1d..cff9e0cbc52 100644 --- a/homeassistant/components/alarm_control_panel/translations/sv.json +++ b/homeassistant/components/alarm_control_panel/translations/sv.json @@ -7,6 +7,9 @@ "disarm": "Avlarma {entity_name}", "trigger": "Utl\u00f6sare {entity_name}" }, + "condition_type": { + "is_triggered": "har utl\u00f6sts" + }, "trigger_type": { "armed_away": "{entity_name} larmad borta", "armed_home": "{entity_name} larmad hemma", diff --git a/homeassistant/components/generic/translations/ca.json b/homeassistant/components/generic/translations/ca.json index 3f2cd6afa47..818a030c8a7 100644 --- a/homeassistant/components/generic/translations/ca.json +++ b/homeassistant/components/generic/translations/ca.json @@ -15,6 +15,7 @@ "stream_no_video": "El flux no cont\u00e9 v\u00eddeo", "stream_not_permitted": "Operaci\u00f3 no permesa mentre s'intentava connectar al flux de dades. Protocol de transport RTSP incorrecte?", "stream_unauthorised": "L'autoritzaci\u00f3 ha fallat mentre s'intentava connectar amb el flux de dades", + "template_error": "Error renderitzant plantilla. Consulta els registres per m\u00e9s informaci\u00f3.", "timeout": "El temps m\u00e0xim de c\u00e0rrega de l'URL ha expirat", "unable_still_load": "No s'ha pogut carregar cap imatge v\u00e0lida des de l'URL d'imatge fixa (pot ser per un amfitri\u00f3 o URL inv\u00e0lid o un error d'autenticaci\u00f3). Revisa els registres per a m\u00e9s informaci\u00f3.", "unknown": "Error inesperat" @@ -57,6 +58,7 @@ "stream_no_video": "El flux no cont\u00e9 v\u00eddeo", "stream_not_permitted": "Operaci\u00f3 no permesa mentre s'intentava connectar al flux de dades. Protocol de transport RTSP incorrecte?", "stream_unauthorised": "L'autoritzaci\u00f3 ha fallat mentre s'intentava connectar amb el flux de dades", + "template_error": "Error renderitzant plantilla. Consulta els registres per m\u00e9s informaci\u00f3.", "timeout": "El temps m\u00e0xim de c\u00e0rrega de l'URL ha expirat", "unable_still_load": "No s'ha pogut carregar cap imatge v\u00e0lida des de l'URL d'imatge fixa (pot ser per un amfitri\u00f3 o URL inv\u00e0lid o un error d'autenticaci\u00f3). Revisa els registres per a m\u00e9s informaci\u00f3.", "unknown": "Error inesperat" diff --git a/homeassistant/components/generic/translations/id.json b/homeassistant/components/generic/translations/id.json index 95c6d6e2ae2..a9c553580ca 100644 --- a/homeassistant/components/generic/translations/id.json +++ b/homeassistant/components/generic/translations/id.json @@ -15,6 +15,7 @@ "stream_no_video": "Streaming tidak memiliki video", "stream_not_permitted": "Operasi tidak diizinkan saat mencoba menyambung ke streaming. Apakah protokol transportasi RTSP salah?", "stream_unauthorised": "Otorisasi gagal saat mencoba menyambung ke streaming", + "template_error": "Kesalahan saat merender templat. Tinjau log untuk info lebih lanjut.", "timeout": "Tenggang waktu habis saat memuat URL", "unable_still_load": "Tidak dapat memuat gambar yang valid dari URL gambar diam (mis. host yang tidak valid, URL, atau kegagalan autentikasi). Tinjau log untuk info lebih lanjut.", "unknown": "Kesalahan yang tidak diharapkan" @@ -57,6 +58,7 @@ "stream_no_video": "Streaming tidak memiliki video", "stream_not_permitted": "Operasi tidak diizinkan saat mencoba menyambung ke streaming. Apakah protokol transportasi RTSP salah?", "stream_unauthorised": "Otorisasi gagal saat mencoba menyambung ke streaming", + "template_error": "Kesalahan saat merender templat. Tinjau log untuk info lebih lanjut.", "timeout": "Tenggang waktu habis saat memuat URL", "unable_still_load": "Tidak dapat memuat gambar yang valid dari URL gambar diam (mis. host yang tidak valid, URL, atau kegagalan autentikasi). Tinjau log untuk info lebih lanjut.", "unknown": "Kesalahan yang tidak diharapkan" diff --git a/homeassistant/components/generic/translations/ja.json b/homeassistant/components/generic/translations/ja.json index a106cbf4cdc..f4fd7d8ec46 100644 --- a/homeassistant/components/generic/translations/ja.json +++ b/homeassistant/components/generic/translations/ja.json @@ -15,6 +15,7 @@ "stream_no_video": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u52d5\u753b\u304c\u3042\u308a\u307e\u305b\u3093", "stream_not_permitted": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u63a5\u7d9a\u3057\u3088\u3046\u3068\u3057\u3066\u3044\u308b\u9593\u3001\u64cd\u4f5c\u3067\u304d\u307e\u305b\u3093\u3002RTSP\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u30d7\u30ed\u30c8\u30b3\u30eb\u3092\u9593\u9055\u3048\u305f\uff1f", "stream_unauthorised": "\u30b9\u30c8\u30ea\u30fc\u30e0\u3078\u306e\u63a5\u7d9a\u6642\u306b\u3001\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "template_error": "\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u306e\u30ec\u30f3\u30c0\u30ea\u30f3\u30b0\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "timeout": "URL\u306e\u8aad\u307f\u8fbc\u307f\u4e2d\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", "unable_still_load": "\u9759\u6b62\u753b\u306eURL\u304b\u3089\u6709\u52b9\u306a\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u3080\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\uff08\u4f8b: \u7121\u52b9\u306a\u30db\u30b9\u30c8\u3001URL\u3001\u307e\u305f\u306f\u8a8d\u8a3c\u5931\u6557)\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" @@ -57,6 +58,7 @@ "stream_no_video": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u52d5\u753b\u304c\u3042\u308a\u307e\u305b\u3093", "stream_not_permitted": "\u30b9\u30c8\u30ea\u30fc\u30e0\u306b\u63a5\u7d9a\u3057\u3088\u3046\u3068\u3057\u3066\u3044\u308b\u9593\u3001\u64cd\u4f5c\u3067\u304d\u307e\u305b\u3093\u3002RTSP\u30c8\u30e9\u30f3\u30b9\u30dd\u30fc\u30c8\u30d7\u30ed\u30c8\u30b3\u30eb\u3092\u9593\u9055\u3048\u305f\uff1f", "stream_unauthorised": "\u30b9\u30c8\u30ea\u30fc\u30e0\u3078\u306e\u63a5\u7d9a\u6642\u306b\u3001\u8a8d\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "template_error": "\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u306e\u30ec\u30f3\u30c0\u30ea\u30f3\u30b0\u4e2d\u306b\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "timeout": "URL\u306e\u8aad\u307f\u8fbc\u307f\u4e2d\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", "unable_still_load": "\u9759\u6b62\u753b\u306eURL\u304b\u3089\u6709\u52b9\u306a\u753b\u50cf\u3092\u8aad\u307f\u8fbc\u3080\u3053\u3068\u304c\u3067\u304d\u307e\u305b\u3093\uff08\u4f8b: \u7121\u52b9\u306a\u30db\u30b9\u30c8\u3001URL\u3001\u307e\u305f\u306f\u8a8d\u8a3c\u5931\u6557)\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001\u30ed\u30b0\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" diff --git a/homeassistant/components/generic/translations/tr.json b/homeassistant/components/generic/translations/tr.json index 7b6ab65f948..d439c559aa5 100644 --- a/homeassistant/components/generic/translations/tr.json +++ b/homeassistant/components/generic/translations/tr.json @@ -15,6 +15,7 @@ "stream_no_video": "Ak\u0131\u015fta video yok", "stream_not_permitted": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken i\u015fleme izin verilmiyor. Yanl\u0131\u015f RTSP aktar\u0131m protokol\u00fc?", "stream_unauthorised": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken yetkilendirme ba\u015far\u0131s\u0131z oldu", + "template_error": "\u015eablon olu\u015fturma hatas\u0131. Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "timeout": "URL y\u00fcklenirken zaman a\u015f\u0131m\u0131", "unable_still_load": "Hareketsiz resim URL'sinden ge\u00e7erli resim y\u00fcklenemiyor (\u00f6r. ge\u00e7ersiz ana bilgisayar, URL veya kimlik do\u011frulama hatas\u0131). Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "unknown": "Beklenmeyen hata" @@ -57,6 +58,7 @@ "stream_no_video": "Ak\u0131\u015fta video yok", "stream_not_permitted": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken i\u015fleme izin verilmiyor. Yanl\u0131\u015f RTSP aktar\u0131m protokol\u00fc?", "stream_unauthorised": "Ak\u0131\u015fa ba\u011flanmaya \u00e7al\u0131\u015f\u0131rken yetkilendirme ba\u015far\u0131s\u0131z oldu", + "template_error": "\u015eablon olu\u015fturma hatas\u0131. Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "timeout": "URL y\u00fcklenirken zaman a\u015f\u0131m\u0131", "unable_still_load": "Hareketsiz resim URL'sinden ge\u00e7erli resim y\u00fcklenemiyor (\u00f6r. ge\u00e7ersiz ana bilgisayar, URL veya kimlik do\u011frulama hatas\u0131). Daha fazla bilgi i\u00e7in g\u00fcnl\u00fc\u011f\u00fc inceleyin.", "unknown": "Beklenmeyen hata" diff --git a/homeassistant/components/ialarm_xr/translations/ca.json b/homeassistant/components/ialarm_xr/translations/ca.json new file mode 100644 index 00000000000..6c5ca634ccc --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "host": "Amfitri\u00f3", + "password": "Contrasenya", + "port": "Port", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/et.json b/homeassistant/components/ialarm_xr/translations/et.json new file mode 100644 index 00000000000..97fc5aa5a29 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Salas\u00f5na", + "port": "Port", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/id.json b/homeassistant/components/ialarm_xr/translations/id.json new file mode 100644 index 00000000000..558b7de6b24 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Kata Sandi", + "port": "Port", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/ja.json b/homeassistant/components/ialarm_xr/translations/ja.json new file mode 100644 index 00000000000..65aac61f40f --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "host": "\u30db\u30b9\u30c8", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "port": "\u30dd\u30fc\u30c8", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/nl.json b/homeassistant/components/ialarm_xr/translations/nl.json new file mode 100644 index 00000000000..3ec5fe68d61 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "host": "Host", + "password": "Wachtwoord", + "port": "Poort", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/tr.json b/homeassistant/components/ialarm_xr/translations/tr.json new file mode 100644 index 00000000000..f15e4339e3c --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "host": "Sunucu", + "password": "Parola", + "port": "Port", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/ca.json b/homeassistant/components/totalconnect/translations/ca.json index 404e07d6b69..0c1a540b8ac 100644 --- a/homeassistant/components/totalconnect/translations/ca.json +++ b/homeassistant/components/totalconnect/translations/ca.json @@ -28,5 +28,12 @@ } } } + }, + "options": { + "step": { + "init": { + "title": "Opcions de TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/et.json b/homeassistant/components/totalconnect/translations/et.json index c4bca75a558..192476efa72 100644 --- a/homeassistant/components/totalconnect/translations/et.json +++ b/homeassistant/components/totalconnect/translations/et.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Aku t\u00fchjenemise automaatne eiramine" + }, + "description": "Eira tsoone automaatselt kui nad teatavad t\u00fchjast akust.", + "title": "TotalConnecti valikud" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/id.json b/homeassistant/components/totalconnect/translations/id.json index 1702ceb5688..08bce345e75 100644 --- a/homeassistant/components/totalconnect/translations/id.json +++ b/homeassistant/components/totalconnect/translations/id.json @@ -28,5 +28,15 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Otomatis dilewatkan saat baterai lemah" + }, + "title": "Opsi TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/ja.json b/homeassistant/components/totalconnect/translations/ja.json index c20b55d5583..1e7750b2442 100644 --- a/homeassistant/components/totalconnect/translations/ja.json +++ b/homeassistant/components/totalconnect/translations/ja.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "\u30d0\u30c3\u30c6\u30ea\u30fc\u6b8b\u91cf(\u4f4e)\u3067\u306e\u81ea\u52d5\u30d0\u30a4\u30d1\u30b9" + }, + "description": "\u30d0\u30c3\u30c6\u30ea\u30fc\u6b8b\u91cf\u304c\u5c11\u306a\u3044\u3068\u5831\u544a\u3055\u308c\u305f\u77ac\u9593\u306b\u3001\u81ea\u52d5\u7684\u306b\u30be\u30fc\u30f3\u3092\u30d0\u30a4\u30d1\u30b9\u3057\u307e\u3059\u3002", + "title": "TotalConnect\u30aa\u30d7\u30b7\u30e7\u30f3" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/nl.json b/homeassistant/components/totalconnect/translations/nl.json index cf9a6bb10a1..aaf06b0b70f 100644 --- a/homeassistant/components/totalconnect/translations/nl.json +++ b/homeassistant/components/totalconnect/translations/nl.json @@ -28,5 +28,12 @@ } } } + }, + "options": { + "step": { + "init": { + "title": "TotalConnect-opties" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/tr.json b/homeassistant/components/totalconnect/translations/tr.json index ef50457f846..f40eba3d429 100644 --- a/homeassistant/components/totalconnect/translations/tr.json +++ b/homeassistant/components/totalconnect/translations/tr.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "D\u00fc\u015f\u00fck pili otomatik atla" + }, + "description": "D\u00fc\u015f\u00fck pil bildirdikleri anda b\u00f6lgeleri otomatik olarak atlay\u0131n.", + "title": "Toplam Ba\u011flant\u0131 Se\u00e7enekleri" + } + } } } \ No newline at end of file From 93f0945772e1bf407001e7c4d899c41d2708d63f Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 27 May 2022 05:48:52 +0200 Subject: [PATCH 025/947] Update frontend to 20220526.0 (#72567) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6c8f568c4d2..48488bc8f47 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220525.0"], + "requirements": ["home-assistant-frontend==20220526.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5a6dc9891a6..309d4b89e9f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220525.0 +home-assistant-frontend==20220526.0 httpx==0.22.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2404e4331a1..c7459285fa0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,7 +822,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220525.0 +home-assistant-frontend==20220526.0 # homeassistant.components.home_connect homeconnect==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0153a1b3323..7233824a3cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -589,7 +589,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220525.0 +home-assistant-frontend==20220526.0 # homeassistant.components.home_connect homeconnect==0.7.0 From 465210784fcf945e3fea7e7b5c801f935edc0f79 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 27 May 2022 05:51:24 +0200 Subject: [PATCH 026/947] fjaraskupan: Don't set hardware filters for service id (#72569) --- homeassistant/components/fjaraskupan/__init__.py | 4 ++-- homeassistant/components/fjaraskupan/config_flow.py | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index 488139b080b..ec4528bc079 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -9,7 +9,7 @@ import logging from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from fjaraskupan import UUID_SERVICE, Device, State, device_filter +from fjaraskupan import DEVICE_NAME, Device, State, device_filter from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -90,7 +90,7 @@ class EntryState: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Fjäråskupan from a config entry.""" - scanner = BleakScanner(filters={"UUIDs": [str(UUID_SERVICE)]}) + scanner = BleakScanner(filters={"Pattern": DEVICE_NAME, "DuplicateData": True}) state = EntryState(scanner, {}) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py index da0a7f1dd2b..3af34c0eef6 100644 --- a/homeassistant/components/fjaraskupan/config_flow.py +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -7,7 +7,7 @@ import async_timeout from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from fjaraskupan import UUID_SERVICE, device_filter +from fjaraskupan import DEVICE_NAME, device_filter from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_flow import register_discovery_flow @@ -27,7 +27,8 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: event.set() async with BleakScanner( - detection_callback=detection, filters={"UUIDs": [str(UUID_SERVICE)]} + detection_callback=detection, + filters={"Pattern": DEVICE_NAME, "DuplicateData": True}, ): try: async with async_timeout.timeout(CONST_WAIT_TIME): From 049c06061ce92834b0c82b0e8b06ae7520322e54 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 May 2022 17:54:26 -1000 Subject: [PATCH 027/947] Fix memory leak when firing state_changed events (#72571) --- homeassistant/components/recorder/models.py | 2 +- homeassistant/core.py | 45 +++++++++++++++++---- tests/test_core.py | 45 +++++++++++++++++++++ 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index dff8edde79f..70c816c2af5 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -746,7 +746,7 @@ class LazyState(State): def context(self) -> Context: # type: ignore[override] """State context.""" if self._context is None: - self._context = Context(id=None) # type: ignore[arg-type] + self._context = Context(id=None) return self._context @context.setter diff --git a/homeassistant/core.py b/homeassistant/core.py index 2753b801347..d7cae4e411e 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -37,7 +37,6 @@ from typing import ( ) from urllib.parse import urlparse -import attr import voluptuous as vol import yarl @@ -716,14 +715,26 @@ class HomeAssistant: self._stopped.set() -@attr.s(slots=True, frozen=False) class Context: """The context that triggered something.""" - user_id: str | None = attr.ib(default=None) - parent_id: str | None = attr.ib(default=None) - id: str = attr.ib(factory=ulid_util.ulid) - origin_event: Event | None = attr.ib(default=None, eq=False) + __slots__ = ("user_id", "parent_id", "id", "origin_event") + + def __init__( + self, + user_id: str | None = None, + parent_id: str | None = None, + id: str | None = None, # pylint: disable=redefined-builtin + ) -> None: + """Init the context.""" + self.id = id or ulid_util.ulid() + self.user_id = user_id + self.parent_id = parent_id + self.origin_event: Event | None = None + + def __eq__(self, other: Any) -> bool: + """Compare contexts.""" + return bool(self.__class__ == other.__class__ and self.id == other.id) def as_dict(self) -> dict[str, str | None]: """Return a dictionary representation of the context.""" @@ -1163,6 +1174,24 @@ class State: context, ) + def expire(self) -> None: + """Mark the state as old. + + We give up the original reference to the context to ensure + the context can be garbage collected by replacing it with + a new one with the same id to ensure the old state + can still be examined for comparison against the new state. + + Since we are always going to fire a EVENT_STATE_CHANGED event + after we remove a state from the state machine we need to make + sure we don't end up holding a reference to the original context + since it can never be garbage collected as each event would + reference the previous one. + """ + self.context = Context( + self.context.user_id, self.context.parent_id, self.context.id + ) + def __eq__(self, other: Any) -> bool: """Return the comparison of the state.""" return ( # type: ignore[no-any-return] @@ -1303,6 +1332,7 @@ class StateMachine: if old_state is None: return False + old_state.expire() self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": None}, @@ -1396,7 +1426,6 @@ class StateMachine: if context is None: context = Context(id=ulid_util.ulid(dt_util.utc_to_timestamp(now))) - state = State( entity_id, new_state, @@ -1406,6 +1435,8 @@ class StateMachine: context, old_state is None, ) + if old_state is not None: + old_state.expire() self._states[entity_id] = state self._bus.async_fire( EVENT_STATE_CHANGED, diff --git a/tests/test_core.py b/tests/test_core.py index ee1005a60b0..67513ea8b17 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,9 +6,11 @@ import array import asyncio from datetime import datetime, timedelta import functools +import gc import logging import os from tempfile import TemporaryDirectory +from typing import Any from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest @@ -1829,3 +1831,46 @@ async def test_event_context(hass): cancel2() assert dummy_event2.context.origin_event == dummy_event + + +def _get_full_name(obj) -> str: + """Get the full name of an object in memory.""" + objtype = type(obj) + name = objtype.__name__ + if module := getattr(objtype, "__module__", None): + return f"{module}.{name}" + return name + + +def _get_by_type(full_name: str) -> list[Any]: + """Get all objects in memory with a specific type.""" + return [obj for obj in gc.get_objects() if _get_full_name(obj) == full_name] + + +# The logger will hold a strong reference to the event for the life of the tests +# so we must patch it out +@pytest.mark.skipif( + not os.environ.get("DEBUG_MEMORY"), + reason="Takes too long on the CI", +) +@patch.object(ha._LOGGER, "debug", lambda *args: None) +async def test_state_changed_events_to_not_leak_contexts(hass): + """Test state changed events do not leak contexts.""" + gc.collect() + # Other tests can log Contexts which keep them in memory + # so we need to look at how many exist at the start + init_count = len(_get_by_type("homeassistant.core.Context")) + + assert len(_get_by_type("homeassistant.core.Context")) == init_count + for i in range(20): + hass.states.async_set("light.switch", str(i)) + await hass.async_block_till_done() + gc.collect() + + assert len(_get_by_type("homeassistant.core.Context")) == init_count + 2 + + hass.states.async_remove("light.switch") + await hass.async_block_till_done() + gc.collect() + + assert len(_get_by_type("homeassistant.core.Context")) == init_count From a526b2b819919bc872382f95a77250894e22c174 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 May 2022 18:15:56 -1000 Subject: [PATCH 028/947] Add support for async_remove_config_entry_device to bond (#72511) --- homeassistant/components/bond/__init__.py | 21 ++++++++ tests/components/bond/common.py | 23 ++++++++ tests/components/bond/test_fan.py | 10 +--- tests/components/bond/test_init.py | 66 ++++++++++++++++++++++- 4 files changed, 110 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 557e68272c2..476423631c3 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -119,3 +119,24 @@ def _async_remove_old_device_identifiers( continue if config_entry_id in dev.config_entries: device_registry.async_remove_device(dev.id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove bond config entry from a device.""" + hub: BondHub = hass.data[DOMAIN][config_entry.entry_id][HUB] + for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN or len(identifier) != 3: + continue + bond_id: str = identifier[1] + # Bond still uses the 3 arg tuple before + # the identifiers were typed + device_id: str = identifier[2] # type: ignore[misc] + # If device_id is no longer present on + # the hub, we allow removal. + if hub.bond_id != bond_id or not any( + device_id == device.device_id for device in hub.devices + ): + return True + return False diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index 4b45a4016c0..c5a649ab30a 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -19,6 +19,20 @@ from homeassistant.util import utcnow from tests.common import MockConfigEntry, async_fire_time_changed +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + def ceiling_fan_with_breeze(name: str): """Create a ceiling fan with given name with breeze support.""" return { @@ -246,3 +260,12 @@ async def help_test_entity_available( async_fire_time_changed(hass, utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() assert hass.states.get(entity_id).state != STATE_UNAVAILABLE + + +def ceiling_fan(name: str): + """Create a ceiling fan with given name.""" + return { + "name": name, + "type": DeviceType.CEILING_FAN, + "actions": ["SetSpeed", "SetDirection"], + } diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py index 7c860e68efc..305c131125f 100644 --- a/tests/components/bond/test_fan.py +++ b/tests/components/bond/test_fan.py @@ -33,6 +33,7 @@ from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.util import utcnow from .common import ( + ceiling_fan, help_test_entity_available, patch_bond_action, patch_bond_action_returns_clientresponseerror, @@ -43,15 +44,6 @@ from .common import ( from tests.common import async_fire_time_changed -def ceiling_fan(name: str): - """Create a ceiling fan with given name.""" - return { - "name": name, - "type": DeviceType.CEILING_FAN, - "actions": ["SetSpeed", "SetDirection"], - } - - def ceiling_fan_with_breeze(name: str): """Create a ceiling fan with given name with breeze support.""" return { diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 03eb490b65e..5db5d8e65bf 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -7,13 +7,16 @@ from bond_async import DeviceType import pytest from homeassistant.components.bond.const import DOMAIN +from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry from homeassistant.setup import async_setup_component from .common import ( + ceiling_fan, patch_bond_bridge, patch_bond_device, patch_bond_device_ids, @@ -22,7 +25,9 @@ from .common import ( patch_bond_version, patch_setup_entry, patch_start_bpup, + remove_device, setup_bond_entity, + setup_platform, ) from tests.common import MockConfigEntry @@ -279,3 +284,62 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant): device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) assert device is not None assert device.suggested_area == "Office" + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + config_entry = await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "test-hub-id"}, + bond_device_id="test-device-id", + ) + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["fan.name_1"] + assert entity.unique_id == "test-hub-id_test-device-id" + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, config_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test-hub-id", "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "wrong-hub-id", "test-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) + + hub_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "test-hub-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), hub_device_entry.id, config_entry.entry_id + ) + is False + ) From cbd0c8976b593927a33b8de26169fa106f4a620d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 26 May 2022 22:15:20 -0700 Subject: [PATCH 029/947] Attach SSL context to SMTP notify and IMAP sensor (#72568) --- .../components/imap_email_content/sensor.py | 26 ++++++++++------ homeassistant/components/smtp/notify.py | 30 +++++++++++++------ tests/components/smtp/test_notify.py | 1 + 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index d0d87e0b2d5..a8bd394a159 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -17,12 +17,14 @@ from homeassistant.const import ( CONF_PORT, CONF_USERNAME, CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util.ssl import client_context _LOGGER = logging.getLogger(__name__) @@ -46,6 +48,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_FOLDER, default="INBOX"): cv.string, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, } ) @@ -58,11 +61,12 @@ def setup_platform( ) -> None: """Set up the Email sensor platform.""" reader = EmailReader( - config.get(CONF_USERNAME), - config.get(CONF_PASSWORD), - config.get(CONF_SERVER), - config.get(CONF_PORT), - config.get(CONF_FOLDER), + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_SERVER], + config[CONF_PORT], + config[CONF_FOLDER], + config[CONF_VERIFY_SSL], ) if (value_template := config.get(CONF_VALUE_TEMPLATE)) is not None: @@ -70,8 +74,8 @@ def setup_platform( sensor = EmailContentSensor( hass, reader, - config.get(CONF_NAME) or config.get(CONF_USERNAME), - config.get(CONF_SENDERS), + config.get(CONF_NAME) or config[CONF_USERNAME], + config[CONF_SENDERS], value_template, ) @@ -82,21 +86,25 @@ def setup_platform( class EmailReader: """A class to read emails from an IMAP server.""" - def __init__(self, user, password, server, port, folder): + def __init__(self, user, password, server, port, folder, verify_ssl): """Initialize the Email Reader.""" self._user = user self._password = password self._server = server self._port = port self._folder = folder + self._verify_ssl = verify_ssl self._last_id = None self._unread_ids = deque([]) self.connection = None def connect(self): """Login and setup the connection.""" + ssl_context = client_context() if self._verify_ssl else None try: - self.connection = imaplib.IMAP4_SSL(self._server, self._port) + self.connection = imaplib.IMAP4_SSL( + self._server, self._port, ssl_context=ssl_context + ) self.connection.login(self._user, self._password) return True except imaplib.IMAP4.error: diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 7b8e2dad1ed..866d7980d08 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -25,10 +25,12 @@ from homeassistant.const import ( CONF_SENDER, CONF_TIMEOUT, CONF_USERNAME, + CONF_VERIFY_SSL, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.reload import setup_reload_service import homeassistant.util.dt as dt_util +from homeassistant.util.ssl import client_context from . import DOMAIN, PLATFORMS @@ -65,6 +67,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_SENDER_NAME): cv.string, vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, } ) @@ -73,16 +76,17 @@ def get_service(hass, config, discovery_info=None): """Get the mail notification service.""" setup_reload_service(hass, DOMAIN, PLATFORMS) mail_service = MailNotificationService( - config.get(CONF_SERVER), - config.get(CONF_PORT), - config.get(CONF_TIMEOUT), - config.get(CONF_SENDER), - config.get(CONF_ENCRYPTION), + config[CONF_SERVER], + config[CONF_PORT], + config[CONF_TIMEOUT], + config[CONF_SENDER], + config[CONF_ENCRYPTION], config.get(CONF_USERNAME), config.get(CONF_PASSWORD), - config.get(CONF_RECIPIENT), + config[CONF_RECIPIENT], config.get(CONF_SENDER_NAME), - config.get(CONF_DEBUG), + config[CONF_DEBUG], + config[CONF_VERIFY_SSL], ) if mail_service.connection_is_valid(): @@ -106,6 +110,7 @@ class MailNotificationService(BaseNotificationService): recipients, sender_name, debug, + verify_ssl, ): """Initialize the SMTP service.""" self._server = server @@ -118,18 +123,25 @@ class MailNotificationService(BaseNotificationService): self.recipients = recipients self._sender_name = sender_name self.debug = debug + self._verify_ssl = verify_ssl self.tries = 2 def connect(self): """Connect/authenticate to SMTP Server.""" + ssl_context = client_context() if self._verify_ssl else None if self.encryption == "tls": - mail = smtplib.SMTP_SSL(self._server, self._port, timeout=self._timeout) + mail = smtplib.SMTP_SSL( + self._server, + self._port, + timeout=self._timeout, + context=ssl_context, + ) else: mail = smtplib.SMTP(self._server, self._port, timeout=self._timeout) mail.set_debuglevel(self.debug) mail.ehlo_or_helo_if_needed() if self.encryption == "starttls": - mail.starttls() + mail.starttls(context=ssl_context) mail.ehlo() if self.username and self.password: mail.login(self.username, self.password) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 38f48c169ac..ac742e10ea1 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -76,6 +76,7 @@ def message(): ["recip1@example.com", "testrecip@test.com"], "Home Assistant", 0, + True, ) yield mailer From cc42a95100327bb59fe69f1d8d95f1fa19fcd5e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 08:36:32 +0200 Subject: [PATCH 030/947] Migrate xiaomi_miio light to color_mode (#70998) --- homeassistant/components/xiaomi_miio/light.py | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index e97c6e76503..28feb23d93e 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -19,9 +19,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + ColorMode, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -234,6 +232,9 @@ async def async_setup_entry( class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): """Representation of a Abstract Xiaomi Philips Light.""" + _attr_color_mode = ColorMode.BRIGHTNESS + _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + def __init__(self, name, device, entry, unique_id): """Initialize the light device.""" super().__init__(name, device, entry, unique_id) @@ -263,11 +264,6 @@ class XiaomiPhilipsAbstractLight(XiaomiMiioEntity, LightEntity): """Return the brightness of this light between 0..255.""" return self._brightness - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS - async def _try_command(self, mask_error, func, *args, **kwargs): """Call a light command handling error messages.""" try: @@ -399,6 +395,9 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): """Representation of a Xiaomi Philips Bulb.""" + _attr_color_mode = ColorMode.COLOR_TEMP + _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + def __init__(self, name, device, entry, unique_id): """Initialize the light device.""" super().__init__(name, device, entry, unique_id) @@ -420,11 +419,6 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): """Return the warmest color_temp that this light supports.""" return 333 - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_COLOR_TEMP in kwargs: @@ -760,6 +754,8 @@ class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight): class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): """Representation of a Xiaomi Philips Zhirui Bedside Lamp.""" + _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} + def __init__(self, name, device, entry, unique_id): """Initialize the light device.""" super().__init__(name, device, entry, unique_id) @@ -792,9 +788,11 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): return self._hs_color @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP + def color_mode(self): + """Return the color mode of the light.""" + if self.hs_color: + return ColorMode.HS + return ColorMode.COLOR_TEMP async def async_turn_on(self, **kwargs): """Turn the light on.""" @@ -935,6 +933,9 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb): class XiaomiGatewayLight(LightEntity): """Representation of a gateway device's light.""" + _attr_color_mode = ColorMode.HS + _attr_supported_color_modes = {ColorMode.HS} + def __init__(self, gateway_device, gateway_name, gateway_device_id): """Initialize the XiaomiGatewayLight.""" self._gateway = gateway_device @@ -984,11 +985,6 @@ class XiaomiGatewayLight(LightEntity): """Return the hs color value.""" return self._hs - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR - def turn_on(self, **kwargs): """Turn the light on.""" if ATTR_HS_COLOR in kwargs: @@ -1036,6 +1032,9 @@ class XiaomiGatewayLight(LightEntity): class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): """Representation of Xiaomi Gateway Bulb.""" + _attr_color_mode = ColorMode.COLOR_TEMP + _attr_supported_color_modes = {ColorMode.COLOR_TEMP} + @property def brightness(self): """Return the brightness of the light.""" @@ -1061,11 +1060,6 @@ class XiaomiGatewayBulb(XiaomiGatewayDevice, LightEntity): """Return max cct.""" return self._sub_device.status["cct_max"] - @property - def supported_features(self): - """Return the supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP - async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" await self.hass.async_add_executor_job(self._sub_device.on) From 01b5f984144987a9042042f1df7b16c888b150c3 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Fri, 27 May 2022 17:20:37 +1000 Subject: [PATCH 031/947] Bump httpx to 0.23.0 (#72573) Co-authored-by: J. Nick Koston --- homeassistant/package_constraints.txt | 6 +++--- pyproject.toml | 2 +- requirements.txt | 2 +- script/gen_requirements_all.py | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 309d4b89e9f..f235cc3f02c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 home-assistant-frontend==20220526.0 -httpx==0.22.0 +httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.7 @@ -78,9 +78,9 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.5.0 +anyio==3.6.1 h11==0.12.0 -httpcore==0.14.7 +httpcore==0.15.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation diff --git a/pyproject.toml b/pyproject.toml index 60551dae997..499376b95f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,7 @@ dependencies = [ "ciso8601==2.2.0", # When bumping httpx, please check the version pins of # httpcore, anyio, and h11 in gen_requirements_all - "httpx==0.22.0", + "httpx==0.23.0", "ifaddr==0.1.7", "jinja2==3.1.2", "PyJWT==2.4.0", diff --git a/requirements.txt b/requirements.txt index 0c13b9c319b..8321e70f8de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ awesomeversion==22.5.1 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 -httpx==0.22.0 +httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a7b26d297c9..adf57f14f97 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -98,9 +98,9 @@ regex==2021.8.28 # these requirements are quite loose. As the entire stack has some outstanding issues, and # even newer versions seem to introduce new issues, it's useful for us to pin all these # requirements so we can directly link HA versions to these library versions. -anyio==3.5.0 +anyio==3.6.1 h11==0.12.0 -httpcore==0.14.7 +httpcore==0.15.0 # Ensure we have a hyperframe version that works in Python 3.10 # 5.2.0 fixed a collections abc deprecation From 9cd9d06bccfd3e73ec989ba2692229c129507914 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 27 May 2022 02:46:22 -0500 Subject: [PATCH 032/947] Avoid network activity during Plex tests (#72499) --- tests/components/plex/test_config_flow.py | 6 ++++++ tests/components/plex/test_init.py | 1 + 2 files changed, 7 insertions(+) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c22890ebef3..f02abd834d7 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -193,6 +193,8 @@ async def test_single_available_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + await hass.config_entries.async_unload(result["result"].entry_id) + async def test_multiple_servers_with_selection( hass, @@ -249,6 +251,8 @@ async def test_multiple_servers_with_selection( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + await hass.config_entries.async_unload(result["result"].entry_id) + async def test_adding_last_unconfigured_server( hass, @@ -305,6 +309,8 @@ async def test_adding_last_unconfigured_server( ) assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + await hass.config_entries.async_unload(result["result"].entry_id) + async def test_all_available_servers_configured( hass, diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py index bbab50a7bbb..94278ca6052 100644 --- a/tests/components/plex/test_init.py +++ b/tests/components/plex/test_init.py @@ -184,6 +184,7 @@ async def test_setup_when_certificate_changed( plextv_account, plextv_resources, plextv_shared_users, + mock_websocket, ): """Test setup component when the Plex certificate has changed.""" From 39448009bfb44d70a5df98a3629f6579f65aee3e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 26 May 2022 22:15:43 -1000 Subject: [PATCH 033/947] Revert "Remove sqlite 3.34.1 downgrade workaround by reverting "Downgrade sqlite-libs on docker image (#55591)" (#72342)" (#72578) --- Dockerfile | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Dockerfile b/Dockerfile index 13552d55a3d..1d6ce675e74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,21 @@ RUN \ -e ./homeassistant --use-deprecated=legacy-resolver \ && python3 -m compileall homeassistant/homeassistant +# Fix Bug with Alpine 3.14 and sqlite 3.35 +# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524 +ARG BUILD_ARCH +RUN \ + if [ "${BUILD_ARCH}" = "amd64" ]; then \ + export APK_ARCH=x86_64; \ + elif [ "${BUILD_ARCH}" = "i386" ]; then \ + export APK_ARCH=x86; \ + else \ + export APK_ARCH=${BUILD_ARCH}; \ + fi \ + && curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \ + && apk add --no-cache sqlite-libs-3.34.1-r0.apk \ + && rm -f sqlite-libs-3.34.1-r0.apk + # Home Assistant S6-Overlay COPY rootfs / From 9b60b092c625206c255097d3a1fa9726e1e9ca87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 27 May 2022 10:41:40 +0200 Subject: [PATCH 034/947] Update aioqsw to v0.1.0 (#72576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qnap_qsw: update aioqsw to v0.1.0 Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/qnap_qsw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index 9331a7df468..0dfd0e4793e 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -3,7 +3,7 @@ "name": "QNAP QSW", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/qnap_qsw", - "requirements": ["aioqsw==0.0.8"], + "requirements": ["aioqsw==0.1.0"], "codeowners": ["@Noltari"], "iot_class": "local_polling", "loggers": ["aioqsw"] diff --git a/requirements_all.txt b/requirements_all.txt index c7459285fa0..7e299929590 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -223,7 +223,7 @@ aiopvpc==3.0.0 aiopyarr==22.2.2 # homeassistant.components.qnap_qsw -aioqsw==0.0.8 +aioqsw==0.1.0 # homeassistant.components.recollect_waste aiorecollect==1.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7233824a3cc..25cba8501e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -192,7 +192,7 @@ aiopvpc==3.0.0 aiopyarr==22.2.2 # homeassistant.components.qnap_qsw -aioqsw==0.0.8 +aioqsw==0.1.0 # homeassistant.components.recollect_waste aiorecollect==1.0.8 From 43e66b3af986c6298bf1a25a4780258032d84443 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 27 May 2022 10:44:31 +0200 Subject: [PATCH 035/947] Adjust config-flow type hints in firmata (#72502) --- homeassistant/components/firmata/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/firmata/config_flow.py b/homeassistant/components/firmata/config_flow.py index b4a9ada2c27..8aa4cfb836c 100644 --- a/homeassistant/components/firmata/config_flow.py +++ b/homeassistant/components/firmata/config_flow.py @@ -1,11 +1,13 @@ """Config flow to configure firmata component.""" import logging +from typing import Any from pymata_express.pymata_express_serial import serial from homeassistant import config_entries from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult from .board import get_board from .const import CONF_SERIAL_PORT, DOMAIN @@ -18,7 +20,7 @@ class FirmataFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config: dict): + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Import a firmata board as a config entry. This flow is triggered by `async_setup` for configured boards. From 371dfd85c8b61a62c48526778b377c090e6c0982 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 27 May 2022 02:52:24 -0700 Subject: [PATCH 036/947] Reduce the scope of the google calendar track deprecation (#72575) --- homeassistant/components/google/__init__.py | 1 - homeassistant/components/google/calendar.py | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index f034d48b9c5..b7263d2e469 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -117,7 +117,6 @@ CONFIG_SCHEMA = vol.Schema( _SINGLE_CALSEARCH_CONFIG = vol.All( cv.deprecated(CONF_MAX_RESULTS), - cv.deprecated(CONF_TRACK), vol.Schema( { vol.Required(CONF_NAME): cv.string, diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 01780702b7f..ba4368fefae 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -93,6 +93,11 @@ def _async_setup_entities( num_entities = len(disc_info[CONF_ENTITIES]) for data in disc_info[CONF_ENTITIES]: entity_enabled = data.get(CONF_TRACK, True) + if not entity_enabled: + _LOGGER.warning( + "The 'track' option in google_calendars.yaml has been deprecated. The setting " + "has been imported to the UI, and should now be removed from google_calendars.yaml" + ) entity_name = data[CONF_DEVICE_ID] entity_id = generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass) calendar_id = disc_info[CONF_CAL_ID] From 35bc6900ea5d80211f343250df7a132b73bcd4cd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 15:09:43 +0200 Subject: [PATCH 037/947] Simplify MQTT PLATFORM_CONFIG_SCHEMA_BASE (#72589) --- homeassistant/components/mqtt/__init__.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e8847375584..78f64387435 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -190,26 +190,7 @@ MQTT_WILL_BIRTH_SCHEMA = vol.Schema( ) PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( - { - vol.Optional(Platform.ALARM_CONTROL_PANEL.value): cv.ensure_list, - vol.Optional(Platform.BINARY_SENSOR.value): cv.ensure_list, - vol.Optional(Platform.BUTTON.value): cv.ensure_list, - vol.Optional(Platform.CAMERA.value): cv.ensure_list, - vol.Optional(Platform.CLIMATE.value): cv.ensure_list, - vol.Optional(Platform.COVER.value): cv.ensure_list, - vol.Optional(Platform.DEVICE_TRACKER.value): cv.ensure_list, - vol.Optional(Platform.FAN.value): cv.ensure_list, - vol.Optional(Platform.HUMIDIFIER.value): cv.ensure_list, - vol.Optional(Platform.LIGHT.value): cv.ensure_list, - vol.Optional(Platform.LOCK.value): cv.ensure_list, - vol.Optional(Platform.NUMBER.value): cv.ensure_list, - vol.Optional(Platform.SCENE.value): cv.ensure_list, - vol.Optional(Platform.SELECT.value): cv.ensure_list, - vol.Optional(Platform.SIREN.value): cv.ensure_list, - vol.Optional(Platform.SENSOR.value): cv.ensure_list, - vol.Optional(Platform.SWITCH.value): cv.ensure_list, - vol.Optional(Platform.VACUUM.value): cv.ensure_list, - } + {vol.Optional(platform.value): cv.ensure_list for platform in PLATFORMS} ) CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( From 5ca82b2d33b24eaf23b3292cae58ad6e0d704617 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 15:38:22 +0200 Subject: [PATCH 038/947] Migrate zha light to color_mode (#70970) * Migrate zha light to color_mode * Fix restoring color mode * Correct set operations * Derive color mode from group members * Add color mode to color channel * use Zigpy color mode enum Co-authored-by: David Mulcahey --- .../components/zha/core/channels/lighting.py | 6 + homeassistant/components/zha/light.py | 116 +++++++++++------- tests/components/zha/test_light.py | 12 +- 3 files changed, 86 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 1dbf1d201c8..13d5b4c2742 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -36,6 +36,7 @@ class ColorChannel(ZigbeeChannel): MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 ZCL_INIT_ATTRS = { + "color_mode": False, "color_temp_physical_min": True, "color_temp_physical_max": True, "color_capabilities": True, @@ -51,6 +52,11 @@ class ColorChannel(ZigbeeChannel): return self.CAPABILITIES_COLOR_XY | self.CAPABILITIES_COLOR_TEMP return self.CAPABILITIES_COLOR_XY + @property + def color_mode(self) -> int | None: + """Return cached value of the color_mode attribute.""" + return self.cluster.get("color_mode") + @property def color_loop_active(self) -> int | None: """Return cached value of the color_loop_active attribute.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2ec507109a2..30ae9688729 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -3,12 +3,11 @@ from __future__ import annotations from collections import Counter from datetime import timedelta -import enum import functools import itertools import logging import random -from typing import Any +from typing import Any, cast from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.lighting import Color @@ -17,16 +16,17 @@ from zigpy.zcl.foundation import Status from homeassistant.components import light from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_EFFECT_LIST, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, - LightEntityFeature, + ATTR_SUPPORTED_COLOR_MODES, + ColorMode, + brightness_supported, + filter_supported_color_modes, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -86,24 +86,14 @@ GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.LIGHT) PARALLEL_UPDATES = 0 SIGNAL_LIGHT_GROUP_STATE_CHANGED = "zha_light_group_state_changed" +COLOR_MODES_GROUP_LIGHT = {ColorMode.COLOR_TEMP, ColorMode.HS} SUPPORT_GROUP_LIGHT = ( - SUPPORT_BRIGHTNESS - | SUPPORT_COLOR_TEMP - | LightEntityFeature.EFFECT - | LightEntityFeature.FLASH - | SUPPORT_COLOR - | LightEntityFeature.TRANSITION + light.LightEntityFeature.EFFECT + | light.LightEntityFeature.FLASH + | light.LightEntityFeature.TRANSITION ) -class LightColorMode(enum.IntEnum): - """ZCL light color mode enum.""" - - HS_COLOR = 0x00 - XY_COLOR = 0x01 - COLOR_TEMP = 0x02 - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -146,6 +136,7 @@ class BaseLight(LogMixin, light.LightEntity): self._color_channel = None self._identify_channel = None self._default_transition = None + self._color_mode = ColorMode.UNKNOWN # Set by sub classes @property def extra_state_attributes(self) -> dict[str, Any]: @@ -160,6 +151,11 @@ class BaseLight(LogMixin, light.LightEntity): return False return self._state + @property + def color_mode(self): + """Return the color mode of this light.""" + return self._color_mode + @property def brightness(self): """Return the brightness of this light.""" @@ -230,9 +226,9 @@ class BaseLight(LogMixin, light.LightEntity): brightness = self._off_brightness t_log = {} - if ( - brightness is not None or transition - ) and self._supported_features & light.SUPPORT_BRIGHTNESS: + if (brightness is not None or transition) and brightness_supported( + self._attr_supported_color_modes + ): if brightness is not None: level = min(254, brightness) else: @@ -257,10 +253,7 @@ class BaseLight(LogMixin, light.LightEntity): self.debug("turned on: %s", t_log) return self._state = True - if ( - light.ATTR_COLOR_TEMP in kwargs - and self.supported_features & light.SUPPORT_COLOR_TEMP - ): + if light.ATTR_COLOR_TEMP in kwargs: temperature = kwargs[light.ATTR_COLOR_TEMP] result = await self._color_channel.move_to_color_temp(temperature, duration) t_log["move_to_color_temp"] = result @@ -270,10 +263,7 @@ class BaseLight(LogMixin, light.LightEntity): self._color_temp = temperature self._hs_color = None - if ( - light.ATTR_HS_COLOR in kwargs - and self.supported_features & light.SUPPORT_COLOR - ): + if light.ATTR_HS_COLOR in kwargs: hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*hs_color) result = await self._color_channel.move_to_color( @@ -286,10 +276,7 @@ class BaseLight(LogMixin, light.LightEntity): self._hs_color = hs_color self._color_temp = None - if ( - effect == light.EFFECT_COLORLOOP - and self.supported_features & light.LightEntityFeature.EFFECT - ): + if effect == light.EFFECT_COLORLOOP: result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION | UPDATE_COLORLOOP_DIRECTION @@ -302,9 +289,7 @@ class BaseLight(LogMixin, light.LightEntity): t_log["color_loop_set"] = result self._effect = light.EFFECT_COLORLOOP elif ( - self._effect == light.EFFECT_COLORLOOP - and effect != light.EFFECT_COLORLOOP - and self.supported_features & light.LightEntityFeature.EFFECT + self._effect == light.EFFECT_COLORLOOP and effect != light.EFFECT_COLORLOOP ): result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION, @@ -316,10 +301,7 @@ class BaseLight(LogMixin, light.LightEntity): t_log["color_loop_set"] = result self._effect = None - if ( - flash is not None - and self._supported_features & light.LightEntityFeature.FLASH - ): + if flash is not None: result = await self._identify_channel.trigger_effect( FLASH_EFFECTS[flash], EFFECT_DEFAULT_VARIANT ) @@ -332,7 +314,7 @@ class BaseLight(LogMixin, light.LightEntity): async def async_turn_off(self, **kwargs): """Turn the entity off.""" duration = kwargs.get(light.ATTR_TRANSITION) - supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS + supports_level = brightness_supported(self._attr_supported_color_modes) if duration and supports_level: result = await self._level_channel.move_to_level_with_on_off( @@ -356,6 +338,7 @@ class BaseLight(LogMixin, light.LightEntity): class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" + _attr_supported_color_modes: set(ColorMode) _REFRESH_INTERVAL = (45, 75) def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): @@ -372,19 +355,20 @@ class Light(BaseLight, ZhaEntity): self._cancel_refresh_handle = None effect_list = [] + self._attr_supported_color_modes = {ColorMode.ONOFF} if self._level_channel: - self._supported_features |= light.SUPPORT_BRIGHTNESS + self._attr_supported_color_modes.add(ColorMode.BRIGHTNESS) self._supported_features |= light.LightEntityFeature.TRANSITION self._brightness = self._level_channel.current_level if self._color_channel: color_capabilities = self._color_channel.color_capabilities if color_capabilities & CAPABILITIES_COLOR_TEMP: - self._supported_features |= light.SUPPORT_COLOR_TEMP + self._attr_supported_color_modes.add(ColorMode.COLOR_TEMP) self._color_temp = self._color_channel.color_temperature if color_capabilities & CAPABILITIES_COLOR_XY: - self._supported_features |= light.SUPPORT_COLOR + self._attr_supported_color_modes.add(ColorMode.HS) curr_x = self._color_channel.current_x curr_y = self._color_channel.current_y if curr_x is not None and curr_y is not None: @@ -399,6 +383,16 @@ class Light(BaseLight, ZhaEntity): effect_list.append(light.EFFECT_COLORLOOP) if self._color_channel.color_loop_active == 1: self._effect = light.EFFECT_COLORLOOP + self._attr_supported_color_modes = filter_supported_color_modes( + self._attr_supported_color_modes + ) + if len(self._attr_supported_color_modes) == 1: + self._color_mode = next(iter(self._attr_supported_color_modes)) + else: # Light supports color_temp + hs, determine which mode the light is in + if self._color_channel.color_mode == Color.ColorMode.Color_temperature: + self._color_mode = ColorMode.COLOR_TEMP + else: + self._color_mode = ColorMode.HS if self._identify_channel: self._supported_features |= light.LightEntityFeature.FLASH @@ -455,6 +449,8 @@ class Light(BaseLight, ZhaEntity): self._brightness = last_state.attributes["brightness"] if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] + if "color_mode" in last_state.attributes: + self._color_mode = ColorMode(last_state.attributes["color_mode"]) if "color_temp" in last_state.attributes: self._color_temp = last_state.attributes["color_temp"] if "hs_color" in last_state.attributes: @@ -493,12 +489,14 @@ class Light(BaseLight, ZhaEntity): ) if (color_mode := results.get("color_mode")) is not None: - if color_mode == LightColorMode.COLOR_TEMP: + if color_mode == Color.ColorMode.Color_temperature: + self._color_mode = ColorMode.COLOR_TEMP color_temp = results.get("color_temperature") if color_temp is not None and color_mode: self._color_temp = color_temp self._hs_color = None else: + self._color_mode = ColorMode.HS color_x = results.get("current_x") color_y = results.get("current_y") if color_x is not None and color_y is not None: @@ -573,6 +571,7 @@ class LightGroup(BaseLight, ZhaGroupEntity): CONF_DEFAULT_LIGHT_TRANSITION, 0, ) + self._color_mode = None async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -633,6 +632,29 @@ class LightGroup(BaseLight, ZhaGroupEntity): effects_count = Counter(itertools.chain(all_effects)) self._effect = effects_count.most_common(1)[0][0] + self._attr_color_mode = None + all_color_modes = list( + helpers.find_state_attributes(on_states, ATTR_COLOR_MODE) + ) + if all_color_modes: + # Report the most common color mode, select brightness and onoff last + color_mode_count = Counter(itertools.chain(all_color_modes)) + if ColorMode.ONOFF in color_mode_count: + color_mode_count[ColorMode.ONOFF] = -1 + if ColorMode.BRIGHTNESS in color_mode_count: + color_mode_count[ColorMode.BRIGHTNESS] = 0 + self._attr_color_mode = color_mode_count.most_common(1)[0][0] + + self._attr_supported_color_modes = None + all_supported_color_modes = list( + helpers.find_state_attributes(states, ATTR_SUPPORTED_COLOR_MODES) + ) + if all_supported_color_modes: + # Merge all color modes. + self._attr_supported_color_modes = cast( + set[str], set().union(*all_supported_color_modes) + ) + self._supported_features = 0 for support in helpers.find_state_attributes(states, ATTR_SUPPORTED_FEATURES): # Merge supported features by emulating support for every feature diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 4ac777f5d8e..8cf0e668503 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -12,6 +12,7 @@ from homeassistant.components.light import ( DOMAIN as LIGHT_DOMAIN, FLASH_LONG, FLASH_SHORT, + ColorMode, ) from homeassistant.components.zha.core.group import GroupMember from homeassistant.components.zha.light import FLASH_EFFECTS @@ -580,7 +581,11 @@ async def test_zha_group_light_entity( await async_wait_for_updates(hass) # test that the lights were created and are off - assert hass.states.get(group_entity_id).state == STATE_OFF + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_OFF + assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + # Light which is off has no color mode + assert "color_mode" not in group_state.attributes # test turning the lights on and off from the HA await async_test_on_off_from_hass(hass, group_cluster_on_off, group_entity_id) @@ -603,6 +608,11 @@ async def test_zha_group_light_entity( await async_test_dimmer_from_light( hass, dev1_cluster_level, group_entity_id, 150, STATE_ON ) + # Check state + group_state = hass.states.get(group_entity_id) + assert group_state.state == STATE_ON + assert group_state.attributes["supported_color_modes"] == [ColorMode.HS] + assert group_state.attributes["color_mode"] == ColorMode.HS # test long flashing the lights from the HA await async_test_flash_from_hass( From 60387a417fb82b47700899a6b7e80b30dcc9766f Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 27 May 2022 09:43:39 -0400 Subject: [PATCH 039/947] Add support for polled Smart Energy Metering sensors to ZHA (#71527) * Add framework for polled se metering sensors * add model * find attr * type info --- .../zha/core/channels/homeautomation.py | 2 +- .../zha/core/channels/smartenergy.py | 26 +++++++++++- homeassistant/components/zha/sensor.py | 25 ++++++++++- homeassistant/components/zha/switch.py | 42 ++++++++++++------- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index e1019ed31bf..60c33c93003 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -102,7 +102,7 @@ class ElectricalMeasurementChannel(ZigbeeChannel): for attr, value in result.items(): self.async_send_signal( f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - self.cluster.attridx.get(attr, attr), + self.cluster.find_attribute(attr).id, attr, value, ) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index b153372a322..927ceb248c5 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -7,7 +7,12 @@ from functools import partialmethod from zigpy.zcl.clusters import smartenergy from .. import registries, typing as zha_typing -from ..const import REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_OP +from ..const import ( + REPORT_CONFIG_ASAP, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_OP, + SIGNAL_ATTR_UPDATED, +) from .base import ZigbeeChannel @@ -163,6 +168,25 @@ class Metering(ZigbeeChannel): ) # 1 digit to the right, 15 digits to the left self._summa_format = self.get_formatting(fmting) + async def async_force_update(self) -> None: + """Retrieve latest state.""" + self.debug("async_force_update") + + attrs = [ + a["attr"] + for a in self.REPORT_CONFIG + if a["attr"] not in self.cluster.unsupported_attributes + ] + result = await self.get_attributes(attrs, from_cache=False, only_cache=False) + if result: + for attr, value in result.items(): + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + self.cluster.find_attribute(attr).id, + attr, + value, + ) + @staticmethod def get_formatting(formatting: int) -> str: """Return a formatting string, given the formatting value. diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 3e3017f6fa9..36ca873188f 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -420,7 +420,10 @@ class Illuminance(Sensor): return round(pow(10, ((value - 1) / 10000)), 1) -@MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) +@MULTI_MATCH( + channel_names=CHANNEL_SMARTENERGY_METERING, + stop_on_match_group=CHANNEL_SMARTENERGY_METERING, +) class SmartEnergyMetering(Sensor): """Metering sensor.""" @@ -464,6 +467,26 @@ class SmartEnergyMetering(Sensor): return attrs +@MULTI_MATCH( + channel_names=CHANNEL_SMARTENERGY_METERING, + models={"TS011F"}, + stop_on_match_group=CHANNEL_SMARTENERGY_METERING, +) +class PolledSmartEnergyMetering(SmartEnergyMetering): + """Polled metering sensor.""" + + @property + def should_poll(self) -> bool: + """Poll the entity for current state.""" + return True + + async def async_update(self) -> None: + """Retrieve latest state.""" + if not self.available: + return + await self._channel.async_force_update() + + @MULTI_MATCH(channel_names=CHANNEL_SMARTENERGY_METERING) class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): """Smart Energy Metering summation sensor.""" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index d9199ed77c8..1926b08fc60 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -29,6 +29,7 @@ from .entity import ZhaEntity, ZhaGroupEntity if TYPE_CHECKING: from .core.channels.base import ZigbeeChannel + from .core.channels.general import OnOffChannel from .core.device import ZHADevice STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SWITCH) @@ -62,10 +63,16 @@ async def async_setup_entry( class Switch(ZhaEntity, SwitchEntity): """ZHA switch.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__( + self, + unique_id: str, + zha_device: ZHADevice, + channels: list[ZigbeeChannel], + **kwargs: Any, + ) -> None: """Initialize the ZHA switch.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) + self._on_off_channel: OnOffChannel = self.cluster_channels.get(CHANNEL_ON_OFF) @property def is_on(self) -> bool: @@ -74,14 +81,14 @@ class Switch(ZhaEntity, SwitchEntity): return False return self._on_off_channel.on_off - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" result = await self._on_off_channel.turn_on() if not result: return self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" result = await self._on_off_channel.turn_off() if not result: @@ -112,7 +119,12 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): """Representation of a switch group.""" def __init__( - self, entity_ids: list[str], unique_id: str, group_id: int, zha_device, **kwargs + self, + entity_ids: list[str], + unique_id: str, + group_id: int, + zha_device: ZHADevice, + **kwargs: Any, ) -> None: """Initialize a switch group.""" super().__init__(entity_ids, unique_id, group_id, zha_device, **kwargs) @@ -126,7 +138,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): """Return if the switch is on based on the statemachine.""" return bool(self._state) - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" result = await self._on_off_channel.on() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -134,7 +146,7 @@ class SwitchGroup(ZhaGroupEntity, SwitchEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" result = await self._on_off_channel.off() if isinstance(result, Exception) or result[1] is not Status.SUCCESS: @@ -165,7 +177,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): unique_id: str, zha_device: ZHADevice, channels: list[ZigbeeChannel], - **kwargs, + **kwargs: Any, ) -> ZhaEntity | None: """Entity Factory. @@ -190,7 +202,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): unique_id: str, zha_device: ZHADevice, channels: list[ZigbeeChannel], - **kwargs, + **kwargs: Any, ) -> None: """Init this number configuration entity.""" self._channel: ZigbeeChannel = channels[0] @@ -215,7 +227,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): invert = bool(self._channel.cluster.get(self._zcl_inverter_attribute)) return (not val) if invert else val - async def async_turn_on_off(self, state) -> None: + async def async_turn_on_off(self, state: bool) -> None: """Turn the entity on or off.""" try: invert = bool(self._channel.cluster.get(self._zcl_inverter_attribute)) @@ -230,11 +242,11 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity): ): self.async_write_ha_state() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" await self.async_turn_on_off(True) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.async_turn_on_off(False) @@ -263,8 +275,8 @@ class OnOffWindowDetectionFunctionConfigurationEntity( ): """Representation of a ZHA window detection configuration entity.""" - _zcl_attribute = "window_detection_function" - _zcl_inverter_attribute = "window_detection_function_inverter" + _zcl_attribute: str = "window_detection_function" + _zcl_inverter_attribute: str = "window_detection_function_inverter" @CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac02"}) @@ -273,4 +285,4 @@ class P1MotionTriggerIndicatorSwitch( ): """Representation of a ZHA motion triggering configuration entity.""" - _zcl_attribute = "trigger_indicator" + _zcl_attribute: str = "trigger_indicator" From b6575aa66b8d965c0f24a5b0e46f938f507b279b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 16:53:49 +0200 Subject: [PATCH 040/947] Minor cleanup of test integration's cover platform (#72598) --- tests/testing_config/custom_components/test/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index edd8965e4e9..98d59a473b1 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -18,7 +18,7 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O from tests.common import MockEntity -ENTITIES = {} +ENTITIES = [] def init(empty=False): From f76afffd5ab12014d1e506ed1ab3c38f2ed3b8f1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 17:40:55 +0200 Subject: [PATCH 041/947] Require passing target player when resolving media (#72593) --- .../components/media_source/__init__.py | 23 +++++++++++++------ .../components/media_source/local_source.py | 4 ++-- .../components/media_source/models.py | 7 ++++-- .../components/dlna_dms/test_media_source.py | 8 +++---- tests/components/media_source/test_init.py | 19 +++++++++++++++ 5 files changed, 46 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 3c42016f8f7..4818934d1dd 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,10 +18,11 @@ from homeassistant.components.media_player.browse_media import ( ) from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.frame import report from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass from . import local_source @@ -80,15 +81,15 @@ async def _process_media_source_platform( @callback def _get_media_item( - hass: HomeAssistant, media_content_id: str | None + hass: HomeAssistant, media_content_id: str | None, target_media_player: str | None ) -> MediaSourceItem: """Return media item.""" if media_content_id: - item = MediaSourceItem.from_uri(hass, media_content_id) + item = MediaSourceItem.from_uri(hass, media_content_id, target_media_player) else: # We default to our own domain if its only one registered domain = None if len(hass.data[DOMAIN]) > 1 else DOMAIN - return MediaSourceItem(hass, domain, "") + return MediaSourceItem(hass, domain, "", target_media_player) if item.domain is not None and item.domain not in hass.data[DOMAIN]: raise ValueError("Unknown media source") @@ -108,7 +109,7 @@ async def async_browse_media( raise BrowseError("Media Source not loaded") try: - item = await _get_media_item(hass, media_content_id).async_browse() + item = await _get_media_item(hass, media_content_id, None).async_browse() except ValueError as err: raise BrowseError(str(err)) from err @@ -124,13 +125,21 @@ async def async_browse_media( @bind_hass -async def async_resolve_media(hass: HomeAssistant, media_content_id: str) -> PlayMedia: +async def async_resolve_media( + hass: HomeAssistant, + media_content_id: str, + target_media_player: str | None | UndefinedType = UNDEFINED, +) -> PlayMedia: """Get info to play media.""" if DOMAIN not in hass.data: raise Unresolvable("Media Source not loaded") + if target_media_player is UNDEFINED: + report("calls media_source.async_resolve_media without passing an entity_id") + target_media_player = None + try: - item = _get_media_item(hass, media_content_id) + item = _get_media_item(hass, media_content_id, target_media_player) except ValueError as err: raise Unresolvable(str(err)) from err diff --git a/homeassistant/components/media_source/local_source.py b/homeassistant/components/media_source/local_source.py index 89feba5317f..863380b7600 100644 --- a/homeassistant/components/media_source/local_source.py +++ b/homeassistant/components/media_source/local_source.py @@ -264,7 +264,7 @@ class UploadMediaView(http.HomeAssistantView): raise web.HTTPBadRequest() from err try: - item = MediaSourceItem.from_uri(self.hass, data["media_content_id"]) + item = MediaSourceItem.from_uri(self.hass, data["media_content_id"], None) except ValueError as err: LOGGER.error("Received invalid upload data: %s", err) raise web.HTTPBadRequest() from err @@ -328,7 +328,7 @@ async def websocket_remove_media( ) -> None: """Remove media.""" try: - item = MediaSourceItem.from_uri(hass, msg["media_content_id"]) + item = MediaSourceItem.from_uri(hass, msg["media_content_id"], None) except ValueError as err: connection.send_error(msg["id"], websocket_api.ERR_INVALID_FORMAT, str(err)) return diff --git a/homeassistant/components/media_source/models.py b/homeassistant/components/media_source/models.py index ceb57ef1fb4..0aee6ad1330 100644 --- a/homeassistant/components/media_source/models.py +++ b/homeassistant/components/media_source/models.py @@ -50,6 +50,7 @@ class MediaSourceItem: hass: HomeAssistant domain: str | None identifier: str + target_media_player: str | None async def async_browse(self) -> BrowseMediaSource: """Browse this item.""" @@ -94,7 +95,9 @@ class MediaSourceItem: return cast(MediaSource, self.hass.data[DOMAIN][self.domain]) @classmethod - def from_uri(cls, hass: HomeAssistant, uri: str) -> MediaSourceItem: + def from_uri( + cls, hass: HomeAssistant, uri: str, target_media_player: str | None + ) -> MediaSourceItem: """Create an item from a uri.""" if not (match := URI_SCHEME_REGEX.match(uri)): raise ValueError("Invalid media source URI") @@ -102,7 +105,7 @@ class MediaSourceItem: domain = match.group("domain") identifier = match.group("identifier") - return cls(hass, domain, identifier) + return cls(hass, domain, identifier, target_media_player) class MediaSource(ABC): diff --git a/tests/components/dlna_dms/test_media_source.py b/tests/components/dlna_dms/test_media_source.py index f2c3011e274..5f76b061590 100644 --- a/tests/components/dlna_dms/test_media_source.py +++ b/tests/components/dlna_dms/test_media_source.py @@ -49,7 +49,7 @@ async def test_get_media_source(hass: HomeAssistant) -> None: async def test_resolve_media_unconfigured(hass: HomeAssistant) -> None: """Test resolve_media without any devices being configured.""" source = DmsMediaSource(hass) - item = MediaSourceItem(hass, DOMAIN, "source_id/media_id") + item = MediaSourceItem(hass, DOMAIN, "source_id/media_id", None) with pytest.raises(Unresolvable, match="No sources have been configured"): await source.async_resolve_media(item) @@ -116,11 +116,11 @@ async def test_resolve_media_success( async def test_browse_media_unconfigured(hass: HomeAssistant) -> None: """Test browse_media without any devices being configured.""" source = DmsMediaSource(hass) - item = MediaSourceItem(hass, DOMAIN, "source_id/media_id") + item = MediaSourceItem(hass, DOMAIN, "source_id/media_id", None) with pytest.raises(BrowseError, match="No sources have been configured"): await source.async_browse_media(item) - item = MediaSourceItem(hass, DOMAIN, "") + item = MediaSourceItem(hass, DOMAIN, "", None) with pytest.raises(BrowseError, match="No sources have been configured"): await source.async_browse_media(item) @@ -239,7 +239,7 @@ async def test_browse_media_source_id( dms_device_mock.async_browse_metadata.side_effect = UpnpError # Browse by source_id - item = MediaSourceItem(hass, DOMAIN, f"{MOCK_SOURCE_ID}/:media-item-id") + item = MediaSourceItem(hass, DOMAIN, f"{MOCK_SOURCE_ID}/:media-item-id", None) dms_source = DmsMediaSource(hass) with pytest.raises(BrowseError): await dms_source.async_browse_media(item) diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index 491b1972cb6..f2a8ff13533 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -109,6 +109,25 @@ async def test_async_resolve_media(hass): assert media.mime_type == "audio/mpeg" +@patch("homeassistant.helpers.frame._REPORTED_INTEGRATIONS", set()) +async def test_async_resolve_media_no_entity(hass, caplog): + """Test browse media.""" + assert await async_setup_component(hass, media_source.DOMAIN, {}) + await hass.async_block_till_done() + + media = await media_source.async_resolve_media( + hass, + media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), + ) + assert isinstance(media, media_source.models.PlayMedia) + assert media.url == "/media/local/test.mp3" + assert media.mime_type == "audio/mpeg" + assert ( + "calls media_source.async_resolve_media without passing an entity_id" + in caplog.text + ) + + async def test_async_unresolve_media(hass): """Test browse media.""" assert await async_setup_component(hass, media_source.DOMAIN, {}) From 47d0cc9b09b49bed4de7218a902acfe00c2de758 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 27 May 2022 18:05:06 +0200 Subject: [PATCH 042/947] Update integrations to pass target player when resolving media (#72597) --- .../components/apple_tv/media_player.py | 4 ++- .../components/bluesound/media_player.py | 4 ++- homeassistant/components/cast/media_player.py | 4 ++- .../components/dlna_dmr/media_player.py | 4 ++- .../components/esphome/media_player.py | 4 ++- .../components/forked_daapd/media_player.py | 4 ++- .../components/gstreamer/media_player.py | 4 ++- homeassistant/components/heos/media_player.py | 4 ++- homeassistant/components/kodi/media_player.py | 4 ++- homeassistant/components/mpd/media_player.py | 4 ++- .../components/openhome/media_player.py | 4 ++- .../panasonic_viera/media_player.py | 4 ++- homeassistant/components/roku/media_player.py | 4 ++- .../components/slimproto/media_player.py | 4 ++- .../components/sonos/media_player.py | 4 ++- .../components/soundtouch/media_player.py | 4 ++- .../components/squeezebox/media_player.py | 4 ++- .../components/unifiprotect/media_player.py | 4 ++- homeassistant/components/vlc/media_player.py | 4 ++- .../components/vlc_telnet/media_player.py | 4 ++- .../yamaha_musiccast/media_player.py | 4 ++- tests/components/camera/test_media_source.py | 10 ++++---- .../dlna_dms/test_device_availability.py | 10 ++++---- .../dlna_dms/test_dms_device_source.py | 2 +- .../components/dlna_dms/test_media_source.py | 12 ++++----- tests/components/google_translate/test_tts.py | 2 +- tests/components/marytts/test_tts.py | 2 +- tests/components/media_source/test_init.py | 11 +++++--- .../components/motioneye/test_media_source.py | 15 ++++++++--- tests/components/nest/test_media_source.py | 25 +++++++++++-------- tests/components/netatmo/test_media_source.py | 2 +- tests/components/tts/test_init.py | 2 +- tests/components/tts/test_media_source.py | 14 +++++++---- tests/components/voicerss/test_tts.py | 2 +- tests/components/yandextts/test_tts.py | 2 +- 35 files changed, 128 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 5a7298dcbee..30a397d953c 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -284,7 +284,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): await self.atv.apps.launch_app(media_id) if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url media_type = MEDIA_TYPE_MUSIC diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 4fe89d84cf1..7f1c6b6553f 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -1025,7 +1025,9 @@ class BluesoundPlayer(MediaPlayerEntity): return if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url media_id = async_process_play_media_url(self.hass, media_id) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b64c3372c15..ea21259ccc4 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -605,7 +605,9 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): """Play a piece of media.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = sourced_media.mime_type media_id = sourced_media.url diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index fd1fc9b2bab..9ecf9f8ad40 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -597,7 +597,9 @@ class DlnaDmrEntity(MediaPlayerEntity): # If media is media_source, resolve it to url and MIME type, and maybe metadata if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = sourced_media.mime_type media_id = sourced_media.url _LOGGER.debug("sourced_media is %s", sourced_media) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index 6e83d12a427..f9027142ae2 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -95,7 +95,9 @@ class EsphomeMediaPlayer( ) -> None: """Send the play command with media url to the media player.""" if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url media_id = async_process_play_media_url(self.hass, media_id) diff --git a/homeassistant/components/forked_daapd/media_player.py b/homeassistant/components/forked_daapd/media_player.py index f2c64fa81da..25695dceeb5 100644 --- a/homeassistant/components/forked_daapd/media_player.py +++ b/homeassistant/components/forked_daapd/media_player.py @@ -666,7 +666,9 @@ class ForkedDaapdMaster(MediaPlayerEntity): """Play a URI.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type == MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py index 545941f2924..723be2880ff 100644 --- a/homeassistant/components/gstreamer/media_player.py +++ b/homeassistant/components/gstreamer/media_player.py @@ -96,7 +96,9 @@ class GstreamerDevice(MediaPlayerEntity): """Play media.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url elif media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index 29a9b2b2a18..ad9225d9b21 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -201,7 +201,9 @@ class HeosMediaPlayer(MediaPlayerEntity): """Play a piece of media.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_URL - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type in (MEDIA_TYPE_URL, MEDIA_TYPE_MUSIC): diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index cea3adcde00..e19ffc6219c 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -713,7 +713,9 @@ class KodiEntity(MediaPlayerEntity): """Send the play_media command to the media player.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_URL - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url media_type_lower = media_type.lower() diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py index d3262a0d5da..ecee057a653 100644 --- a/homeassistant/components/mpd/media_player.py +++ b/homeassistant/components/mpd/media_player.py @@ -453,7 +453,9 @@ class MpdDevice(MediaPlayerEntity): """Send the media player the command for playing a playlist.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = async_process_play_media_url(self.hass, play_item.url) if media_type == MEDIA_TYPE_PLAYLIST: diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index fa9cce1cfb6..b6a0b549c40 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -209,7 +209,9 @@ class OpenhomeDevice(MediaPlayerEntity): """Send the play_media command to the media player.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py index fd44c2853f1..7b75809f827 100644 --- a/homeassistant/components/panasonic_viera/media_player.py +++ b/homeassistant/components/panasonic_viera/media_player.py @@ -188,7 +188,9 @@ class PanasonicVieraTVEntity(MediaPlayerEntity): """Play media.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_URL - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type != MEDIA_TYPE_URL: diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index e6fe0d7dcf5..a47432694dd 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -384,7 +384,9 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = MEDIA_TYPE_URL media_id = sourced_media.url mime_type = sourced_media.mime_type diff --git a/homeassistant/components/slimproto/media_player.py b/homeassistant/components/slimproto/media_player.py index 6b1989830e2..2f85aa4b9df 100644 --- a/homeassistant/components/slimproto/media_player.py +++ b/homeassistant/components/slimproto/media_player.py @@ -180,7 +180,9 @@ class SlimProtoPlayer(MediaPlayerEntity): to_send_media_type: str | None = media_type # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url to_send_media_type = sourced_media.mime_type diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index e2a63a86b06..fd37e546105 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -550,7 +550,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_type = MEDIA_TYPE_MUSIC media_id = ( run_coroutine_threadsafe( - media_source.async_resolve_media(self.hass, media_id), + media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ), self.hass.loop, ) .result() diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 3172eb4aed6..7c9ade3bee1 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -357,7 +357,9 @@ class SoundTouchDevice(MediaPlayerEntity): async def async_play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = async_process_play_media_url(self.hass, play_item.url) await self.hass.async_add_executor_job( diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index eda742281ee..cd628a639c5 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -484,7 +484,9 @@ class SqueezeBoxEntity(MediaPlayerEntity): if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if media_type in MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 0b7c2a2f60d..1acd14be130 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -118,7 +118,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): """Play a piece of media.""" if media_source.is_media_source_id(media_id): media_type = MEDIA_TYPE_MUSIC - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = async_process_play_media_url(self.hass, play_item.url) if media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py index 7312eacd1c6..88b663e09c6 100644 --- a/homeassistant/components/vlc/media_player.py +++ b/homeassistant/components/vlc/media_player.py @@ -168,7 +168,9 @@ class VlcDevice(MediaPlayerEntity): """Play media from a URL or file.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = sourced_media.url elif media_type != MEDIA_TYPE_MUSIC: diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 89fa1a3c323..75305acbb0c 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -296,7 +296,9 @@ class VlcDevice(MediaPlayerEntity): """Play media from a URL or file.""" # Handle media_source if media_source.is_media_source_id(media_id): - sourced_media = await media_source.async_resolve_media(self.hass, media_id) + sourced_media = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_type = sourced_media.mime_type media_id = sourced_media.url diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index d0141977f29..954942b2c6b 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -275,7 +275,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): async def async_play_media(self, media_type: str, media_id: str, **kwargs) -> None: """Play media.""" if media_source.is_media_source_id(media_id): - play_item = await media_source.async_resolve_media(self.hass, media_id) + play_item = await media_source.async_resolve_media( + self.hass, media_id, self.entity_id + ) media_id = play_item.url if self.state == STATE_OFF: diff --git a/tests/components/camera/test_media_source.py b/tests/components/camera/test_media_source.py index b7c273bb23a..4134e9b1151 100644 --- a/tests/components/camera/test_media_source.py +++ b/tests/components/camera/test_media_source.py @@ -62,7 +62,7 @@ async def test_resolving(hass, mock_camera_hls): return_value="http://example.com/stream", ): item = await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert item is not None assert item.url == "http://example.com/stream" @@ -74,7 +74,7 @@ async def test_resolving_errors(hass, mock_camera_hls): with pytest.raises(media_source.Unresolvable) as exc_info: await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert str(exc_info.value) == "Stream integration not loaded" @@ -82,7 +82,7 @@ async def test_resolving_errors(hass, mock_camera_hls): with pytest.raises(media_source.Unresolvable) as exc_info: await media_source.async_resolve_media( - hass, "media-source://camera/camera.non_existing" + hass, "media-source://camera/camera.non_existing", None ) assert str(exc_info.value) == "Could not resolve media item: camera.non_existing" @@ -91,13 +91,13 @@ async def test_resolving_errors(hass, mock_camera_hls): new_callable=PropertyMock(return_value=StreamType.WEB_RTC), ): await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert str(exc_info.value) == "Camera does not support MJPEG or HLS streaming." with pytest.raises(media_source.Unresolvable) as exc_info: await media_source.async_resolve_media( - hass, "media-source://camera/camera.demo_camera" + hass, "media-source://camera/camera.demo_camera", None ) assert ( str(exc_info.value) == "camera.demo_camera does not support play stream service" diff --git a/tests/components/dlna_dms/test_device_availability.py b/tests/components/dlna_dms/test_device_availability.py index 67ad1024709..a3ec5326f00 100644 --- a/tests/components/dlna_dms/test_device_availability.py +++ b/tests/components/dlna_dms/test_device_availability.py @@ -152,15 +152,15 @@ async def test_unavailable_device( ) with pytest.raises(Unresolvable, match="DMS is not connected"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//resolve_path" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}//resolve_path", None ) with pytest.raises(Unresolvable, match="DMS is not connected"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:resolve_object" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:resolve_object", None ) with pytest.raises(Unresolvable): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?resolve_search" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/?resolve_search", None ) @@ -651,7 +651,7 @@ async def test_become_unavailable( # Check async_resolve_object currently works assert await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id", None ) # Now break the network connection @@ -660,7 +660,7 @@ async def test_become_unavailable( # async_resolve_object should fail with pytest.raises(Unresolvable): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:object_id", None ) # The device should now be unavailable diff --git a/tests/components/dlna_dms/test_dms_device_source.py b/tests/components/dlna_dms/test_dms_device_source.py index 5e4021a5dda..622a3b8a4f9 100644 --- a/tests/components/dlna_dms/test_dms_device_source.py +++ b/tests/components/dlna_dms/test_dms_device_source.py @@ -45,7 +45,7 @@ async def async_resolve_media( ) -> DidlPlayMedia: """Call media_source.async_resolve_media with the test source's ID.""" result = await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/{media_content_id}", None ) assert isinstance(result, DidlPlayMedia) return result diff --git a/tests/components/dlna_dms/test_media_source.py b/tests/components/dlna_dms/test_media_source.py index 5f76b061590..35f34d0689b 100644 --- a/tests/components/dlna_dms/test_media_source.py +++ b/tests/components/dlna_dms/test_media_source.py @@ -60,31 +60,31 @@ async def test_resolve_media_bad_identifier( """Test trying to resolve an item that has an unresolvable identifier.""" # Empty identifier with pytest.raises(Unresolvable, match="No source ID.*"): - await media_source.async_resolve_media(hass, f"media-source://{DOMAIN}") + await media_source.async_resolve_media(hass, f"media-source://{DOMAIN}", None) # Identifier has media_id but no source_id # media_source.URI_SCHEME_REGEX won't let the ID through to dlna_dms with pytest.raises(Unresolvable, match="Invalid media source URI"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}//media_id" + hass, f"media-source://{DOMAIN}//media_id", None ) # Identifier has source_id but no media_id with pytest.raises(Unresolvable, match="No media ID.*"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/source_id/" + hass, f"media-source://{DOMAIN}/source_id/", None ) # Identifier is missing source_id/media_id separator with pytest.raises(Unresolvable, match="No media ID.*"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/source_id" + hass, f"media-source://{DOMAIN}/source_id", None ) # Identifier has an unknown source_id with pytest.raises(Unresolvable, match="Unknown source ID: unknown_source"): await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/unknown_source/media_id" + hass, f"media-source://{DOMAIN}/unknown_source/media_id", None ) @@ -105,7 +105,7 @@ async def test_resolve_media_success( dms_device_mock.async_browse_metadata.return_value = didl_item result = await media_source.async_resolve_media( - hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{object_id}" + hass, f"media-source://{DOMAIN}/{MOCK_SOURCE_ID}/:{object_id}", None ) assert isinstance(result, DidlPlayMedia) assert result.url == f"{MOCK_DEVICE_BASE_URL}/{res_url}" diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index c81cea57090..cc80d9c64b9 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -25,7 +25,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 843b6578746..60211f7dc0c 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -21,7 +21,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py index f2a8ff13533..33dd263c46c 100644 --- a/tests/components/media_source/test_init.py +++ b/tests/components/media_source/test_init.py @@ -103,6 +103,7 @@ async def test_async_resolve_media(hass): media = await media_source.async_resolve_media( hass, media_source.generate_media_source_id(media_source.DOMAIN, "local/test.mp3"), + None, ) assert isinstance(media, media_source.models.PlayMedia) assert media.url == "/media/local/test.mp3" @@ -135,15 +136,17 @@ async def test_async_unresolve_media(hass): # Test no media content with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "") + await media_source.async_resolve_media(hass, "", None) # Test invalid media content with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "invalid") + await media_source.async_resolve_media(hass, "invalid", None) # Test invalid media source with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "media-source://media_source2") + await media_source.async_resolve_media( + hass, "media-source://media_source2", None + ) async def test_websocket_browse_media(hass, hass_ws_client): @@ -261,4 +264,4 @@ async def test_browse_resolve_without_setup(): await media_source.async_browse_media(Mock(data={}), None) with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(Mock(data={}), None) + await media_source.async_resolve_media(Mock(data={}), None, None) diff --git a/tests/components/motioneye/test_media_source.py b/tests/components/motioneye/test_media_source.py index 9b86b783d43..2cf31c21da7 100644 --- a/tests/components/motioneye/test_media_source.py +++ b/tests/components/motioneye/test_media_source.py @@ -367,6 +367,7 @@ async def test_async_resolve_media_success(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#/foo.mp4" ), + None, ) assert media == PlayMedia(url="http://movie-url", mime_type="video/mp4") assert client.get_movie_url.call_args == call(TEST_CAMERA_ID, "/foo.mp4") @@ -379,6 +380,7 @@ async def test_async_resolve_media_success(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#images#/foo.jpg" ), + None, ) assert media == PlayMedia(url="http://image-url", mime_type="image/jpeg") assert client.get_image_url.call_args == call(TEST_CAMERA_ID, "/foo.jpg") @@ -409,18 +411,20 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: # URI doesn't contain necessary components. with pytest.raises(Unresolvable): - await media_source.async_resolve_media(hass, f"{const.URI_SCHEME}{DOMAIN}/foo") + await media_source.async_resolve_media( + hass, f"{const.URI_SCHEME}{DOMAIN}/foo", None + ) # Config entry doesn't exist. with pytest.raises(MediaSourceError): await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/1#2#3#4" + hass, f"{const.URI_SCHEME}{DOMAIN}/1#2#3#4", None ) # Device doesn't exist. with pytest.raises(MediaSourceError): await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#2#3#4" + hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#2#3#4", None ) # Device identifiers are incorrect (no camera id) @@ -431,6 +435,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{broken_device_1.id}#images#4" ), + None, ) # Device identifiers are incorrect (non integer camera id) @@ -441,6 +446,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{broken_device_2.id}#images#4" ), + None, ) # Kind is incorrect. @@ -448,6 +454,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{TEST_CONFIG_ENTRY_ID}#{device.id}#games#moo", + None, ) # Playback URL raises exception. @@ -459,6 +466,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#/foo.mp4" ), + None, ) # Media path does not start with '/' @@ -470,6 +478,7 @@ async def test_async_resolve_media_failure(hass: HomeAssistant) -> None: f"{const.URI_SCHEME}{DOMAIN}" f"/{TEST_CONFIG_ENTRY_ID}#{device.id}#movies#foo.mp4" ), + None, ) # Media missing path. diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 1536d0bee1e..09a3f9f625c 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -361,7 +361,7 @@ async def test_camera_event(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "image/jpeg" @@ -374,7 +374,7 @@ async def test_camera_event(hass, auth, hass_client): # Resolving the device id points to the most recent event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "image/jpeg" @@ -535,7 +535,7 @@ async def test_multiple_image_events_in_session(hass, auth, hass_client): # Resolve the most recent event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier2}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier2}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier2}" assert media.mime_type == "image/jpeg" @@ -548,7 +548,7 @@ async def test_multiple_image_events_in_session(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" assert media.mime_type == "image/jpeg" @@ -632,7 +632,7 @@ async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): # to the same clip preview media clip object. # Resolve media for the first event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" assert media.mime_type == "video/mp4" @@ -645,7 +645,7 @@ async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): # Resolve media for the second event media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier1}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier1}" assert media.mime_type == "video/mp4" @@ -712,6 +712,7 @@ async def test_resolve_missing_event_id(hass, auth): await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}", + None, ) @@ -723,6 +724,7 @@ async def test_resolve_invalid_device_id(hass, auth): await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/invalid-device-id/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + None, ) @@ -740,6 +742,7 @@ async def test_resolve_invalid_event_id(hass, auth): media = await media_source.async_resolve_media( hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW...", + None, ) assert ( media.url == f"/api/nest/event_media/{device.id}/GXXWRWVeHNUlUU3V3MGV3bUOYW..." @@ -835,7 +838,7 @@ async def test_camera_event_clip_preview(hass, auth, hass_client, mp4): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "video/mp4" @@ -921,7 +924,7 @@ async def test_event_media_failure(hass, auth, hass_client): # Resolving the event links to the media media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{device.id}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{device.id}/{event_identifier}" assert media.mime_type == "image/jpeg" @@ -1128,7 +1131,7 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): event_identifier = browse.children[0].identifier media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" @@ -1182,7 +1185,7 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): event_identifier = browse.children[0].identifier media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event_identifier}", None ) assert media.url == f"/api/nest/event_media/{event_identifier}" assert media.mime_type == "video/mp4" @@ -1234,7 +1237,7 @@ async def test_media_store_save_filesystem_error(hass, auth, hass_client): event = browse.children[0] media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/{event.identifier}" + hass, f"{const.URI_SCHEME}{DOMAIN}/{event.identifier}", None ) assert media.url == f"/api/nest/event_media/{event.identifier}" assert media.mime_type == "video/mp4" diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py index db1a79145b4..390da95496a 100644 --- a/tests/components/netatmo/test_media_source.py +++ b/tests/components/netatmo/test_media_source.py @@ -79,7 +79,7 @@ async def test_async_browse_media(hass): # Test successful event resolve media = await media_source.async_resolve_media( - hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672" + hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672", None ) assert media == PlayMedia( url="http:///files/high/index.m3u8", mime_type="application/x-mpegURL" diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 7fd8cc0facb..78fa49a8fc9 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -29,7 +29,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index 22edfef5358..8af1ad9d3bb 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -68,7 +68,7 @@ async def test_browsing(hass): async def test_resolving(hass, mock_get_tts_audio): """Test resolving.""" media = await media_source.async_resolve_media( - hass, "media-source://tts/demo?message=Hello%20World" + hass, "media-source://tts/demo?message=Hello%20World", None ) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" @@ -82,7 +82,9 @@ async def test_resolving(hass, mock_get_tts_audio): # Pass language and options mock_get_tts_audio.reset_mock() media = await media_source.async_resolve_media( - hass, "media-source://tts/demo?message=Bye%20World&language=de&voice=Paulus" + hass, + "media-source://tts/demo?message=Bye%20World&language=de&voice=Paulus", + None, ) assert media.url.startswith("/api/tts_proxy/") assert media.mime_type == "audio/mpeg" @@ -98,16 +100,18 @@ async def test_resolving_errors(hass): """Test resolving.""" # No message added with pytest.raises(media_source.Unresolvable): - await media_source.async_resolve_media(hass, "media-source://tts/demo") + await media_source.async_resolve_media(hass, "media-source://tts/demo", None) # Non-existing provider with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media( - hass, "media-source://tts/non-existing?message=bla" + hass, "media-source://tts/non-existing?message=bla", None ) # Non-existing option with pytest.raises(media_source.Unresolvable): await media_source.async_resolve_media( - hass, "media-source://tts/non-existing?message=bla&non_existing_option=bla" + hass, + "media-source://tts/non-existing?message=bla&non_existing_option=bla", + None, ) diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 3e74d9dc815..099b280625f 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -33,7 +33,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index fdc204384a5..8549b51c341 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -27,7 +27,7 @@ async def get_media_source_url(hass, media_content_id): if media_source.DOMAIN not in hass.config.components: assert await async_setup_component(hass, media_source.DOMAIN, {}) - resolved = await media_source.async_resolve_media(hass, media_content_id) + resolved = await media_source.async_resolve_media(hass, media_content_id, None) return resolved.url From 2a1405c4bdc908520bcf4eab0e15a597b27f90d4 Mon Sep 17 00:00:00 2001 From: xLarry Date: Fri, 27 May 2022 18:19:18 +0200 Subject: [PATCH 043/947] Bump laundrify_aio to v1.1.2 (#72605) --- homeassistant/components/laundrify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/laundrify/manifest.json b/homeassistant/components/laundrify/manifest.json index 6a61446d31c..a5737b9cf97 100644 --- a/homeassistant/components/laundrify/manifest.json +++ b/homeassistant/components/laundrify/manifest.json @@ -3,7 +3,7 @@ "name": "laundrify", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/laundrify", - "requirements": ["laundrify_aio==1.1.1"], + "requirements": ["laundrify_aio==1.1.2"], "codeowners": ["@xLarry"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 7e299929590..ee11241aa51 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -927,7 +927,7 @@ krakenex==2.1.0 lakeside==0.12 # homeassistant.components.laundrify -laundrify_aio==1.1.1 +laundrify_aio==1.1.2 # homeassistant.components.foscam libpyfoscam==1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25cba8501e9..4a8d2c99107 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -655,7 +655,7 @@ kostal_plenticore==0.2.0 krakenex==2.1.0 # homeassistant.components.laundrify -laundrify_aio==1.1.1 +laundrify_aio==1.1.2 # homeassistant.components.foscam libpyfoscam==1.0 From d59258bd2553ea1c89dd203590cb7e521d6d2000 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 May 2022 10:30:40 -0700 Subject: [PATCH 044/947] Revert "Add service entity context (#71558)" (#72610) --- homeassistant/helpers/service.py | 11 ----------- tests/helpers/test_service.py | 16 +--------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9a1e6caa27e..bc3451c24c0 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Iterable -from contextvars import ContextVar import dataclasses from functools import partial, wraps import logging @@ -64,15 +63,6 @@ _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" -_current_entity: ContextVar[str | None] = ContextVar("current_entity", default=None) - - -@callback -def async_get_current_entity() -> str | None: - """Get the current entity on which the service is called.""" - return _current_entity.get() - - class ServiceParams(TypedDict): """Type for service call parameters.""" @@ -716,7 +706,6 @@ async def _handle_entity_call( ) -> None: """Handle calling service method.""" entity.async_set_context(context) - _current_entity.set(entity.entity_id) if isinstance(func, str): result = hass.async_run_job(partial(getattr(entity, func), **data)) # type: ignore[arg-type] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 76cf83e31bf..d08477dc917 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -19,12 +19,12 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.helpers import ( - config_validation as cv, device_registry as dev_reg, entity_registry as ent_reg, service, template, ) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import EntityCategory from homeassistant.setup import async_setup_component @@ -1206,17 +1206,3 @@ async def test_async_extract_config_entry_ids(hass): ) assert await service.async_extract_config_entry_ids(hass, call) == {"abc"} - - -async def test_current_entity_context(hass, mock_entities): - """Test we set the current entity context var.""" - - async def mock_service(entity, call): - assert entity.entity_id == service.async_get_current_entity() - - await service.entity_service_call( - hass, - [Mock(entities=mock_entities)], - mock_service, - ha.ServiceCall("test_domain", "test_service", {"entity_id": "light.kitchen"}), - ) From a733b92389f41034e547f7f8d2adb650ae3499ae Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 27 May 2022 10:31:48 -0700 Subject: [PATCH 045/947] Include provider type in auth token response (#72560) --- homeassistant/components/auth/__init__.py | 19 +++++++++++++++---- tests/components/auth/test_init.py | 1 + 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 1dc483eec6e..27fe511182d 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -19,13 +19,15 @@ Exchange the authorization code retrieved from the login flow for tokens. Return value will be the access and refresh tokens. The access token will have a limited expiration. New access tokens can be requested using the refresh -token. +token. The value ha_auth_provider will contain the auth provider type that was +used to authorize the refresh token. { "access_token": "ABCDEFGH", "expires_in": 1800, "refresh_token": "IJKLMNOPQRST", - "token_type": "Bearer" + "token_type": "Bearer", + "ha_auth_provider": "homeassistant" } ## Grant type refresh_token @@ -289,7 +291,12 @@ class TokenView(HomeAssistantView): "expires_in": int( refresh_token.access_token_expiration.total_seconds() ), - } + "ha_auth_provider": credential.auth_provider_type, + }, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, ) async def _async_handle_refresh_token(self, hass, data, remote_addr): @@ -346,7 +353,11 @@ class TokenView(HomeAssistantView): "expires_in": int( refresh_token.access_token_expiration.total_seconds() ), - } + }, + headers={ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }, ) diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index ef231950bd9..706221d9371 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -81,6 +81,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): assert ( await hass.auth.async_validate_access_token(tokens["access_token"]) is not None ) + assert tokens["ha_auth_provider"] == "insecure_example" # Use refresh token to get more tokens. resp = await client.post( From 040e120101ffbdd93dd686928d88a60849b68dfe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 07:32:26 -1000 Subject: [PATCH 046/947] Fix recorder system health when the db_url is lacking a hostname (#72612) --- .../recorder/system_health/__init__.py | 5 ++- .../components/recorder/test_system_health.py | 31 +++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/system_health/__init__.py b/homeassistant/components/recorder/system_health/__init__.py index 8ba68a1649b..c4bf2c3bb89 100644 --- a/homeassistant/components/recorder/system_health/__init__.py +++ b/homeassistant/components/recorder/system_health/__init__.py @@ -2,8 +2,7 @@ from __future__ import annotations from typing import Any - -from yarl import URL +from urllib.parse import urlparse from homeassistant.components import system_health from homeassistant.components.recorder.core import Recorder @@ -60,7 +59,7 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: instance = get_instance(hass) run_history = instance.run_history - database_name = URL(instance.db_url).path.lstrip("/") + database_name = urlparse(instance.db_url).path.lstrip("/") db_engine_info = _async_get_db_engine_info(instance) db_stats: dict[str, Any] = {} diff --git a/tests/components/recorder/test_system_health.py b/tests/components/recorder/test_system_health.py index 80997b9df36..b465ee89ebe 100644 --- a/tests/components/recorder/test_system_health.py +++ b/tests/components/recorder/test_system_health.py @@ -53,6 +53,37 @@ async def test_recorder_system_health_alternate_dbms(hass, recorder_mock, dialec } +@pytest.mark.parametrize( + "dialect_name", [SupportedDialect.MYSQL, SupportedDialect.POSTGRESQL] +) +async def test_recorder_system_health_db_url_missing_host( + hass, recorder_mock, dialect_name +): + """Test recorder system health with a db_url without a hostname.""" + assert await async_setup_component(hass, "system_health", {}) + await async_wait_recording_done(hass) + + instance = get_instance(hass) + with patch( + "homeassistant.components.recorder.core.Recorder.dialect_name", dialect_name + ), patch.object( + instance, + "db_url", + "postgresql://homeassistant:blabla@/home_assistant?host=/config/socket", + ), patch( + "sqlalchemy.orm.session.Session.execute", + return_value=Mock(first=Mock(return_value=("1048576",))), + ): + info = await get_system_health_info(hass, "recorder") + assert info == { + "current_recorder_run": instance.run_history.current.start, + "oldest_recorder_run": instance.run_history.first.start, + "estimated_db_size": "1.00 MiB", + "database_engine": dialect_name.value, + "database_version": ANY, + } + + async def test_recorder_system_health_crashed_recorder_runs_table( hass: HomeAssistant, async_setup_recorder_instance: SetupRecorderInstanceT ): From ea1e40a424a24a99883249f51e90651ec1bff1d1 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 27 May 2022 11:32:38 -0600 Subject: [PATCH 047/947] Bump regenmaschine to 2022.05.0 (#72613) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index 331f191d029..bbe58e263b1 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==2022.01.0"], + "requirements": ["regenmaschine==2022.05.0"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index ee11241aa51..a05e0641723 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2065,7 +2065,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.01.0 +regenmaschine==2022.05.0 # homeassistant.components.renault renault-api==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a8d2c99107..bfa4f2eb165 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,7 +1364,7 @@ rachiopy==1.0.3 radios==0.1.1 # homeassistant.components.rainmachine -regenmaschine==2022.01.0 +regenmaschine==2022.05.0 # homeassistant.components.renault renault-api==0.1.11 From 34323ce64542ed2b54af4032f1ba99df74f3bb18 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 08:11:33 -1000 Subject: [PATCH 048/947] Add explict type casts for postgresql filters (#72615) --- homeassistant/components/recorder/filters.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 7f1d0bc597f..0a383d8ef2b 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable import json from typing import Any -from sqlalchemy import Column, not_, or_ +from sqlalchemy import JSON, Column, Text, cast, not_, or_ from sqlalchemy.sql.elements import ClauseList from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE @@ -110,8 +110,7 @@ class Filters: """Generate the entity filter query.""" _encoder = json.dumps return or_( - (ENTITY_ID_IN_EVENT == _encoder(None)) - & (OLD_ENTITY_ID_IN_EVENT == _encoder(None)), + (ENTITY_ID_IN_EVENT == JSON.NULL) & (OLD_ENTITY_ID_IN_EVENT == JSON.NULL), self._generate_filter_for_columns( (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder ).self_group(), @@ -123,7 +122,7 @@ def _globs_to_like( ) -> ClauseList: """Translate glob to sql.""" return or_( - column.like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) + cast(column, Text()).like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) for glob_str in glob_strs for column in columns ) @@ -133,7 +132,7 @@ def _entity_matcher( entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: return or_( - column.in_([encoder(entity_id) for entity_id in entity_ids]) + cast(column, Text()).in_([encoder(entity_id) for entity_id in entity_ids]) for column in columns ) @@ -142,5 +141,7 @@ def _domain_matcher( domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: return or_( - column.like(encoder(f"{domain}.%")) for domain in domains for column in columns + cast(column, Text()).like(encoder(f"{domain}.%")) + for domain in domains + for column in columns ) From 9fe4aef4bcb7c041ce71131e1967867f911770ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 27 May 2022 23:37:19 +0200 Subject: [PATCH 049/947] Bump awesomeversion from 22.5.1 to 22.5.2 (#72624) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f235cc3f02c..a43b4f99f63 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ async-upnp-client==0.30.1 async_timeout==4.0.2 atomicwrites==1.4.0 attrs==21.2.0 -awesomeversion==22.5.1 +awesomeversion==22.5.2 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 diff --git a/pyproject.toml b/pyproject.toml index 499376b95f5..cc745f58ad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "async_timeout==4.0.2", "attrs==21.2.0", "atomicwrites==1.4.0", - "awesomeversion==22.5.1", + "awesomeversion==22.5.2", "bcrypt==3.1.7", "certifi>=2021.5.30", "ciso8601==2.2.0", diff --git a/requirements.txt b/requirements.txt index 8321e70f8de..fe2bf87ad25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ astral==2.2 async_timeout==4.0.2 attrs==21.2.0 atomicwrites==1.4.0 -awesomeversion==22.5.1 +awesomeversion==22.5.2 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 From a43d47fa0bb2a7c3d18ec28c7b9e2059f90e0223 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 11:38:29 -1000 Subject: [PATCH 050/947] Escape % and _ in history/logbook entity_globs, and use ? as _ (#72623) Co-authored-by: pyos --- homeassistant/components/recorder/filters.py | 11 +- tests/components/history/test_init.py | 12 +- .../components/logbook/test_websocket_api.py | 207 ++++++++++++++++++ 3 files changed, 223 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 0a383d8ef2b..5dd1e4b7884 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -18,8 +18,11 @@ DOMAIN = "history" HISTORY_FILTERS = "history_filters" GLOB_TO_SQL_CHARS = { - 42: "%", # * - 46: "_", # . + ord("*"): "%", + ord("?"): "_", + ord("%"): "\\%", + ord("_"): "\\_", + ord("\\"): "\\\\", } @@ -122,7 +125,9 @@ def _globs_to_like( ) -> ClauseList: """Translate glob to sql.""" return or_( - cast(column, Text()).like(encoder(glob_str.translate(GLOB_TO_SQL_CHARS))) + cast(column, Text()).like( + encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\" + ) for glob_str in glob_strs for column in columns ) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index a2626ab2004..cbc5e86c37e 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -719,7 +719,7 @@ async def test_fetch_period_api_with_entity_glob_exclude( { "history": { "exclude": { - "entity_globs": ["light.k*"], + "entity_globs": ["light.k*", "binary_sensor.*_?"], "domains": "switch", "entities": "media_player.test", }, @@ -731,6 +731,9 @@ async def test_fetch_period_api_with_entity_glob_exclude( hass.states.async_set("light.match", "on") hass.states.async_set("switch.match", "on") hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.sensor_l", "on") + hass.states.async_set("binary_sensor.sensor_r", "on") + hass.states.async_set("binary_sensor.sensor", "on") await async_wait_recording_done(hass) @@ -740,9 +743,10 @@ async def test_fetch_period_api_with_entity_glob_exclude( ) assert response.status == HTTPStatus.OK response_json = await response.json() - assert len(response_json) == 2 - assert response_json[0][0]["entity_id"] == "light.cow" - assert response_json[1][0]["entity_id"] == "light.match" + assert len(response_json) == 3 + assert response_json[0][0]["entity_id"] == "binary_sensor.sensor" + assert response_json[1][0]["entity_id"] == "light.cow" + assert response_json[2][0]["entity_id"] == "light.match" async def test_fetch_period_api_with_entity_glob_include_and_exclude( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 02fea4f980f..9d7146ec96c 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -22,6 +22,7 @@ from homeassistant.const import ( CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, + CONF_INCLUDE, EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_ON, @@ -642,6 +643,212 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( assert sum(hass.bus.async_listeners().values()) == init_count +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_unsubscribe_logbook_stream_included_entities( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream with included entities.""" + test_entities = ( + "light.inc", + "switch.any", + "cover.included", + "cover.not_included", + "automation.not_included", + "binary_sensor.is_light", + ) + + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "automation", "script") + ] + ) + await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_INCLUDE: { + CONF_ENTITIES: ["light.inc"], + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: "*.included", + } + }, + }, + ) + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + + hass.states.async_remove("light.zulu") + await hass.async_block_till_done() + + hass.states.async_set("light.zulu", "on", {"effect": "help", "color": "blue"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "on", "when": ANY}, + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "on", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "on", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + + for _ in range(3): + for entity_id in test_entities: + hass.states.async_set(entity_id, STATE_ON) + hass.states.async_set(entity_id, STATE_OFF) + await async_wait_recording_done(hass) + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"entity_id": "light.inc", "state": "on", "when": ANY}, + {"entity_id": "light.inc", "state": "off", "when": ANY}, + {"entity_id": "switch.any", "state": "on", "when": ANY}, + {"entity_id": "switch.any", "state": "off", "when": ANY}, + {"entity_id": "cover.included", "state": "on", "when": ANY}, + {"entity_id": "cover.included", "state": "off", "when": ANY}, + ] + + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.included"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.excluded"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + { + ATTR_NAME: "Mock automation switch matching entity", + ATTR_ENTITY_ID: "switch.match_domain", + }, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation switch matching domain", ATTR_DOMAIN: "switch"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation matches nothing"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "light.inc"}, + ) + + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_id": ANY, + "domain": "automation", + "entity_id": "cover.included", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "switch.match_domain", + "message": "triggered", + "name": "Mock automation switch matching entity", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation switch matching domain", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation matches nothing", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "light.inc", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + ] + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( hass, recorder_mock, hass_ws_client From 327c6964e233d482224831c65a6fe074a79478fa Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 28 May 2022 00:24:05 +0000 Subject: [PATCH 051/947] [ci skip] Translation update --- homeassistant/components/totalconnect/translations/ca.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/totalconnect/translations/ca.json b/homeassistant/components/totalconnect/translations/ca.json index 0c1a540b8ac..36c1037d917 100644 --- a/homeassistant/components/totalconnect/translations/ca.json +++ b/homeassistant/components/totalconnect/translations/ca.json @@ -32,6 +32,10 @@ "options": { "step": { "init": { + "data": { + "auto_bypass_low_battery": "Bypass autom\u00e0tic de bateria baixa" + }, + "description": "Bypass autom\u00e0tic de les zones que informin de bateria baixa.", "title": "Opcions de TotalConnect" } } From 4a5679db08e8624ce383c2527ecb4e72799026f4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 27 May 2022 22:49:55 -1000 Subject: [PATCH 052/947] Prevent config entries from being reloaded concurrently (#72636) * Prevent config entries being reloaded concurrently - Fixes Config entry has already been setup when two places try to reload the config entry at the same time. - This comes up quite a bit: https://github.com/home-assistant/core/issues?q=is%3Aissue+sort%3Aupdated-desc+%22Config+entry+has+already+been+setup%22+is%3Aclosed * Make sure plex creates mocks in the event loop * drop reload_lock, already inherits --- homeassistant/config_entries.py | 13 ++++++---- tests/components/plex/conftest.py | 2 +- tests/test_config_entries.py | 40 ++++++++++++++++++++++++++++++- 3 files changed, 49 insertions(+), 6 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7dfbb131c1b..0ac02adb8d0 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -186,6 +186,7 @@ class ConfigEntry: "reason", "_async_cancel_retry_setup", "_on_unload", + "reload_lock", ) def __init__( @@ -275,6 +276,9 @@ class ConfigEntry: # Hold list for functions to call on unload. self._on_unload: list[CALLBACK_TYPE] | None = None + # Reload lock to prevent conflicting reloads + self.reload_lock = asyncio.Lock() + async def async_setup( self, hass: HomeAssistant, @@ -1005,12 +1009,13 @@ class ConfigEntries: if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry - unload_result = await self.async_unload(entry_id) + async with entry.reload_lock: + unload_result = await self.async_unload(entry_id) - if not unload_result or entry.disabled_by: - return unload_result + if not unload_result or entry.disabled_by: + return unload_result - return await self.async_setup(entry_id) + return await self.async_setup(entry_id) async def async_set_disabled_by( self, entry_id: str, disabled_by: ConfigEntryDisabler | None diff --git a/tests/components/plex/conftest.py b/tests/components/plex/conftest.py index 47e7d96d2fe..506aadcce61 100644 --- a/tests/components/plex/conftest.py +++ b/tests/components/plex/conftest.py @@ -381,7 +381,7 @@ def hubs_music_library_fixture(): @pytest.fixture(name="entry") -def mock_config_entry(): +async def mock_config_entry(): """Return the default mocked config entry.""" return MockConfigEntry( domain=DOMAIN, diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 3611c204ba7..2602887d1d5 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1497,7 +1497,7 @@ async def test_reload_entry_entity_registry_works(hass): ) await hass.async_block_till_done() - assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_unload_entry.mock_calls) == 2 async def test_unique_id_persisted(hass, manager): @@ -3080,3 +3080,41 @@ async def test_deprecated_disabled_by_str_set(hass, manager, caplog): ) assert entry.disabled_by is config_entries.ConfigEntryDisabler.USER assert " str for config entry disabled_by. This is deprecated " in caplog.text + + +async def test_entry_reload_concurrency(hass, manager): + """Test multiple reload calls do not cause a reload race.""" + entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + async_setup = AsyncMock(return_value=True) + loaded = 1 + + async def _async_setup_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded += 1 + return loaded == 1 + + async def _async_unload_entry(*args, **kwargs): + await asyncio.sleep(0) + nonlocal loaded + loaded -= 1 + return loaded == 0 + + mock_integration( + hass, + MockModule( + "comp", + async_setup=async_setup, + async_setup_entry=_async_setup_entry, + async_unload_entry=_async_unload_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + tasks = [] + for _ in range(15): + tasks.append(asyncio.create_task(manager.async_reload(entry.entry_id))) + await asyncio.gather(*tasks) + assert entry.state is config_entries.ConfigEntryState.LOADED + assert loaded == 1 From 233f086853eb7edac47b636504205731b80942c1 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 28 May 2022 19:55:50 +0200 Subject: [PATCH 053/947] Bump bimmer_connected to 0.9.2 (#72653) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index d41a87ef2c1..c7130d12698 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.0"], + "requirements": ["bimmer_connected==0.9.2"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index a05e0641723..bf7769454f8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -394,7 +394,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.0 +bimmer_connected==0.9.2 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bfa4f2eb165..698dc977f2b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.0 +bimmer_connected==0.9.2 # homeassistant.components.blebox blebox_uniapi==1.3.3 From e0614953a23de9ef9becb85ff71c699cbee4f0bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 May 2022 09:47:14 -1000 Subject: [PATCH 054/947] Add support for async_remove_config_entry_device to homekit_controller (#72630) --- .../components/homekit_controller/__init__.py | 18 +++++++- tests/components/homekit_controller/common.py | 14 +++++++ .../homekit_controller/test_init.py | 42 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 6b538658b23..6909b226556 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -15,9 +15,10 @@ from aiohomekit.model.characteristics import ( from aiohomekit.model.services import Service, ServicesTypes from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ATTR_IDENTIFIERS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType @@ -261,3 +262,18 @@ async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: "HomeKit again", entry.title, ) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove homekit_controller config entry from a device.""" + hkid = config_entry.data["AccessoryPairingID"] + connection: HKDevice = hass.data[KNOWN_DEVICES][hkid] + return not device_entry.identifiers.intersection( + identifier + for accessory in connection.entity_map.accessories + for identifier in connection.device_info_for_accessory(accessory)[ + ATTR_IDENTIFIERS + ] + ) diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 8f59fae8639..749bd4b0f07 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -372,3 +372,17 @@ async def assert_devices_and_entities_created( # Root device must not have a via, otherwise its not the device assert root_device.via_device_id is None + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] diff --git a/tests/components/homekit_controller/test_init.py b/tests/components/homekit_controller/test_init.py index 03694e7186a..820b89e587d 100644 --- a/tests/components/homekit_controller/test_init.py +++ b/tests/components/homekit_controller/test_init.py @@ -8,9 +8,17 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.homekit_controller.const import ENTITY_MAP from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component + +from .common import Helper, remove_device from tests.components.homekit_controller.common import setup_test_component +ALIVE_DEVICE_NAME = "Light Bulb" +ALIVE_DEEVICE_ENTITY_ID = "light.testdevice" + def create_motion_sensor_service(accessory): """Define motion characteristics as per page 225 of HAP spec.""" @@ -47,3 +55,37 @@ async def test_async_remove_entry(hass: HomeAssistant): assert len(controller.pairings) == 0 assert hkid not in hass.data[ENTITY_MAP].storage_data + + +def create_alive_service(accessory): + """Create a service to validate we can only remove dead devices.""" + service = accessory.add_service(ServicesTypes.LIGHTBULB, name=ALIVE_DEVICE_NAME) + service.add_char(CharacteristicsTypes.ON) + return service + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + helper: Helper = await setup_test_component(hass, create_alive_service) + config_entry = helper.config_entry + entry_id = config_entry.entry_id + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities[ALIVE_DEEVICE_ENTITY_ID] + device_registry = dr.async_get(hass) + + live_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("homekit_controller:accessory-id", "E9:88:E7:B8:B4:40:aid:1")}, + ) + assert ( + await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) + is True + ) From a598cdfeb30396bf266e3654abe79d64e3f81760 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 May 2022 12:51:40 -0700 Subject: [PATCH 055/947] Don't import google calendar user pref for disabling new entities (#72652) --- homeassistant/components/google/__init__.py | 29 ++++---- tests/components/google/test_init.py | 82 ++++++++------------- 2 files changed, 42 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index b7263d2e469..1336e9991e3 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -199,11 +199,18 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning( "Configuration of Google Calendar in YAML in configuration.yaml is " "is deprecated and will be removed in a future release; Your existing " - "OAuth Application Credentials and other settings have been imported " + "OAuth Application Credentials and access settings have been imported " "into the UI automatically and can be safely removed from your " "configuration.yaml file" ) - + if conf.get(CONF_TRACK_NEW) is False: + # The track_new as False would previously result in new entries + # in google_calendars.yaml with track set to Fasle which is + # handled at calendar entity creation time. + _LOGGER.warning( + "You must manually set the integration System Options in the " + "UI to disable newly discovered entities going forward" + ) return True @@ -260,23 +267,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def async_upgrade_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Upgrade the config entry if needed.""" - if DATA_CONFIG not in hass.data[DOMAIN] and entry.options: + if entry.options: return - - options = ( - entry.options - if entry.options - else { - CONF_CALENDAR_ACCESS: get_feature_access(hass).name, - } - ) - disable_new_entities = ( - not hass.data[DOMAIN].get(DATA_CONFIG, {}).get(CONF_TRACK_NEW, True) - ) hass.config_entries.async_update_entry( entry, - options=options, - pref_disable_new_entities=disable_new_entities, + options={ + CONF_CALENDAR_ACCESS: get_feature_access(hass).name, + }, ) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 93c0642514e..b6f7a6b4cbc 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -154,57 +154,6 @@ async def test_calendar_yaml_error( assert hass.states.get(TEST_API_ENTITY) -@pytest.mark.parametrize( - "google_config_track_new,calendars_config,expected_state", - [ - ( - None, - [], - State( - TEST_API_ENTITY, - STATE_OFF, - attributes={ - "offset_reached": False, - "friendly_name": TEST_API_ENTITY_NAME, - }, - ), - ), - ( - True, - [], - State( - TEST_API_ENTITY, - STATE_OFF, - attributes={ - "offset_reached": False, - "friendly_name": TEST_API_ENTITY_NAME, - }, - ), - ), - (False, [], None), - ], - ids=["default", "True", "False"], -) -async def test_track_new( - hass: HomeAssistant, - component_setup: ComponentSetup, - mock_calendars_list: ApiResult, - test_api_calendar: dict[str, Any], - mock_events_list: ApiResult, - mock_calendars_yaml: None, - expected_state: State, - setup_config_entry: MockConfigEntry, -) -> None: - """Test behavior of configuration.yaml settings for tracking new calendars not in the config.""" - - mock_calendars_list({"items": [test_api_calendar]}) - mock_events_list({}) - assert await component_setup() - - state = hass.states.get(TEST_API_ENTITY) - assert_state(state, expected_state) - - @pytest.mark.parametrize("calendars_config", [[]]) async def test_found_calendar_from_api( hass: HomeAssistant, @@ -263,7 +212,7 @@ async def test_load_application_credentials( @pytest.mark.parametrize( - "calendars_config_track,expected_state", + "calendars_config_track,expected_state,google_config_track_new", [ ( True, @@ -275,8 +224,35 @@ async def test_load_application_credentials( "friendly_name": TEST_YAML_ENTITY_NAME, }, ), + None, ), - (False, None), + ( + True, + State( + TEST_YAML_ENTITY, + STATE_OFF, + attributes={ + "offset_reached": False, + "friendly_name": TEST_YAML_ENTITY_NAME, + }, + ), + True, + ), + ( + True, + State( + TEST_YAML_ENTITY, + STATE_OFF, + attributes={ + "offset_reached": False, + "friendly_name": TEST_YAML_ENTITY_NAME, + }, + ), + False, # Has no effect + ), + (False, None, None), + (False, None, True), + (False, None, False), ], ) async def test_calendar_config_track_new( From a4f678e7c9364d1962f3911530d67ee82483f1fe Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sat, 28 May 2022 22:31:03 +0200 Subject: [PATCH 056/947] Manage stations via integrations configuration in Tankerkoenig (#72654) --- .../components/tankerkoenig/config_flow.py | 64 +++++++++++++------ .../components/tankerkoenig/strings.json | 2 +- .../tankerkoenig/translations/en.json | 4 +- .../tankerkoenig/test_config_flow.py | 23 +++++-- 4 files changed, 68 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 65c367d1ba4..af3b5273b16 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_SHOW_ON_MAP, LENGTH_KILOMETERS, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.selector import ( @@ -29,6 +29,24 @@ from homeassistant.helpers.selector import ( from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_TYPES +async def async_get_nearby_stations( + hass: HomeAssistant, data: dict[str, Any] +) -> dict[str, Any]: + """Fetch nearby stations.""" + try: + return await hass.async_add_executor_job( + getNearbyStations, + data[CONF_API_KEY], + data[CONF_LOCATION][CONF_LATITUDE], + data[CONF_LOCATION][CONF_LONGITUDE], + data[CONF_RADIUS], + "all", + "dist", + ) + except customException as err: + return {"ok": False, "message": err, "exception": True} + + class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -57,7 +75,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): selected_station_ids: list[str] = [] # add all nearby stations - nearby_stations = await self._get_nearby_stations(config) + nearby_stations = await async_get_nearby_stations(self.hass, config) for station in nearby_stations.get("stations", []): selected_station_ids.append(station["id"]) @@ -91,7 +109,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) self._abort_if_unique_id_configured() - data = await self._get_nearby_stations(user_input) + data = await async_get_nearby_stations(self.hass, user_input) if not data.get("ok"): return self._show_form_user( user_input, errors={CONF_API_KEY: "invalid_auth"} @@ -182,21 +200,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): options=options, ) - async def _get_nearby_stations(self, data: dict[str, Any]) -> dict[str, Any]: - """Fetch nearby stations.""" - try: - return await self.hass.async_add_executor_job( - getNearbyStations, - data[CONF_API_KEY], - data[CONF_LOCATION][CONF_LATITUDE], - data[CONF_LOCATION][CONF_LONGITUDE], - data[CONF_RADIUS], - "all", - "dist", - ) - except customException as err: - return {"ok": False, "message": err, "exception": True} - class OptionsFlowHandler(config_entries.OptionsFlow): """Handle an options flow.""" @@ -204,14 +207,36 @@ class OptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry + self._stations: dict[str, str] = {} async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle options flow.""" if user_input is not None: + self.hass.config_entries.async_update_entry( + self.config_entry, + data={ + **self.config_entry.data, + CONF_STATIONS: user_input.pop(CONF_STATIONS), + }, + ) return self.async_create_entry(title="", data=user_input) + nearby_stations = await async_get_nearby_stations( + self.hass, dict(self.config_entry.data) + ) + if stations := nearby_stations.get("stations"): + for station in stations: + self._stations[ + station["id"] + ] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)" + + # add possible extra selected stations from import + for selected_station in self.config_entry.data[CONF_STATIONS]: + if selected_station not in self._stations: + self._stations[selected_station] = f"id: {selected_station}" + return self.async_show_form( step_id="init", data_schema=vol.Schema( @@ -220,6 +245,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_SHOW_ON_MAP, default=self.config_entry.options[CONF_SHOW_ON_MAP], ): bool, + vol.Required( + CONF_STATIONS, default=self.config_entry.data[CONF_STATIONS] + ): cv.multi_select(self._stations), } ), ) diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 7c1ba54fcc0..5e0c367c192 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -32,7 +32,7 @@ "init": { "title": "Tankerkoenig options", "data": { - "scan_interval": "Update Interval", + "stations": "Stations", "show_on_map": "Show stations on map" } } diff --git a/homeassistant/components/tankerkoenig/translations/en.json b/homeassistant/components/tankerkoenig/translations/en.json index 399788de8f4..83cc36fd4c8 100644 --- a/homeassistant/components/tankerkoenig/translations/en.json +++ b/homeassistant/components/tankerkoenig/translations/en.json @@ -31,8 +31,8 @@ "step": { "init": { "data": { - "scan_interval": "Update Interval", - "show_on_map": "Show stations on map" + "show_on_map": "Show stations on map", + "stations": "Stations" }, "title": "Tankerkoenig options" } diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index 0a90b424b73..b18df0eed24 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -42,6 +42,15 @@ MOCK_STATIONS_DATA = { ], } +MOCK_OPTIONS_DATA = { + **MOCK_USER_DATA, + CONF_STATIONS: [ + "3bcd61da-xxxx-xxxx-xxxx-19d5523a7ae8", + "36b4b812-xxxx-xxxx-xxxx-c51735325858", + "54e2b642-xxxx-xxxx-xxxx-87cd4e9867f1", + ], +} + MOCK_IMPORT_DATA = { CONF_API_KEY: "269534f6-xxxx-xxxx-xxxx-yyyyzzzzxxxx", CONF_FUEL_TYPES: ["e5"], @@ -217,7 +226,7 @@ async def test_options_flow(hass: HomeAssistant): mock_config = MockConfigEntry( domain=DOMAIN, - data=MOCK_USER_DATA, + data=MOCK_OPTIONS_DATA, options={CONF_SHOW_ON_MAP: True}, unique_id=f"{DOMAIN}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", ) @@ -225,17 +234,23 @@ async def test_options_flow(hass: HomeAssistant): with patch( "homeassistant.components.tankerkoenig.async_setup_entry" - ) as mock_setup_entry: + ) as mock_setup_entry, patch( + "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", + return_value=MOCK_NEARVY_STATIONS_OK, + ): await mock_config.async_setup(hass) await hass.async_block_till_done() assert mock_setup_entry.called - result = await hass.config_entries.options.async_init(mock_config.entry_id) + result = await hass.config_entries.options.async_init(mock_config.entry_id) assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "init" result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={CONF_SHOW_ON_MAP: False}, + user_input={ + CONF_SHOW_ON_MAP: False, + CONF_STATIONS: MOCK_OPTIONS_DATA[CONF_STATIONS], + }, ) assert result["type"] == RESULT_TYPE_CREATE_ENTRY assert not mock_config.options[CONF_SHOW_ON_MAP] From 24c34c0ef0e38d37cd94e4806d1d0a5b715cfe7d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 29 May 2022 01:26:50 +0200 Subject: [PATCH 057/947] Strict typing Sensibo (#72454) --- .strict-typing | 1 + .../components/sensibo/binary_sensor.py | 22 +++++--- homeassistant/components/sensibo/climate.py | 54 +++++++++++++------ .../components/sensibo/config_flow.py | 4 +- homeassistant/components/sensibo/entity.py | 6 ++- .../components/sensibo/manifest.json | 3 +- homeassistant/components/sensibo/number.py | 5 +- homeassistant/components/sensibo/select.py | 7 ++- homeassistant/components/sensibo/sensor.py | 21 +++++--- homeassistant/components/sensibo/update.py | 2 + homeassistant/components/sensibo/util.py | 2 +- mypy.ini | 11 ++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/sensibo/test_climate.py | 34 ++++++++++++ 15 files changed, 136 insertions(+), 40 deletions(-) diff --git a/.strict-typing b/.strict-typing index e07d8b9cfc8..7fe03203583 100644 --- a/.strict-typing +++ b/.strict-typing @@ -196,6 +196,7 @@ homeassistant.components.rtsp_to_webrtc.* homeassistant.components.samsungtv.* homeassistant.components.scene.* homeassistant.components.select.* +homeassistant.components.sensibo.* homeassistant.components.sensor.* homeassistant.components.senseme.* homeassistant.components.senz.* diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 27e551a51c8..e8d83f04593 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -20,6 +21,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class MotionBaseEntityDescriptionMixin: @@ -93,13 +96,16 @@ async def async_setup_entry( coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] - entities.extend( - SensiboMotionSensor(coordinator, device_id, sensor_id, sensor_data, description) - for device_id, device_data in coordinator.data.parsed.items() - for sensor_id, sensor_data in device_data.motion_sensors.items() - for description in MOTION_SENSOR_TYPES - if device_data.motion_sensors - ) + + for device_id, device_data in coordinator.data.parsed.items(): + if device_data.motion_sensors: + entities.extend( + SensiboMotionSensor( + coordinator, device_id, sensor_id, sensor_data, description + ) + for sensor_id, sensor_data in device_data.motion_sensors.items() + for description in MOTION_SENSOR_TYPES + ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) for description in DEVICE_SENSOR_TYPES @@ -140,6 +146,8 @@ class SensiboMotionSensor(SensiboMotionBaseEntity, BinarySensorEntity): @property def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" + if TYPE_CHECKING: + assert self.sensor_data return self.entity_description.value_fn(self.sensor_data) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index c1e690cd28a..4b0e797a5b7 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -1,6 +1,8 @@ """Support for Sensibo wifi-enabled home thermostats.""" from __future__ import annotations +from typing import TYPE_CHECKING, Any + import voluptuous as vol from homeassistant.components.climate import ClimateEntity @@ -24,6 +26,7 @@ from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity SERVICE_ASSUME_STATE = "assume_state" +PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { "fanLevel": ClimateEntityFeature.FAN_MODE, @@ -107,70 +110,87 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @property def hvac_mode(self) -> HVACMode: """Return hvac operation.""" - if self.device_data.device_on: + if self.device_data.device_on and self.device_data.hvac_mode: return SENSIBO_TO_HA[self.device_data.hvac_mode] return HVACMode.OFF @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac operation modes.""" - return [SENSIBO_TO_HA[mode] for mode in self.device_data.hvac_modes] + hvac_modes = [] + if TYPE_CHECKING: + assert self.device_data.hvac_modes + for mode in self.device_data.hvac_modes: + hvac_modes.append(SENSIBO_TO_HA[mode]) + return hvac_modes if hvac_modes else [HVACMode.OFF] @property def current_temperature(self) -> float | None: """Return the current temperature.""" - return convert_temperature( - self.device_data.temp, - TEMP_CELSIUS, - self.temperature_unit, - ) + if self.device_data.temp: + return convert_temperature( + self.device_data.temp, + TEMP_CELSIUS, + self.temperature_unit, + ) + return None @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" - return self.device_data.target_temp + target_temp: int | None = self.device_data.target_temp + return target_temp @property def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - return self.device_data.temp_step + target_temp_step: int = self.device_data.temp_step + return target_temp_step @property def fan_mode(self) -> str | None: """Return the fan setting.""" - return self.device_data.fan_mode + fan_mode: str | None = self.device_data.fan_mode + return fan_mode @property def fan_modes(self) -> list[str] | None: """Return the list of available fan modes.""" - return self.device_data.fan_modes + if self.device_data.fan_modes: + return self.device_data.fan_modes + return None @property def swing_mode(self) -> str | None: """Return the swing setting.""" - return self.device_data.swing_mode + swing_mode: str | None = self.device_data.swing_mode + return swing_mode @property def swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" - return self.device_data.swing_modes + if self.device_data.swing_modes: + return self.device_data.swing_modes + return None @property def min_temp(self) -> float: """Return the minimum temperature.""" - return self.device_data.temp_list[0] + min_temp: int = self.device_data.temp_list[0] + return min_temp @property def max_temp(self) -> float: """Return the maximum temperature.""" - return self.device_data.temp_list[-1] + max_temp: int = self.device_data.temp_list[-1] + return max_temp @property def available(self) -> bool: """Return True if entity is available.""" return self.device_data.available and super().available - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if "targetTemperature" not in self.device_data.active_features: raise HomeAssistantError( @@ -255,7 +275,7 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): f"Could not set state for device {self.name} due to reason {failure}" ) - async def async_assume_state(self, state) -> None: + async def async_assume_state(self, state: str) -> None: """Sync state with api.""" await self._async_set_ac_state_property("on", state != HVACMode.OFF, True) await self.coordinator.async_refresh() diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index c4b637e4439..a3254a01839 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -75,7 +75,9 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_user(self, user_input=None) -> FlowResult: + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle the initial step.""" errors: dict[str, str] = {} diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index ce85ecf2a38..c2f4869a4e6 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -1,7 +1,7 @@ """Base entity for Sensibo integration.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any import async_timeout from pysensibo.model import MotionSensor, SensiboDevice @@ -119,6 +119,8 @@ class SensiboMotionBaseEntity(SensiboBaseEntity): ) @property - def sensor_data(self) -> MotionSensor: + def sensor_data(self) -> MotionSensor | None: """Return data for device.""" + if TYPE_CHECKING: + assert self.device_data.motion_sensors return self.device_data.motion_sensors[self._sensor_id] diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 308d991e675..c289322d584 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -2,10 +2,11 @@ "domain": "sensibo", "name": "Sensibo", "documentation": "https://www.home-assistant.io/integrations/sensibo", - "requirements": ["pysensibo==1.0.14"], + "requirements": ["pysensibo==1.0.15"], "config_flow": true, "codeowners": ["@andrey-git", "@gjohansson-ST"], "iot_class": "cloud_polling", + "quality_scale": "platinum", "homekit": { "models": ["Sensibo"] }, diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index fc18e28f1a3..89bd9b270a9 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -14,6 +14,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class SensiboEntityDescriptionMixin: @@ -89,7 +91,8 @@ class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): @property def value(self) -> float | None: """Return the value from coordinator data.""" - return getattr(self.device_data, self.entity_description.key) + value: float | None = getattr(self.device_data, self.entity_description.key) + return value async def async_set_value(self, value: float) -> None: """Set value for calibration.""" diff --git a/homeassistant/components/sensibo/select.py b/homeassistant/components/sensibo/select.py index 56b8fbac4fd..f64411ff4dc 100644 --- a/homeassistant/components/sensibo/select.py +++ b/homeassistant/components/sensibo/select.py @@ -13,6 +13,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class SensiboSelectDescriptionMixin: @@ -82,7 +84,10 @@ class SensiboSelect(SensiboDeviceBaseEntity, SelectEntity): @property def current_option(self) -> str | None: """Return the current selected option.""" - return getattr(self.device_data, self.entity_description.remote_key) + option: str | None = getattr( + self.device_data, self.entity_description.remote_key + ) + return option @property def options(self) -> list[str]: diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 7ac871a61eb..7254948bdad 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -29,6 +30,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity, SensiboMotionBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class MotionBaseEntityDescriptionMixin: @@ -127,13 +130,15 @@ async def async_setup_entry( entities: list[SensiboMotionSensor | SensiboDeviceSensor] = [] - entities.extend( - SensiboMotionSensor(coordinator, device_id, sensor_id, sensor_data, description) - for device_id, device_data in coordinator.data.parsed.items() - for sensor_id, sensor_data in device_data.motion_sensors.items() - for description in MOTION_SENSOR_TYPES - if device_data.motion_sensors - ) + for device_id, device_data in coordinator.data.parsed.items(): + if device_data.motion_sensors: + entities.extend( + SensiboMotionSensor( + coordinator, device_id, sensor_id, sensor_data, description + ) + for sensor_id, sensor_data in device_data.motion_sensors.items() + for description in MOTION_SENSOR_TYPES + ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) for device_id, device_data in coordinator.data.parsed.items() @@ -173,6 +178,8 @@ class SensiboMotionSensor(SensiboMotionBaseEntity, SensorEntity): @property def native_value(self) -> StateType: """Return value of sensor.""" + if TYPE_CHECKING: + assert self.sensor_data return self.entity_description.value_fn(self.sensor_data) diff --git a/homeassistant/components/sensibo/update.py b/homeassistant/components/sensibo/update.py index 6e227a891b0..48304cbd3c5 100644 --- a/homeassistant/components/sensibo/update.py +++ b/homeassistant/components/sensibo/update.py @@ -20,6 +20,8 @@ from .const import DOMAIN from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity +PARALLEL_UPDATES = 0 + @dataclass class DeviceBaseEntityDescriptionMixin: diff --git a/homeassistant/components/sensibo/util.py b/homeassistant/components/sensibo/util.py index fda9d4a210e..8a181cbe568 100644 --- a/homeassistant/components/sensibo/util.py +++ b/homeassistant/components/sensibo/util.py @@ -31,7 +31,7 @@ async def async_validate_api(hass: HomeAssistant, api_key: str) -> str: raise ConnectionError from err devices = device_query["result"] - user = user_query["result"].get("username") + user: str = user_query["result"].get("username") if not devices: LOGGER.error("Could not retrieve any devices from Sensibo servers") raise NoDevicesError diff --git a/mypy.ini b/mypy.ini index e0c512782fb..e2159766fb4 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1919,6 +1919,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sensibo.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.sensor.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index bf7769454f8..93c151fd843 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1792,7 +1792,7 @@ pysaj==0.0.16 pysdcp==1 # homeassistant.components.sensibo -pysensibo==1.0.14 +pysensibo==1.0.15 # homeassistant.components.serial # homeassistant.components.zha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 698dc977f2b..718e876aa2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1211,7 +1211,7 @@ pyruckus==0.12 pysabnzbd==1.1.1 # homeassistant.components.sensibo -pysensibo==1.0.14 +pysensibo==1.0.15 # homeassistant.components.serial # homeassistant.components.zha diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 0b6c043240c..79813727c15 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -624,3 +624,37 @@ async def test_climate_assumed_state( state2 = hass.states.get("climate.hallway") assert state2.state == "off" + + +async def test_climate_no_fan_no_swing( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate fan service.""" + + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] == "high" + assert state.attributes["swing_mode"] == "stopped" + + monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_mode", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_mode", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "fan_modes", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "swing_modes", None) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state = hass.states.get("climate.hallway") + assert state.attributes["fan_mode"] is None + assert state.attributes["swing_mode"] is None + assert state.attributes["fan_modes"] is None + assert state.attributes["swing_modes"] is None From 7a0657c386e2ec28ad1390a339b6b2e8033f1fb1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 29 May 2022 00:23:25 +0000 Subject: [PATCH 058/947] [ci skip] Translation update --- .../accuweather/translations/he.json | 3 +++ .../translations/zh-Hans.json | 3 +++ .../arcam_fmj/translations/zh-Hans.json | 5 +++++ .../button/translations/zh-Hans.json | 2 +- .../components/fan/translations/zh-Hans.json | 2 ++ .../components/group/translations/he.json | 16 +++++++------- .../here_travel_time/translations/he.json | 18 ++++++++++++++++ .../components/hue/translations/zh-Hans.json | 13 ++++++------ .../humidifier/translations/zh-Hans.json | 1 + .../components/ialarm_xr/translations/he.json | 21 +++++++++++++++++++ .../integration/translations/he.json | 6 +++--- .../intellifire/translations/he.json | 3 ++- .../components/kodi/translations/zh-Hans.json | 4 ++-- .../components/laundrify/translations/he.json | 17 +++++++++++++++ .../light/translations/zh-Hans.json | 1 + .../litterrobot/translations/sensor.he.json | 3 ++- .../media_player/translations/zh-Hans.json | 3 +++ .../remote/translations/zh-Hans.json | 1 + .../sensor/translations/zh-Hans.json | 13 ++++++++++++ .../simplisafe/translations/he.json | 5 +++++ .../tankerkoenig/translations/de.json | 3 ++- .../tankerkoenig/translations/en.json | 1 + .../tankerkoenig/translations/pt-BR.json | 3 ++- .../xiaomi_aqara/translations/he.json | 2 +- .../components/zha/translations/zh-Hans.json | 15 +++++++++---- .../zwave_js/translations/zh-Hans.json | 7 +++++++ 26 files changed, 142 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/here_travel_time/translations/he.json create mode 100644 homeassistant/components/ialarm_xr/translations/he.json create mode 100644 homeassistant/components/laundrify/translations/he.json create mode 100644 homeassistant/components/zwave_js/translations/zh-Hans.json diff --git a/homeassistant/components/accuweather/translations/he.json b/homeassistant/components/accuweather/translations/he.json index 0f054ff11fe..f4b95268a25 100644 --- a/homeassistant/components/accuweather/translations/he.json +++ b/homeassistant/components/accuweather/translations/he.json @@ -3,6 +3,9 @@ "abort": { "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." }, + "create_entry": { + "default": "\u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05de\u05e1\u05d5\u05d9\u05de\u05d9\u05dd \u05d0\u05d9\u05e0\u05dd \u05de\u05d5\u05e4\u05e2\u05dc\u05d9\u05dd \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d9\u05db\u05d5\u05dc\u05ea\u05da \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d5\u05ea\u05dd \u05d1\u05e8\u05d9\u05e9\u05d5\u05dd \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05dc\u05d0\u05d7\u05e8 \u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.\n \u05ea\u05d7\u05d6\u05d9\u05ea \u05de\u05d6\u05d2 \u05d4\u05d0\u05d5\u05d5\u05d9\u05e8 \u05d0\u05d9\u05e0\u05d4 \u05de\u05d5\u05e4\u05e2\u05dc\u05ea \u05db\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc. \u05d1\u05d9\u05db\u05d5\u05dc\u05ea\u05da \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05ea \u05d6\u05d4 \u05d1\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1." + }, "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", diff --git a/homeassistant/components/alarm_control_panel/translations/zh-Hans.json b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json index e955d21afdb..1e9694ddb14 100644 --- a/homeassistant/components/alarm_control_panel/translations/zh-Hans.json +++ b/homeassistant/components/alarm_control_panel/translations/zh-Hans.json @@ -4,6 +4,7 @@ "arm_away": "{entity_name} \u79bb\u5bb6\u8b66\u6212", "arm_home": "{entity_name} \u5728\u5bb6\u8b66\u6212", "arm_night": "{entity_name} \u591c\u95f4\u8b66\u6212", + "arm_vacation": "{entity_name} \u5ea6\u5047\u8b66\u6212", "disarm": "\u89e3\u9664 {entity_name} \u8b66\u6212", "trigger": "\u89e6\u53d1 {entity_name}" }, @@ -11,6 +12,7 @@ "is_armed_away": "{entity_name} \u79bb\u5bb6\u8b66\u6212", "is_armed_home": "{entity_name} \u5728\u5bb6\u8b66\u6212", "is_armed_night": "{entity_name} \u591c\u95f4\u8b66\u6212", + "is_armed_vacation": "{entity_name} \u5ea6\u5047\u8b66\u6212", "is_disarmed": "{entity_name} \u8b66\u6212\u5df2\u89e3\u9664", "is_triggered": "{entity_name} \u8b66\u62a5\u5df2\u89e6\u53d1" }, @@ -18,6 +20,7 @@ "armed_away": "{entity_name} \u79bb\u5bb6\u8b66\u6212", "armed_home": "{entity_name} \u5728\u5bb6\u8b66\u6212", "armed_night": "{entity_name} \u591c\u95f4\u8b66\u6212", + "armed_vacation": "{entity_name} \u5ea6\u5047\u8b66\u6212", "disarmed": "{entity_name} \u8b66\u6212\u89e3\u9664", "triggered": "{entity_name} \u89e6\u53d1\u8b66\u62a5" } diff --git a/homeassistant/components/arcam_fmj/translations/zh-Hans.json b/homeassistant/components/arcam_fmj/translations/zh-Hans.json index 6e842e66fab..68057bbb8a1 100644 --- a/homeassistant/components/arcam_fmj/translations/zh-Hans.json +++ b/homeassistant/components/arcam_fmj/translations/zh-Hans.json @@ -3,5 +3,10 @@ "abort": { "cannot_connect": "\u8fde\u63a5\u5931\u8d25" } + }, + "device_automation": { + "trigger_type": { + "turn_on": "{entity_name} \u88ab\u8981\u6c42\u6253\u5f00" + } } } \ No newline at end of file diff --git a/homeassistant/components/button/translations/zh-Hans.json b/homeassistant/components/button/translations/zh-Hans.json index 88c70556aa1..12fddc42e13 100644 --- a/homeassistant/components/button/translations/zh-Hans.json +++ b/homeassistant/components/button/translations/zh-Hans.json @@ -1,7 +1,7 @@ { "device_automation": { "action_type": { - "press": "\u6309\u4e0b {entity_name} \u6309\u94ae" + "press": "\u6309\u4e0b {entity_name} \u7684\u6309\u94ae" }, "trigger_type": { "pressed": "{entity_name} \u88ab\u6309\u4e0b" diff --git a/homeassistant/components/fan/translations/zh-Hans.json b/homeassistant/components/fan/translations/zh-Hans.json index 13b6917f4ad..0bcc6440627 100644 --- a/homeassistant/components/fan/translations/zh-Hans.json +++ b/homeassistant/components/fan/translations/zh-Hans.json @@ -1,6 +1,7 @@ { "device_automation": { "action_type": { + "toggle": "\u5207\u6362 {entity_name} \u5f00\u5173", "turn_off": "\u5173\u95ed {entity_name}", "turn_on": "\u6253\u5f00 {entity_name}" }, @@ -9,6 +10,7 @@ "is_on": "{entity_name} \u5df2\u5f00\u542f" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" } diff --git a/homeassistant/components/group/translations/he.json b/homeassistant/components/group/translations/he.json index 354a435f491..a2507082dda 100644 --- a/homeassistant/components/group/translations/he.json +++ b/homeassistant/components/group/translations/he.json @@ -9,7 +9,7 @@ "name": "\u05e9\u05dd" }, "description": "\u05d0\u05dd \u05d4\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \"\u05db\u05dc \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea\" \u05d6\u05de\u05d9\u05e0\u05d4, \u05de\u05e6\u05d1 \u05d4\u05e7\u05d1\u05d5\u05e6\u05d4 \u05de\u05d5\u05e4\u05e2\u05dc \u05e8\u05e7 \u05d0\u05dd \u05db\u05dc \u05d4\u05d7\u05d1\u05e8\u05d9\u05dd \u05e4\u05d5\u05e2\u05dc\u05d9\u05dd. \u05d0\u05dd \"\u05db\u05dc \u05d4\u05d9\u05e9\u05d5\u05d9\u05d5\u05ea\" \u05d0\u05d9\u05e0\u05d5 \u05d6\u05de\u05d9\u05df, \u05de\u05e6\u05d1 \u05d4\u05e7\u05d1\u05d5\u05e6\u05d4 \u05de\u05d5\u05e4\u05e2\u05dc \u05d0\u05dd \u05d7\u05d1\u05e8 \u05db\u05dc\u05e9\u05d4\u05d5 \u05e4\u05d5\u05e2\u05dc.", - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "cover": { "data": { @@ -17,7 +17,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "fan": { "data": { @@ -25,7 +25,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "light": { "data": { @@ -33,7 +33,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "lock": { "data": { @@ -41,7 +41,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "media_player": { "data": { @@ -49,7 +49,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "switch": { "data": { @@ -57,7 +57,7 @@ "hide_members": "\u05d4\u05e1\u05ea\u05e8\u05ea \u05d7\u05d1\u05e8\u05d9\u05dd", "name": "\u05e9\u05dd" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" }, "user": { "description": "\u05e7\u05d1\u05d5\u05e6\u05d5\u05ea \u05de\u05d0\u05e4\u05e9\u05e8\u05d5\u05ea \u05dc\u05da \u05dc\u05d9\u05e6\u05d5\u05e8 \u05d9\u05e9\u05d5\u05ea \u05d7\u05d3\u05e9\u05d4 \u05d4\u05de\u05d9\u05d9\u05e6\u05d2\u05ea \u05d9\u05e9\u05d5\u05d9\u05d5\u05ea \u05de\u05e8\u05d5\u05d1\u05d5\u05ea \u05de\u05d0\u05d5\u05ea\u05d5 \u05e1\u05d5\u05d2.", @@ -68,7 +68,7 @@ "light": "\u05e7\u05d1\u05d5\u05e6\u05ea \u05ea\u05d0\u05d5\u05e8\u05d4", "media_player": "\u05e7\u05d1\u05d5\u05e6\u05ea \u05e0\u05d2\u05e0\u05d9 \u05de\u05d3\u05d9\u05d4" }, - "title": "\u05e7\u05d1\u05d5\u05e6\u05d4 \u05d7\u05d3\u05e9\u05d4" + "title": "\u05d4\u05d5\u05e1\u05e4\u05ea \u05e7\u05d1\u05d5\u05e6\u05d4" } } }, diff --git a/homeassistant/components/here_travel_time/translations/he.json b/homeassistant/components/here_travel_time/translations/he.json new file mode 100644 index 00000000000..dc5eb786f67 --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/he.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "api_key": "\u05de\u05e4\u05ea\u05d7 API" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/translations/zh-Hans.json b/homeassistant/components/hue/translations/zh-Hans.json index b883262a8d6..d7ec6ebaeb4 100644 --- a/homeassistant/components/hue/translations/zh-Hans.json +++ b/homeassistant/components/hue/translations/zh-Hans.json @@ -32,19 +32,20 @@ "2": "\u7b2c\u4e8c\u952e", "3": "\u7b2c\u4e09\u952e", "4": "\u7b2c\u56db\u952e", - "turn_off": "\u5173\u95ed" + "turn_off": "\u5173\u95ed", + "turn_on": "\u6253\u5f00" }, "trigger_type": { - "double_short_release": "\u201c{subtype}\u201d\u4e24\u952e\u540c\u65f6\u677e\u5f00", - "initial_press": "\u201c{subtype}\u201d\u9996\u6b21\u6309\u4e0b", - "long_release": "\u201c{subtype}\u201d\u957f\u6309\u540e\u677e\u5f00", + "double_short_release": "\"{subtype}\" \u4e24\u952e\u540c\u65f6\u677e\u5f00", + "initial_press": "\"{subtype}\" \u9996\u6b21\u6309\u4e0b", + "long_release": "\"{subtype}\" \u957f\u6309\u540e\u677e\u5f00", "remote_button_long_release": "\"{subtype}\" \u957f\u6309\u540e\u677e\u5f00", "remote_button_short_press": "\"{subtype}\" \u5355\u51fb", "remote_button_short_release": "\"{subtype}\" \u677e\u5f00", "remote_double_button_long_press": "\"{subtype}\" \u4e24\u952e\u540c\u65f6\u957f\u6309\u540e\u677e\u5f00", "remote_double_button_short_press": "\"{subtype}\" \u4e24\u952e\u540c\u65f6\u677e\u5f00", - "repeat": "\u201c{subtype}\u201d\u6309\u4f4f\u4e0d\u653e", - "short_release": "\u201c{subtype}\u201d\u77ed\u6309\u540e\u677e\u5f00" + "repeat": "\"{subtype}\" \u6309\u4f4f\u4e0d\u653e", + "short_release": "\"{subtype}\" \u77ed\u6309\u540e\u677e\u5f00" } }, "options": { diff --git a/homeassistant/components/humidifier/translations/zh-Hans.json b/homeassistant/components/humidifier/translations/zh-Hans.json index d21c7bf61f7..230afd3e4ab 100644 --- a/homeassistant/components/humidifier/translations/zh-Hans.json +++ b/homeassistant/components/humidifier/translations/zh-Hans.json @@ -13,6 +13,7 @@ "is_on": "{entity_name} \u5df2\u6253\u5f00" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "target_humidity_changed": "{entity_name} \u7684\u8bbe\u5b9a\u6e7f\u5ea6\u53d8\u5316", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" diff --git a/homeassistant/components/ialarm_xr/translations/he.json b/homeassistant/components/ialarm_xr/translations/he.json new file mode 100644 index 00000000000..b3fb785d55e --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/he.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "user": { + "data": { + "host": "\u05de\u05d0\u05e8\u05d7", + "password": "\u05e1\u05d9\u05e1\u05de\u05d4", + "port": "\u05e4\u05ea\u05d7\u05d4", + "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/integration/translations/he.json b/homeassistant/components/integration/translations/he.json index 219c75605bb..b4aa653fd20 100644 --- a/homeassistant/components/integration/translations/he.json +++ b/homeassistant/components/integration/translations/he.json @@ -8,13 +8,13 @@ "round": "\u05d3\u05d9\u05d5\u05e7", "source": "\u05d7\u05d9\u05d9\u05e9\u05df \u05e7\u05dc\u05d8", "unit_prefix": "\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05de\u05d8\u05e8\u05d9\u05ea", - "unit_time": "\u05d6\u05de\u05df \u05e9\u05d9\u05dc\u05d5\u05d1" + "unit_time": "\u05d9\u05d7\u05d9\u05d3\u05ea \u05d6\u05de\u05df" }, "data_description": { "round": "\u05e9\u05dc\u05d9\u05d8\u05d4 \u05d1\u05de\u05e1\u05e4\u05e8 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05d4\u05e2\u05e9\u05e8\u05d5\u05e0\u05d9\u05d5\u05ea \u05d1\u05e4\u05dc\u05d8." }, - "description": "\u05d3\u05d9\u05d5\u05e7 \u05e9\u05d5\u05dc\u05d8 \u05d1\u05de\u05e1\u05e4\u05e8 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05d4\u05e2\u05e9\u05e8\u05d5\u05e0\u05d9\u05d5\u05ea \u05d1\u05e4\u05dc\u05d8.\n\u05d4\u05e1\u05db\u05d5\u05dd \u05d9\u05e9\u05ea\u05e0\u05d4 \u05d1\u05d4\u05ea\u05d0\u05dd \u05dc\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05d4\u05de\u05d8\u05e8\u05d9\u05ea \u05e9\u05e0\u05d1\u05d7\u05e8\u05d4 \u05d5\u05d6\u05de\u05df \u05d4\u05e9\u05d9\u05dc\u05d5\u05d1.", - "title": "\u05d7\u05d9\u05d9\u05e9\u05df \u05e9\u05d9\u05dc\u05d5\u05d1 \u05d7\u05d3\u05e9" + "description": "\u05e6\u05d5\u05e8 \u05d7\u05d9\u05d9\u05e9\u05df \u05d4\u05de\u05d7\u05e9\u05d1 \u05e1\u05db\u05d5\u05dd Riemann \u05db\u05d3\u05d9 \u05dc\u05d4\u05e2\u05e8\u05d9\u05da \u05d0\u05ea \u05d4\u05d0\u05d9\u05e0\u05d8\u05d2\u05e8\u05dc \u05e9\u05dc \u05d7\u05d9\u05d9\u05e9\u05df.", + "title": "\u05d4\u05d5\u05e1\u05e3 \u05d7\u05d9\u05d9\u05e9\u05df \u05e1\u05db\u05d5\u05dd \u05d0\u05d9\u05e0\u05d8\u05d2\u05e8\u05dc\u05d9 \u05e9\u05dc Riemann" } } }, diff --git a/homeassistant/components/intellifire/translations/he.json b/homeassistant/components/intellifire/translations/he.json index 18cf71bf358..bb6f3c30c76 100644 --- a/homeassistant/components/intellifire/translations/he.json +++ b/homeassistant/components/intellifire/translations/he.json @@ -7,6 +7,7 @@ "error": { "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4" }, + "flow_title": "{serial} ({host})", "step": { "api_config": { "data": { @@ -16,7 +17,7 @@ }, "manual_device_entry": { "data": { - "host": "\u05de\u05d0\u05e8\u05d7" + "host": "\u05de\u05d0\u05e8\u05d7 (\u05db\u05ea\u05d5\u05d1\u05ea IP)" } }, "pick_device": { diff --git a/homeassistant/components/kodi/translations/zh-Hans.json b/homeassistant/components/kodi/translations/zh-Hans.json index 12915ccdb9b..b76fc3c861c 100644 --- a/homeassistant/components/kodi/translations/zh-Hans.json +++ b/homeassistant/components/kodi/translations/zh-Hans.json @@ -43,8 +43,8 @@ }, "device_automation": { "trigger_type": { - "turn_off": "[entity_name} \u88ab\u8981\u6c42\u5173\u95ed", - "turn_on": "[entity_name} \u88ab\u8981\u6c42\u6253\u5f00" + "turn_off": "{entity_name} \u88ab\u8981\u6c42\u5173\u95ed", + "turn_on": "{entity_name} \u88ab\u8981\u6c42\u6253\u5f00" } } } \ No newline at end of file diff --git a/homeassistant/components/laundrify/translations/he.json b/homeassistant/components/laundrify/translations/he.json new file mode 100644 index 00000000000..9c8c4c3b8c8 --- /dev/null +++ b/homeassistant/components/laundrify/translations/he.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." + }, + "error": { + "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", + "invalid_auth": "\u05d0\u05d9\u05de\u05d5\u05ea \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9", + "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "reauth_confirm": { + "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/translations/zh-Hans.json b/homeassistant/components/light/translations/zh-Hans.json index 1054820c6bb..93f16833984 100644 --- a/homeassistant/components/light/translations/zh-Hans.json +++ b/homeassistant/components/light/translations/zh-Hans.json @@ -13,6 +13,7 @@ "is_on": "{entity_name} \u5df2\u6253\u5f00" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" } diff --git a/homeassistant/components/litterrobot/translations/sensor.he.json b/homeassistant/components/litterrobot/translations/sensor.he.json index 0c693d10157..73f622d7a1a 100644 --- a/homeassistant/components/litterrobot/translations/sensor.he.json +++ b/homeassistant/components/litterrobot/translations/sensor.he.json @@ -2,7 +2,8 @@ "state": { "litterrobot__status_code": { "off": "\u05db\u05d1\u05d5\u05d9", - "p": "\u05de\u05d5\u05e9\u05d4\u05d4" + "p": "\u05de\u05d5\u05e9\u05d4\u05d4", + "rdy": "\u05de\u05d5\u05db\u05df" } } } \ No newline at end of file diff --git a/homeassistant/components/media_player/translations/zh-Hans.json b/homeassistant/components/media_player/translations/zh-Hans.json index 0fa034898c3..fe1ec28d8a1 100644 --- a/homeassistant/components/media_player/translations/zh-Hans.json +++ b/homeassistant/components/media_player/translations/zh-Hans.json @@ -1,6 +1,7 @@ { "device_automation": { "condition_type": { + "is_buffering": "{entity_name} \u6b63\u5728\u7f13\u51b2", "is_idle": "{entity_name} \u7a7a\u95f2", "is_off": "{entity_name} \u5df2\u5173\u95ed", "is_on": "{entity_name} \u5df2\u5f00\u542f", @@ -8,6 +9,8 @@ "is_playing": "{entity_name} \u6b63\u5728\u64ad\u653e" }, "trigger_type": { + "buffering": "{entity_name} \u5f00\u59cb\u7f13\u51b2", + "changed_states": "{entity_name} \u72b6\u6001\u53d8\u5316", "idle": "{entity_name} \u7a7a\u95f2", "paused": "{entity_name} \u6682\u505c", "playing": "{entity_name} \u5f00\u59cb\u64ad\u653e", diff --git a/homeassistant/components/remote/translations/zh-Hans.json b/homeassistant/components/remote/translations/zh-Hans.json index f6c509d4a08..4e9430f84e1 100644 --- a/homeassistant/components/remote/translations/zh-Hans.json +++ b/homeassistant/components/remote/translations/zh-Hans.json @@ -10,6 +10,7 @@ "is_on": "{entity_name} \u5df2\u6253\u5f00" }, "trigger_type": { + "changed_states": "{entity_name} \u88ab\u6253\u5f00\u6216\u5173\u95ed", "turned_off": "{entity_name} \u88ab\u5173\u95ed", "turned_on": "{entity_name} \u88ab\u6253\u5f00" } diff --git a/homeassistant/components/sensor/translations/zh-Hans.json b/homeassistant/components/sensor/translations/zh-Hans.json index 4fd2ec4db9d..910aa129864 100644 --- a/homeassistant/components/sensor/translations/zh-Hans.json +++ b/homeassistant/components/sensor/translations/zh-Hans.json @@ -3,17 +3,30 @@ "condition_type": { "is_apparent_power": "{entity_name} \u5f53\u524d\u7684\u89c6\u5728\u529f\u7387", "is_battery_level": "{entity_name} \u5f53\u524d\u7684\u7535\u6c60\u7535\u91cf", + "is_carbon_dioxide": "{entity_name} \u5f53\u524d\u7684\u4e8c\u6c27\u5316\u78b3\u6d53\u5ea6\u6c34\u5e73", + "is_carbon_monoxide": "{entity_name} \u5f53\u524d\u7684\u4e00\u6c27\u5316\u78b3\u6d53\u5ea6\u6c34\u5e73", "is_current": "{entity_name} \u5f53\u524d\u7684\u7535\u6d41", "is_energy": "{entity_name} \u5f53\u524d\u7528\u7535\u91cf", + "is_frequency": "{entity_name} \u5f53\u524d\u7684\u9891\u7387", + "is_gas": "{entity_name} \u5f53\u524d\u7684\u71c3\u6c14", "is_humidity": "{entity_name} \u5f53\u524d\u7684\u6e7f\u5ea6", "is_illuminance": "{entity_name} \u5f53\u524d\u7684\u5149\u7167\u5f3a\u5ea6", + "is_nitrogen_dioxide": "{entity_name} \u5f53\u524d\u7684\u4e8c\u6c27\u5316\u6c2e\u6d53\u5ea6\u6c34\u5e73", + "is_nitrogen_monoxide": "{entity_name} \u5f53\u524d\u7684\u4e00\u6c27\u5316\u6c2e\u6d53\u5ea6\u6c34\u5e73", + "is_nitrous_oxide": "{entity_name} \u5f53\u524d\u7684\u4e00\u6c27\u5316\u4e8c\u6c2e\u6d53\u5ea6\u6c34\u5e73", + "is_ozone": "{entity_name} \u5f53\u524d\u7684\u81ed\u6c27\u6d53\u5ea6\u6c34\u5e73", + "is_pm1": "{entity_name} \u5f53\u524d\u7684 PM1 \u6d53\u5ea6\u6c34\u5e73", + "is_pm10": "{entity_name} \u5f53\u524d\u7684 PM10 \u6d53\u5ea6\u6c34\u5e73", + "is_pm25": "{entity_name} \u5f53\u524d\u7684 PM2.5 \u6d53\u5ea6\u6c34\u5e73", "is_power": "{entity_name} \u5f53\u524d\u7684\u529f\u7387", "is_power_factor": "{entity_name} \u5f53\u524d\u7684\u529f\u7387\u56e0\u6570", "is_pressure": "{entity_name} \u5f53\u524d\u7684\u538b\u529b", "is_reactive_power": "{entity_name} \u5f53\u524d\u7684\u65e0\u529f\u529f\u7387", "is_signal_strength": "{entity_name} \u5f53\u524d\u7684\u4fe1\u53f7\u5f3a\u5ea6", + "is_sulphur_dioxide": "{entity_name} \u5f53\u524d\u7684\u4e8c\u6c27\u5316\u786b\u6d53\u5ea6\u6c34\u5e73", "is_temperature": "{entity_name} \u5f53\u524d\u7684\u6e29\u5ea6", "is_value": "{entity_name} \u5f53\u524d\u7684\u503c", + "is_volatile_organic_compounds": "{entity_name} \u5f53\u524d\u7684\u6325\u53d1\u6027\u6709\u673a\u7269\u6d53\u5ea6\u6c34\u5e73", "is_voltage": "{entity_name} \u5f53\u524d\u7684\u7535\u538b" }, "trigger_type": { diff --git a/homeassistant/components/simplisafe/translations/he.json b/homeassistant/components/simplisafe/translations/he.json index dda9553f48d..85a9e002b77 100644 --- a/homeassistant/components/simplisafe/translations/he.json +++ b/homeassistant/components/simplisafe/translations/he.json @@ -15,6 +15,11 @@ "description": "\u05ea\u05d5\u05e7\u05e3 \u05d0\u05e1\u05d9\u05de\u05d5\u05df \u05d4\u05d2\u05d9\u05e9\u05d4 \u05e9\u05dc\u05da \u05e4\u05d2 \u05d0\u05d5 \u05d1\u05d5\u05d8\u05dc. \u05d9\u05e9 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05d4\u05e1\u05d9\u05e1\u05de\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da.", "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05de\u05d7\u05d3\u05e9 \u05e9\u05dc \u05e9\u05d9\u05dc\u05d5\u05d1" }, + "sms_2fa": { + "data": { + "code": "\u05e7\u05d5\u05d3" + } + }, "user": { "data": { "password": "\u05e1\u05d9\u05e1\u05de\u05d4", diff --git a/homeassistant/components/tankerkoenig/translations/de.json b/homeassistant/components/tankerkoenig/translations/de.json index 3c2a5f1ec72..421521a30da 100644 --- a/homeassistant/components/tankerkoenig/translations/de.json +++ b/homeassistant/components/tankerkoenig/translations/de.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Update-Intervall", - "show_on_map": "Stationen auf der Karte anzeigen" + "show_on_map": "Stationen auf der Karte anzeigen", + "stations": "Stationen" }, "title": "Tankerkoenig Optionen" } diff --git a/homeassistant/components/tankerkoenig/translations/en.json b/homeassistant/components/tankerkoenig/translations/en.json index 83cc36fd4c8..432ad4481c8 100644 --- a/homeassistant/components/tankerkoenig/translations/en.json +++ b/homeassistant/components/tankerkoenig/translations/en.json @@ -31,6 +31,7 @@ "step": { "init": { "data": { + "scan_interval": "Update Interval", "show_on_map": "Show stations on map", "stations": "Stations" }, diff --git a/homeassistant/components/tankerkoenig/translations/pt-BR.json b/homeassistant/components/tankerkoenig/translations/pt-BR.json index b24e0b1dca8..699b98812d4 100644 --- a/homeassistant/components/tankerkoenig/translations/pt-BR.json +++ b/homeassistant/components/tankerkoenig/translations/pt-BR.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Intervalo de atualiza\u00e7\u00e3o", - "show_on_map": "Mostrar postos no mapa" + "show_on_map": "Mostrar postos no mapa", + "stations": "Esta\u00e7\u00f5es" }, "title": "Op\u00e7\u00f5es de Tankerkoenig" } diff --git a/homeassistant/components/xiaomi_aqara/translations/he.json b/homeassistant/components/xiaomi_aqara/translations/he.json index 8d123517560..304d45f4cda 100644 --- a/homeassistant/components/xiaomi_aqara/translations/he.json +++ b/homeassistant/components/xiaomi_aqara/translations/he.json @@ -18,7 +18,7 @@ "data": { "select_ip": "\u05db\u05ea\u05d5\u05d1\u05ea IP" }, - "description": "\u05d9\u05e9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4 \u05e9\u05d5\u05d1 \u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8 \u05e9\u05e2\u05e8\u05d9\u05dd \u05e0\u05d5\u05e1\u05e4\u05d9\u05dd" + "description": "\u05d1\u05d7\u05d9\u05e8\u05ea \u05e9\u05e2\u05e8 \u05e9\u05d9\u05d0\u05d5\u05de\u05d9 \u05d0\u05e7\u05d0\u05e8\u05d4 \u05e9\u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d7\u05d1\u05e8" }, "settings": { "data": { diff --git a/homeassistant/components/zha/translations/zh-Hans.json b/homeassistant/components/zha/translations/zh-Hans.json index 1dd51cd7e62..1d40d80d8a2 100644 --- a/homeassistant/components/zha/translations/zh-Hans.json +++ b/homeassistant/components/zha/translations/zh-Hans.json @@ -48,6 +48,13 @@ "close": "\u5173\u95ed", "dim_down": "\u8c03\u6697", "dim_up": "\u8c03\u4eae", + "face_1": "\u5e76\u4e14\u7b2c 1 \u9762\u6fc0\u6d3b", + "face_2": "\u5e76\u4e14\u7b2c 2 \u9762\u6fc0\u6d3b", + "face_3": "\u5e76\u4e14\u7b2c 3 \u9762\u6fc0\u6d3b", + "face_4": "\u5e76\u4e14\u7b2c 4 \u9762\u6fc0\u6d3b", + "face_5": "\u5e76\u4e14\u7b2c 5 \u9762\u6fc0\u6d3b", + "face_6": "\u5e76\u4e14\u7b2c 6 \u9762\u6fc0\u6d3b", + "face_any": "\u5e76\u4e14\u4efb\u610f\u6216\u6307\u5b9a\u9762\u6fc0\u6d3b", "left": "\u5de6", "open": "\u5f00\u542f", "right": "\u53f3", @@ -56,12 +63,12 @@ }, "trigger_type": { "device_dropped": "\u8bbe\u5907\u81ea\u7531\u843d\u4f53", - "device_flipped": "\u8bbe\u5907\u7ffb\u8f6c \"{subtype}\"", - "device_knocked": "\u8bbe\u5907\u8f7b\u6572 \"{subtype}\"", + "device_flipped": "\u8bbe\u5907\u7ffb\u8f6c{subtype}", + "device_knocked": "\u8bbe\u5907\u8f7b\u6572{subtype}", "device_offline": "\u8bbe\u5907\u79bb\u7ebf", - "device_rotated": "\u8bbe\u5907\u65cb\u8f6c \"{subtype}\"", + "device_rotated": "\u8bbe\u5907\u65cb\u8f6c{subtype}", "device_shaken": "\u8bbe\u5907\u6447\u4e00\u6447", - "device_slid": "\u8bbe\u5907\u5e73\u79fb \"{subtype}\"", + "device_slid": "\u8bbe\u5907\u5e73\u79fb{subtype}", "device_tilted": "\u8bbe\u5907\u503e\u659c", "remote_button_alt_double_press": "\"{subtype}\" \u53cc\u51fb(\u5907\u7528)", "remote_button_alt_long_press": "\"{subtype}\" \u957f\u6309(\u5907\u7528)", diff --git a/homeassistant/components/zwave_js/translations/zh-Hans.json b/homeassistant/components/zwave_js/translations/zh-Hans.json new file mode 100644 index 00000000000..815453d9b62 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "device_automation": { + "action_type": { + "ping": "Ping \u8bbe\u5907" + } + } +} \ No newline at end of file From 46031aff8d60b5f4836e3ee51b7250eea415414a Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 28 May 2022 21:03:13 -0500 Subject: [PATCH 059/947] Avoid swallowing Roku errors (#72517) --- homeassistant/components/roku/helpers.py | 17 +++---- tests/components/roku/test_select.py | 63 +++++++++++++++--------- 2 files changed, 47 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/roku/helpers.py b/homeassistant/components/roku/helpers.py index f5a68f44ab8..6b3c02a5fab 100644 --- a/homeassistant/components/roku/helpers.py +++ b/homeassistant/components/roku/helpers.py @@ -3,15 +3,14 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine from functools import wraps -import logging from typing import Any, TypeVar from rokuecp import RokuConnectionError, RokuConnectionTimeoutError, RokuError from typing_extensions import Concatenate, ParamSpec -from .entity import RokuEntity +from homeassistant.exceptions import HomeAssistantError -_LOGGER = logging.getLogger(__name__) +from .entity import RokuEntity _RokuEntityT = TypeVar("_RokuEntityT", bound=RokuEntity) _P = ParamSpec("_P") @@ -43,14 +42,14 @@ def roku_exception_handler( try: await func(self, *args, **kwargs) except RokuConnectionTimeoutError as error: - if not ignore_timeout and self.available: - _LOGGER.error("Error communicating with API: %s", error) + if not ignore_timeout: + raise HomeAssistantError( + "Timeout communicating with Roku API" + ) from error except RokuConnectionError as error: - if self.available: - _LOGGER.error("Error communicating with API: %s", error) + raise HomeAssistantError("Error communicating with Roku API") from error except RokuError as error: - if self.available: - _LOGGER.error("Invalid response from API: %s", error) + raise HomeAssistantError("Invalid response from Roku API") from error return wrapper diff --git a/tests/components/roku/test_select.py b/tests/components/roku/test_select.py index e82a13c8511..003487c0adf 100644 --- a/tests/components/roku/test_select.py +++ b/tests/components/roku/test_select.py @@ -2,7 +2,13 @@ from unittest.mock import MagicMock import pytest -from rokuecp import Application, Device as RokuDevice, RokuError +from rokuecp import ( + Application, + Device as RokuDevice, + RokuConnectionError, + RokuConnectionTimeoutError, + RokuError, +) from homeassistant.components.roku.const import DOMAIN from homeassistant.components.roku.coordinator import SCAN_INTERVAL @@ -10,6 +16,7 @@ from homeassistant.components.select import DOMAIN as SELECT_DOMAIN from homeassistant.components.select.const import ATTR_OPTION, ATTR_OPTIONS from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, SERVICE_SELECT_OPTION from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -102,11 +109,20 @@ async def test_application_state( assert state.state == "Home" +@pytest.mark.parametrize( + "error, error_string", + [ + (RokuConnectionError, "Error communicating with Roku API"), + (RokuConnectionTimeoutError, "Timeout communicating with Roku API"), + (RokuError, "Invalid response from Roku API"), + ], +) async def test_application_select_error( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_roku: MagicMock, - caplog: pytest.LogCaptureFixture, + error: RokuError, + error_string: str, ) -> None: """Test error handling of the Roku selects.""" entity_registry = er.async_get(hass) @@ -123,22 +139,22 @@ async def test_application_select_error( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - mock_roku.launch.side_effect = RokuError + mock_roku.launch.side_effect = error - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.my_roku_3_application", - ATTR_OPTION: "Netflix", - }, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match=error_string): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.my_roku_3_application", + ATTR_OPTION: "Netflix", + }, + blocking=True, + ) state = hass.states.get("select.my_roku_3_application") assert state assert state.state == "Home" - assert "Invalid response from API" in caplog.text assert mock_roku.launch.call_count == 1 mock_roku.launch.assert_called_with("12") @@ -218,24 +234,23 @@ async def test_channel_select_error( hass: HomeAssistant, init_integration: MockConfigEntry, mock_roku: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test error handling of the Roku selects.""" mock_roku.tune.side_effect = RokuError - await hass.services.async_call( - SELECT_DOMAIN, - SERVICE_SELECT_OPTION, - { - ATTR_ENTITY_ID: "select.58_onn_roku_tv_channel", - ATTR_OPTION: "99.1", - }, - blocking=True, - ) + with pytest.raises(HomeAssistantError, match="Invalid response from Roku API"): + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: "select.58_onn_roku_tv_channel", + ATTR_OPTION: "99.1", + }, + blocking=True, + ) state = hass.states.get("select.58_onn_roku_tv_channel") assert state assert state.state == "getTV (14.3)" - assert "Invalid response from API" in caplog.text assert mock_roku.tune.call_count == 1 mock_roku.tune.assert_called_with("99.1") From 7d391846ffaa3e52886068b9b4645807e46a882b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 28 May 2022 16:38:38 -1000 Subject: [PATCH 060/947] Retry right away on discovery for WiZ (#72659) --- homeassistant/components/wiz/config_flow.py | 14 +++++++-- tests/components/wiz/test_config_flow.py | 33 ++++++++++++++++++++- tests/components/wiz/test_init.py | 21 ++++++++++--- 3 files changed, 60 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 04a0884059f..6b5f5be027f 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -9,8 +9,8 @@ from pywizlight.discovery import DiscoveredBulb from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import dhcp +from homeassistant.config_entries import ConfigEntryState, ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.util.network import is_ip_address @@ -24,7 +24,7 @@ _LOGGER = logging.getLogger(__name__) CONF_DEVICE = "device" -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +class WizConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for WiZ.""" VERSION = 1 @@ -58,7 +58,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug("Discovered device: %s", device) ip_address = device.ip_address mac = device.mac_address - await self.async_set_unique_id(mac) + if current_entry := await self.async_set_unique_id(mac): + if ( + current_entry.state is ConfigEntryState.SETUP_RETRY + and current_entry.data[CONF_HOST] == ip_address + ): + self.hass.async_create_task( + self.hass.config_entries.async_reload(current_entry.entry_id) + ) + return self.async_abort(reason="already_configured") self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address}) await self._async_connect_discovered_or_abort() return await self.async_step_discovery_confirm() diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index f8426ece56d..58b46bbea9d 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -1,5 +1,5 @@ """Test the WiZ Platform config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import pytest from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError @@ -21,8 +21,10 @@ from . import ( FAKE_SOCKET, TEST_CONNECTION, TEST_SYSTEM_INFO, + _mocked_wizlight, _patch_discovery, _patch_wizlight, + async_setup_integration, ) from tests.common import MockConfigEntry @@ -309,6 +311,35 @@ async def test_discovered_by_dhcp_or_integration_discovery_updates_host( assert entry.data[CONF_HOST] == FAKE_IP +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY), + ], +) +async def test_discovered_by_dhcp_or_integration_discovery_avoid_waiting_for_retry( + hass, source, data +): + """Test dhcp or discovery kicks off setup when in retry.""" + bulb = _mocked_wizlight(None, None, FAKE_SOCKET) + bulb.getMac = AsyncMock(side_effect=OSError) + _, entry = await async_setup_integration(hass, wizlight=bulb) + assert entry.data[CONF_HOST] == FAKE_IP + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + bulb.getMac = AsyncMock(return_value=FAKE_MAC) + + with _patch_wizlight(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.state is config_entries.ConfigEntryState.LOADED + + async def test_setup_via_discovery(hass): """Test setting up via discovery.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/wiz/test_init.py b/tests/components/wiz/test_init.py index 30340f78e49..96ae5e20ba3 100644 --- a/tests/components/wiz/test_init.py +++ b/tests/components/wiz/test_init.py @@ -1,13 +1,16 @@ """Tests for wiz integration.""" import datetime -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from homeassistant import config_entries -from homeassistant.const import ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STOP +from homeassistant.components.wiz.const import DOMAIN +from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from . import ( + FAKE_IP, FAKE_MAC, FAKE_SOCKET, _mocked_wizlight, @@ -16,7 +19,7 @@ from . import ( async_setup_integration, ) -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed async def test_setup_retry(hass: HomeAssistant) -> None: @@ -47,7 +50,17 @@ async def test_cleanup_on_failed_first_update(hass: HomeAssistant) -> None: """Test the socket is cleaned up on failed first update.""" bulb = _mocked_wizlight(None, None, FAKE_SOCKET) bulb.updateState = AsyncMock(side_effect=OSError) - _, entry = await async_setup_integration(hass, wizlight=bulb) + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FAKE_MAC, + data={CONF_HOST: FAKE_IP}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.wiz.discovery.find_wizlights", return_value=[] + ), _patch_wizlight(device=bulb): + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY bulb.async_close.assert_called_once() From 6a3d2e54a2df71ea9588f0379aeddadc3763f6d7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 28 May 2022 20:23:16 -0700 Subject: [PATCH 061/947] Handle OAuth2 rejection (#72040) --- .../helpers/config_entry_oauth2_flow.py | 30 +++++++--- homeassistant/strings.json | 1 + script/scaffold/generate.py | 1 + .../helpers/test_config_entry_oauth2_flow.py | 55 +++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index d369b872eb9..365ced24929 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -271,9 +271,10 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): ) -> FlowResult: """Create an entry for auth.""" # Flow has been triggered by external data - if user_input: + if user_input is not None: self.external_data = user_input - return self.async_external_step_done(next_step_id="creation") + next_step = "authorize_rejected" if "error" in user_input else "creation" + return self.async_external_step_done(next_step_id=next_step) try: async with async_timeout.timeout(10): @@ -311,6 +312,13 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): {"auth_implementation": self.flow_impl.domain, "token": token} ) + async def async_step_authorize_rejected(self, data: None = None) -> FlowResult: + """Step to handle flow rejection.""" + return self.async_abort( + reason="user_rejected_authorize", + description_placeholders={"error": self.external_data["error"]}, + ) + async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow. @@ -400,10 +408,8 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): async def get(self, request: web.Request) -> web.Response: """Receive authorization code.""" - if "code" not in request.query or "state" not in request.query: - return web.Response( - text=f"Missing code or state parameter in {request.url}" - ) + if "state" not in request.query: + return web.Response(text="Missing state parameter") hass = request.app["hass"] @@ -412,9 +418,17 @@ class OAuth2AuthorizeCallbackView(http.HomeAssistantView): if state is None: return web.Response(text="Invalid state") + user_input: dict[str, Any] = {"state": state} + + if "code" in request.query: + user_input["code"] = request.query["code"] + elif "error" in request.query: + user_input["error"] = request.query["error"] + else: + return web.Response(text="Missing code or error parameter") + await hass.config_entries.flow.async_configure( - flow_id=state["flow_id"], - user_input={"state": state, "code": request.query["code"]}, + flow_id=state["flow_id"], user_input=user_input ) return web.Response( diff --git a/homeassistant/strings.json b/homeassistant/strings.json index e4d363c22be..9ae30becaee 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -74,6 +74,7 @@ "oauth2_missing_credentials": "The integration requires application credentials.", "oauth2_authorize_url_timeout": "Timeout generating authorize URL.", "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", + "oauth2_user_rejected_authorize": "Account linking rejected: {error}", "reauth_successful": "Re-authentication was successful", "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py index 7f418868463..b7e4c58d1a1 100644 --- a/script/scaffold/generate.py +++ b/script/scaffold/generate.py @@ -188,6 +188,7 @@ def _custom_tasks(template, info: Info) -> None: "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py index 248f3b8dbb0..e5d220c55df 100644 --- a/tests/helpers/test_config_entry_oauth2_flow.py +++ b/tests/helpers/test_config_entry_oauth2_flow.py @@ -223,6 +223,61 @@ async def test_abort_if_oauth_error( assert result["reason"] == "oauth_error" +async def test_abort_if_oauth_rejected( + hass, + flow_handler, + local_impl, + hass_client_no_auth, + aioclient_mock, + current_request_with_host, +): + """Check bad oauth token.""" + flow_handler.async_register_implementation(hass, local_impl) + config_entry_oauth2_flow.async_register_implementation( + hass, TEST_DOMAIN, MockOAuth2Implementation() + ) + + result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pick_implementation" + + # Pick implementation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"implementation": TEST_DOMAIN} + ) + + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP + assert result["url"] == ( + f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}&scope=read+write" + ) + + client = await hass_client_no_auth() + resp = await client.get( + f"/auth/external/callback?error=access_denied&state={state}" + ) + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "user_rejected_authorize" + assert result["description_placeholders"] == {"error": "access_denied"} + + async def test_step_discovery(hass, flow_handler, local_impl): """Check flow triggers from discovery.""" flow_handler.async_register_implementation(hass, local_impl) From e7e48cd9f68f8523fe1630ff48078a820b5fbbf6 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 28 May 2022 20:28:22 -0700 Subject: [PATCH 062/947] Defer google calendar integration reload to a task to avoid races of reload during setup (#72608) --- homeassistant/components/google/__init__.py | 29 ++++------ homeassistant/components/google/api.py | 12 +++- tests/components/google/test_config_flow.py | 31 +++++----- tests/components/google/test_init.py | 64 +++++++++++++++++++++ 4 files changed, 101 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 1336e9991e3..2a40bfe7043 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -217,7 +217,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google from a config entry.""" hass.data.setdefault(DOMAIN, {}) - async_upgrade_entry(hass, entry) implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -240,10 +239,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - access = FeatureAccess[entry.options[CONF_CALENDAR_ACCESS]] - token_scopes = session.token.get("scope", []) - if access.scope not in token_scopes: - _LOGGER.debug("Scope '%s' not in scopes '%s'", access.scope, token_scopes) + if not async_entry_has_scopes(hass, entry): raise ConfigEntryAuthFailed( "Required scopes are not available, reauth required" ) @@ -254,27 +250,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_setup_services(hass, calendar_service) # Only expose the add event service if we have the correct permissions - if access is FeatureAccess.read_write: + if get_feature_access(hass, entry) is FeatureAccess.read_write: await async_setup_add_event_service(hass, calendar_service) hass.config_entries.async_setup_platforms(entry, PLATFORMS) - # Reload entry when options are updated entry.async_on_unload(entry.add_update_listener(async_reload_entry)) return True -def async_upgrade_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Upgrade the config entry if needed.""" - if entry.options: - return - hass.config_entries.async_update_entry( - entry, - options={ - CONF_CALENDAR_ACCESS: get_feature_access(hass).name, - }, - ) +def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Verify that the config entry desired scope is present in the oauth token.""" + access = get_feature_access(hass, entry) + token_scopes = entry.data.get("token", {}).get("scope", []) + return access.scope in token_scopes async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -283,8 +273,9 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Reload the config entry when it changed.""" - await hass.config_entries.async_reload(entry.entry_id) + """Reload config entry if the access options change.""" + if not async_entry_has_scopes(hass, entry): + await hass.config_entries.async_reload(entry.entry_id) async def async_setup_services( diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index eeac854a2ae..4bb9de5d581 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -19,6 +19,7 @@ from oauth2client.client import ( ) from homeassistant.components.application_credentials import AuthImplementation +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.event import async_track_time_interval @@ -127,8 +128,17 @@ class DeviceFlow: ) -def get_feature_access(hass: HomeAssistant) -> FeatureAccess: +def get_feature_access( + hass: HomeAssistant, config_entry: ConfigEntry | None = None +) -> FeatureAccess: """Return the desired calendar feature access.""" + if ( + config_entry + and config_entry.options + and CONF_CALENDAR_ACCESS in config_entry.options + ): + return FeatureAccess[config_entry.options[CONF_CALENDAR_ACCESS]] + # This may be called during config entry setup without integration setup running when there # is no google entry in configuration.yaml return cast( diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index 77ebe1e56cd..8ac017fcba4 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -540,9 +540,15 @@ async def test_options_flow_triggers_reauth( ) -> None: """Test load and unload of a ConfigEntry.""" config_entry.add_to_hass(hass) - await component_setup() + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await component_setup() + mock_setup.assert_called_once() + assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.options == {"calendar_access": "read_write"} + assert config_entry.options == {} # Default is read_write result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == "form" @@ -557,14 +563,7 @@ async def test_options_flow_triggers_reauth( }, ) assert result["type"] == "create_entry" - - await hass.async_block_till_done() assert config_entry.options == {"calendar_access": "read_only"} - # Re-auth flow was initiated because access level changed - assert config_entry.state is ConfigEntryState.SETUP_ERROR - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" async def test_options_flow_no_changes( @@ -574,9 +573,15 @@ async def test_options_flow_no_changes( ) -> None: """Test load and unload of a ConfigEntry.""" config_entry.add_to_hass(hass) - await component_setup() + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await component_setup() + mock_setup.assert_called_once() + assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.options == {"calendar_access": "read_write"} + assert config_entry.options == {} # Default is read_write result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == "form" @@ -589,8 +594,4 @@ async def test_options_flow_no_changes( }, ) assert result["type"] == "create_entry" - - await hass.async_block_till_done() assert config_entry.options == {"calendar_access": "read_write"} - # Re-auth flow was initiated because access level changed - assert config_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index b6f7a6b4cbc..f2cf067f7bb 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -102,6 +102,24 @@ async def test_existing_token_missing_scope( assert flows[0]["step_id"] == "reauth_confirm" +@pytest.mark.parametrize("config_entry_options", [{CONF_CALENDAR_ACCESS: "read_only"}]) +async def test_config_entry_scope_reauth( + hass: HomeAssistant, + token_scopes: list[str], + component_setup: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test setup where the config entry options requires reauth to match the scope.""" + config_entry.add_to_hass(hass) + assert await component_setup() + + assert config_entry.state is ConfigEntryState.SETUP_ERROR + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["step_id"] == "reauth_confirm" + + @pytest.mark.parametrize("calendars_config", [[{"cal_id": "invalid-schema"}]]) async def test_calendar_yaml_missing_required_fields( hass: HomeAssistant, @@ -629,3 +647,49 @@ async def test_calendar_yaml_update( # No yaml config loaded that overwrites the entity name assert not hass.states.get(TEST_YAML_ENTITY) + + +async def test_update_will_reload( + hass: HomeAssistant, + component_setup: ComponentSetup, + setup_config_entry: Any, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + config_entry: MockConfigEntry, +) -> None: + """Test updating config entry options will trigger a reload.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.options == {} # read_write is default + + with patch( + "homeassistant.config_entries.ConfigEntries.async_reload", + return_value=None, + ) as mock_reload: + # No-op does not reload + hass.config_entries.async_update_entry( + config_entry, options={CONF_CALENDAR_ACCESS: "read_write"} + ) + await hass.async_block_till_done() + mock_reload.assert_not_called() + + # Data change does not trigger reload + hass.config_entries.async_update_entry( + config_entry, + data={ + **config_entry.data, + "example": "field", + }, + ) + await hass.async_block_till_done() + mock_reload.assert_not_called() + + # Reload when options changed + hass.config_entries.async_update_entry( + config_entry, options={CONF_CALENDAR_ACCESS: "read_only"} + ) + await hass.async_block_till_done() + mock_reload.assert_called_once() From d59ecc4c963ffbeb80c626de6c600170ed85e165 Mon Sep 17 00:00:00 2001 From: Khole Date: Sun, 29 May 2022 11:08:50 +0100 Subject: [PATCH 063/947] Refactor hive entity (#72311) * Add hive category entity changes * Updates based on PR feedback * Revert libary bump * Update after PR feedback * Update Binary device class for smoke sensor Co-authored-by: Martin Hjelmare * Remove entity category Co-authored-by: Martin Hjelmare * Updates after PR review * Remove unused import * Update light based on PR feedback * Update light code from PR review Co-authored-by: Martin Hjelmare --- homeassistant/components/hive/__init__.py | 13 +- .../components/hive/alarm_control_panel.py | 41 +------ .../components/hive/binary_sensor.py | 99 +++++++--------- homeassistant/components/hive/climate.py | 99 +++------------- homeassistant/components/hive/light.py | 111 ++++-------------- homeassistant/components/hive/sensor.py | 72 ++++-------- homeassistant/components/hive/switch.py | 69 ++++------- homeassistant/components/hive/water_heater.py | 50 ++------ 8 files changed, 152 insertions(+), 402 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 00c3a327578..af32d39ae8f 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -22,7 +22,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORM_LOOKUP, PLATFORMS @@ -132,8 +132,17 @@ class HiveEntity(Entity): """Initialize the instance.""" self.hive = hive self.device = hive_device + self._attr_name = self.device["haName"] + self._attr_unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.device["device_id"])}, + model=self.device["deviceData"]["model"], + manufacturer=self.device["deviceData"]["manufacturer"], + name=self.device["device_name"], + sw_version=self.device["deviceData"]["version"], + via_device=(DOMAIN, self.device["parentDevice"]), + ) self.attributes = {} - self._unique_id = f'{self.device["hiveID"]}-{self.device["hiveType"]}' async def async_added_to_hass(self): """When entity is added to Home Assistant.""" diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index a3509fce66f..f8f35e20ffa 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -13,7 +13,6 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity @@ -50,40 +49,6 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this AdGuard Home instance.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - model=self.device["deviceData"]["model"], - manufacturer=self.device["deviceData"]["manufacturer"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the name of the alarm.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def state(self): - """Return state of alarm.""" - if self.device["status"]["state"]: - return STATE_ALARM_TRIGGERED - return HIVETOHA[self.device["status"]["mode"]] - async def async_alarm_disarm(self, code=None): """Send disarm command.""" await self.hive.alarm.setMode(self.device, "home") @@ -100,3 +65,9 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.alarm.getAlarm(self.device) + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + if self.device["status"]["state"]: + self._attr_state = STATE_ALARM_TRIGGERED + else: + self._attr_state = HIVETOHA[self.device["status"]["mode"]] diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py index 934974d3c1e..313c78275e7 100644 --- a/homeassistant/components/hive/binary_sensor.py +++ b/homeassistant/components/hive/binary_sensor.py @@ -4,27 +4,46 @@ from datetime import timedelta from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity -from .const import ATTR_MODE, DOMAIN +from .const import DOMAIN -DEVICETYPE = { - "contactsensor": BinarySensorDeviceClass.OPENING, - "motionsensor": BinarySensorDeviceClass.MOTION, - "Connectivity": BinarySensorDeviceClass.CONNECTIVITY, - "SMOKE_CO": BinarySensorDeviceClass.SMOKE, - "DOG_BARK": BinarySensorDeviceClass.SOUND, - "GLASS_BREAK": BinarySensorDeviceClass.SOUND, -} PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="contactsensor", device_class=BinarySensorDeviceClass.OPENING + ), + BinarySensorEntityDescription( + key="motionsensor", + device_class=BinarySensorDeviceClass.MOTION, + ), + BinarySensorEntityDescription( + key="Connectivity", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + BinarySensorEntityDescription( + key="SMOKE_CO", + device_class=BinarySensorDeviceClass.SMOKE, + ), + BinarySensorEntityDescription( + key="DOG_BARK", + device_class=BinarySensorDeviceClass.SOUND, + ), + BinarySensorEntityDescription( + key="GLASS_BREAK", + device_class=BinarySensorDeviceClass.SOUND, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -34,62 +53,28 @@ async def async_setup_entry( devices = hive.session.deviceList.get("binary_sensor") entities = [] if devices: - for dev in devices: - entities.append(HiveBinarySensorEntity(hive, dev)) + for description in BINARY_SENSOR_TYPES: + for dev in devices: + if dev["hiveType"] == description.key: + entities.append(HiveBinarySensorEntity(hive, dev, description)) async_add_entities(entities, True) class HiveBinarySensorEntity(HiveEntity, BinarySensorEntity): """Representation of a Hive binary sensor.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def device_class(self): - """Return the class of this sensor.""" - return DEVICETYPE.get(self.device["hiveType"]) - - @property - def name(self): - """Return the name of the binary sensor.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - if self.device["hiveType"] != "Connectivity": - return self.device["deviceData"]["online"] - return True - - @property - def extra_state_attributes(self): - """Show Device Attributes.""" - return { - ATTR_MODE: self.attributes.get(ATTR_MODE), - } - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self.device["status"]["state"] + def __init__(self, hive, hive_device, entity_description): + """Initialise hive binary sensor.""" + super().__init__(hive, hive_device) + self.entity_description = entity_description async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) self.attributes = self.device.get("attributes", {}) + self._attr_is_on = self.device["status"]["state"] + if self.device["hiveType"] != "Connectivity": + self._attr_available = self.device["deviceData"].get("online") + else: + self._attr_available = True diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py index d094ca9eace..d6dfcfa6b2c 100644 --- a/homeassistant/components/hive/climate.py +++ b/homeassistant/components/hive/climate.py @@ -16,7 +16,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity, refresh_system @@ -46,8 +45,6 @@ HIVE_TO_HASS_HVAC_ACTION = { } TEMP_UNIT = {"C": TEMP_CELSIUS, "F": TEMP_FAHRENHEIT} - -SUPPORT_PRESET = [PRESET_NONE, PRESET_BOOST] PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) _LOGGER = logging.getLogger() @@ -105,6 +102,7 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Hive Climate Device.""" _attr_hvac_modes = [HVACMode.AUTO, HVACMode.HEAT, HVACMode.OFF] + _attr_preset_modes = [PRESET_BOOST, PRESET_NONE] _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) @@ -113,84 +111,7 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Initialize the Climate device.""" super().__init__(hive_session, hive_device) self.thermostat_node_id = hive_device["device_id"] - self.temperature_type = TEMP_UNIT.get(hive_device["temperatureunit"]) - - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the name of the Climate device.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ - return HIVE_TO_HASS_STATE[self.device["status"]["mode"]] - - @property - def hvac_action(self) -> HVACAction: - """Return current HVAC action.""" - return HIVE_TO_HASS_HVAC_ACTION[self.device["status"]["action"]] - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self.temperature_type - - @property - def current_temperature(self): - """Return the current temperature.""" - return self.device["status"]["current_temperature"] - - @property - def target_temperature(self): - """Return the target temperature.""" - return self.device["status"]["target_temperature"] - - @property - def min_temp(self): - """Return minimum temperature.""" - return self.device["min_temp"] - - @property - def max_temp(self): - """Return the maximum temperature.""" - return self.device["max_temp"] - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - if self.device["status"]["boost"] == "ON": - return PRESET_BOOST - return PRESET_NONE - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return SUPPORT_PRESET + self._attr_temperature_unit = TEMP_UNIT.get(hive_device["temperatureunit"]) @refresh_system async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: @@ -236,3 +157,19 @@ class HiveClimateEntity(HiveEntity, ClimateEntity): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.heating.getClimate(self.device) + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_hvac_mode = HIVE_TO_HASS_STATE[self.device["status"]["mode"]] + self._attr_hvac_action = HIVE_TO_HASS_HVAC_ACTION[ + self.device["status"]["action"] + ] + self._attr_current_temperature = self.device["status"][ + "current_temperature" + ] + self._attr_target_temperature = self.device["status"]["target_temperature"] + self._attr_min_temp = self.device["min_temp"] + self._attr_max_temp = self.device["max_temp"] + if self.device["status"]["boost"] == "ON": + self._attr_preset_mode = PRESET_BOOST + else: + self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py index ba095896b64..c06237f3709 100644 --- a/homeassistant/components/hive/light.py +++ b/homeassistant/components/hive/light.py @@ -12,7 +12,6 @@ from homeassistant.components.light import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.color as color_util @@ -40,72 +39,18 @@ async def async_setup_entry( class HiveDeviceLight(HiveEntity, LightEntity): """Hive Active Light Device.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id + def __init__(self, hive, hive_device): + """Initialise hive light.""" + super().__init__(hive, hive_device) + if self.device["hiveType"] == "warmwhitelight": + self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} + elif self.device["hiveType"] == "tuneablelight": + self._attr_supported_color_modes = {ColorMode.COLOR_TEMP} + elif self.device["hiveType"] == "colourtuneablelight": + self._attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the display name of this light.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def extra_state_attributes(self): - """Show Device Attributes.""" - return { - ATTR_MODE: self.attributes.get(ATTR_MODE), - } - - @property - def brightness(self): - """Brightness of the light (an integer in the range 1-255).""" - return self.device["status"]["brightness"] - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - return self.device.get("min_mireds") - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - return self.device.get("max_mireds") - - @property - def color_temp(self): - """Return the CT color value in mireds.""" - return self.device["status"].get("color_temp") - - @property - def hs_color(self): - """Return the hs color value.""" - if self.device["status"]["mode"] == "COLOUR": - rgb = self.device["status"].get("hs_color") - return color_util.color_RGB_to_hs(*rgb) - return None - - @property - def is_on(self): - """Return true if light is on.""" - return self.device["status"]["state"] + self._attr_min_mireds = self.device.get("min_mireds") + self._attr_max_mireds = self.device.get("max_mireds") @refresh_system async def async_turn_on(self, **kwargs): @@ -137,32 +82,18 @@ class HiveDeviceLight(HiveEntity, LightEntity): """Instruct the light to turn off.""" await self.hive.light.turnOff(self.device) - @property - def color_mode(self) -> str: - """Return the color mode of the light.""" - if self.device["hiveType"] == "warmwhitelight": - return ColorMode.BRIGHTNESS - if self.device["hiveType"] == "tuneablelight": - return ColorMode.COLOR_TEMP - if self.device["hiveType"] == "colourtuneablelight": - if self.device["status"]["mode"] == "COLOUR": - return ColorMode.HS - return ColorMode.COLOR_TEMP - return ColorMode.ONOFF - - @property - def supported_color_modes(self) -> set[str] | None: - """Flag supported color modes.""" - if self.device["hiveType"] == "warmwhitelight": - return {ColorMode.BRIGHTNESS} - if self.device["hiveType"] == "tuneablelight": - return {ColorMode.COLOR_TEMP} - if self.device["hiveType"] == "colourtuneablelight": - return {ColorMode.COLOR_TEMP, ColorMode.HS} - return {ColorMode.ONOFF} - async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.light.getLight(self.device) self.attributes.update(self.device.get("attributes", {})) + self._attr_extra_state_attributes = { + ATTR_MODE: self.attributes.get(ATTR_MODE), + } + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_is_on = self.device["status"]["state"] + self._attr_brightness = self.device["status"]["brightness"] + if self.device["hiveType"] == "colourtuneablelight": + rgb = self.device["status"]["hs_color"] + self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index 68de137dee7..bab8648407a 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -1,10 +1,14 @@ """Support for the Hive sensors.""" from datetime import timedelta -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity @@ -12,71 +16,41 @@ from .const import DOMAIN PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) -DEVICETYPE = { - "Battery": {"unit": " % ", "type": SensorDeviceClass.BATTERY}, -} + +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="Battery", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + ), +) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Hive thermostat based on a config entry.""" - hive = hass.data[DOMAIN][entry.entry_id] devices = hive.session.deviceList.get("sensor") entities = [] if devices: - for dev in devices: - entities.append(HiveSensorEntity(hive, dev)) + for description in SENSOR_TYPES: + for dev in devices: + if dev["hiveType"] == description.key: + entities.append(HiveSensorEntity(hive, dev, description)) async_add_entities(entities, True) class HiveSensorEntity(HiveEntity, SensorEntity): """Hive Sensor Entity.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def available(self): - """Return if sensor is available.""" - return self.device.get("deviceData", {}).get("online") - - @property - def device_class(self): - """Device class of the entity.""" - return DEVICETYPE[self.device["hiveType"]].get("type") - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return DEVICETYPE[self.device["hiveType"]].get("unit") - - @property - def name(self): - """Return the name of the sensor.""" - return self.device["haName"] - - @property - def native_value(self): - """Return the state of the sensor.""" - return self.device["status"]["state"] + def __init__(self, hive, hive_device, entity_description): + """Initialise hive sensor.""" + super().__init__(hive, hive_device) + self.entity_description = entity_description async def async_update(self): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.sensor.getSensor(self.device) + self._attr_native_value = self.device["status"]["state"] diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py index cb9ac79d51e..64a8276521a 100644 --- a/homeassistant/components/hive/switch.py +++ b/homeassistant/components/hive/switch.py @@ -3,10 +3,9 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity, refresh_system @@ -16,6 +15,14 @@ PARALLEL_UPDATES = 0 SCAN_INTERVAL = timedelta(seconds=15) +SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( + SwitchEntityDescription( + key="activeplug", + ), + SwitchEntityDescription(key="Heating_Heat_On_Demand"), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -25,54 +32,20 @@ async def async_setup_entry( devices = hive.session.deviceList.get("switch") entities = [] if devices: - for dev in devices: - entities.append(HiveDevicePlug(hive, dev)) + for description in SWITCH_TYPES: + for dev in devices: + if dev["hiveType"] == description.key: + entities.append(HiveSwitch(hive, dev, description)) async_add_entities(entities, True) -class HiveDevicePlug(HiveEntity, SwitchEntity): +class HiveSwitch(HiveEntity, SwitchEntity): """Hive Active Plug.""" - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo | None: - """Return device information.""" - if self.device["hiveType"] == "activeplug": - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - return None - - @property - def name(self): - """Return the name of this Switch device if any.""" - return self.device["haName"] - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"].get("online") - - @property - def extra_state_attributes(self): - """Show Device Attributes.""" - return { - ATTR_MODE: self.attributes.get(ATTR_MODE), - } - - @property - def is_on(self): - """Return true if switch is on.""" - return self.device["status"]["state"] + def __init__(self, hive, hive_device, entity_description): + """Initialise hive switch.""" + super().__init__(hive, hive_device) + self.entity_description = entity_description @refresh_system async def async_turn_on(self, **kwargs): @@ -89,3 +62,9 @@ class HiveDevicePlug(HiveEntity, SwitchEntity): await self.hive.session.updateData(self.device) self.device = await self.hive.switch.getSwitch(self.device) self.attributes.update(self.device.get("attributes", {})) + self._attr_extra_state_attributes = { + ATTR_MODE: self.attributes.get(ATTR_MODE), + } + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_is_on = self.device["status"]["state"] diff --git a/homeassistant/components/hive/water_heater.py b/homeassistant/components/hive/water_heater.py index 5e3c18fce69..0e7f2453c92 100644 --- a/homeassistant/components/hive/water_heater.py +++ b/homeassistant/components/hive/water_heater.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HiveEntity, refresh_system @@ -75,48 +74,8 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): """Hive Water Heater Device.""" _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE - - @property - def unique_id(self): - """Return unique ID of entity.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device["device_id"])}, - manufacturer=self.device["deviceData"]["manufacturer"], - model=self.device["deviceData"]["model"], - name=self.device["device_name"], - sw_version=self.device["deviceData"]["version"], - via_device=(DOMAIN, self.device["parentDevice"]), - ) - - @property - def name(self): - """Return the name of the water heater.""" - return HOTWATER_NAME - - @property - def available(self): - """Return if the device is available.""" - return self.device["deviceData"]["online"] - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def current_operation(self): - """Return current operation.""" - return HIVE_TO_HASS_STATE[self.device["status"]["current_operation"]] - - @property - def operation_list(self): - """List of available operation modes.""" - return SUPPORT_WATER_HEATER + _attr_temperature_unit = TEMP_CELSIUS + _attr_operation_list = SUPPORT_WATER_HEATER @refresh_system async def async_turn_on(self, **kwargs): @@ -146,3 +105,8 @@ class HiveWaterHeater(HiveEntity, WaterHeaterEntity): """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.hotwater.getWaterHeater(self.device) + self._attr_available = self.device["deviceData"].get("online") + if self._attr_available: + self._attr_current_operation = HIVE_TO_HASS_STATE[ + self.device["status"]["current_operation"] + ] From d6039528723a5fbcfbad5ecf01c65e311fc1e651 Mon Sep 17 00:00:00 2001 From: shbatm Date: Sun, 29 May 2022 11:00:18 -0500 Subject: [PATCH 064/947] Check ISY994 climate for unknown humidity on Z-Wave Thermostat (#72670) --- homeassistant/components/isy994/climate.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index 1276207f23c..d68395f14da 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -6,6 +6,7 @@ from typing import Any from pyisy.constants import ( CMD_CLIMATE_FAN_SETTING, CMD_CLIMATE_MODE, + ISY_VALUE_UNKNOWN, PROP_HEAT_COOL_STATE, PROP_HUMIDITY, PROP_SETPOINT_COOL, @@ -116,6 +117,8 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): """Return the current humidity.""" if not (humidity := self._node.aux_properties.get(PROP_HUMIDITY)): return None + if humidity == ISY_VALUE_UNKNOWN: + return None return int(humidity.value) @property From 237ef6419b5d77adf7dc09d7dd554965406103bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 May 2022 06:27:32 -1000 Subject: [PATCH 065/947] Add basic typing to emulated_hue (#72663) * Add basic typing to emulated_hue * type a few more places * fixes * numbers are always stringified * numbers are always stringified * coverage * drop assert --- .../components/emulated_hue/__init__.py | 233 +++--------------- .../components/emulated_hue/config.py | 213 ++++++++++++++++ .../components/emulated_hue/const.py | 2 + .../components/emulated_hue/hue_api.py | 96 +++++--- tests/components/emulated_hue/test_init.py | 16 +- tests/components/emulated_hue/test_upnp.py | 13 +- 6 files changed, 324 insertions(+), 249 deletions(-) create mode 100644 homeassistant/components/emulated_hue/config.py diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index d3586ab5fcf..71f98abed80 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -1,4 +1,6 @@ """Support for local control of entities by emulating a Philips Hue bridge.""" +from __future__ import annotations + import logging from aiohttp import web @@ -12,10 +14,29 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import storage import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType +from .config import ( + CONF_ADVERTISE_IP, + CONF_ADVERTISE_PORT, + CONF_ENTITY_HIDDEN, + CONF_ENTITY_NAME, + CONF_EXPOSE_BY_DEFAULT, + CONF_EXPOSED_DOMAINS, + CONF_HOST_IP, + CONF_LIGHTS_ALL_DIMMABLE, + CONF_LISTEN_PORT, + CONF_OFF_MAPS_TO_ON_DOMAINS, + CONF_UPNP_BIND_MULTICAST, + DEFAULT_LIGHTS_ALL_DIMMABLE, + DEFAULT_LISTEN_PORT, + DEFAULT_TYPE, + TYPE_ALEXA, + TYPE_GOOGLE, + Config, +) +from .const import DOMAIN from .hue_api import ( HueAllGroupsStateView, HueAllLightsStateView, @@ -27,46 +48,14 @@ from .hue_api import ( HueUnauthorizedUser, HueUsernameView, ) -from .upnp import DescriptionXmlView, create_upnp_datagram_endpoint - -DOMAIN = "emulated_hue" +from .upnp import ( + DescriptionXmlView, + UPNPResponderProtocol, + create_upnp_datagram_endpoint, +) _LOGGER = logging.getLogger(__name__) -NUMBERS_FILE = "emulated_hue_ids.json" -DATA_KEY = "emulated_hue.ids" -DATA_VERSION = "1" -SAVE_DELAY = 60 - -CONF_ADVERTISE_IP = "advertise_ip" -CONF_ADVERTISE_PORT = "advertise_port" -CONF_ENTITY_HIDDEN = "hidden" -CONF_ENTITY_NAME = "name" -CONF_EXPOSE_BY_DEFAULT = "expose_by_default" -CONF_EXPOSED_DOMAINS = "exposed_domains" -CONF_HOST_IP = "host_ip" -CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable" -CONF_LISTEN_PORT = "listen_port" -CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains" -CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast" - -TYPE_ALEXA = "alexa" -TYPE_GOOGLE = "google_home" - -DEFAULT_LIGHTS_ALL_DIMMABLE = False -DEFAULT_LISTEN_PORT = 8300 -DEFAULT_UPNP_BIND_MULTICAST = True -DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ["script", "scene"] -DEFAULT_EXPOSE_BY_DEFAULT = True -DEFAULT_EXPOSED_DOMAINS = [ - "switch", - "light", - "group", - "input_boolean", - "media_player", - "fan", -] -DEFAULT_TYPE = TYPE_GOOGLE CONFIG_ENTITY_SCHEMA = vol.Schema( { @@ -75,6 +64,7 @@ CONFIG_ENTITY_SCHEMA = vol.Schema( } ) + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -102,8 +92,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -ATTR_EMULATED_HUE_NAME = "emulated_hue_name" - async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate the emulated_hue component.""" @@ -140,7 +128,7 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: config.advertise_ip, config.advertise_port or config.listen_port, ) - protocol = None + protocol: UPNPResponderProtocol | None = None async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" @@ -161,7 +149,8 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: nonlocal site nonlocal runner - _, protocol = await listen + transport_protocol = await listen + protocol = transport_protocol[1] runner = web.AppRunner(app) await runner.setup() @@ -184,163 +173,3 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) return True - - -class Config: - """Hold configuration variables for the emulated hue bridge.""" - - def __init__(self, hass, conf, local_ip): - """Initialize the instance.""" - self.hass = hass - self.type = conf.get(CONF_TYPE) - self.numbers = None - self.store = None - self.cached_states = {} - self._exposed_cache = {} - - if self.type == TYPE_ALEXA: - _LOGGER.warning( - "Emulated Hue running in legacy mode because type has been " - "specified. More info at https://goo.gl/M6tgz8" - ) - - # Get the IP address that will be passed to the Echo during discovery - self.host_ip_addr = conf.get(CONF_HOST_IP) - if self.host_ip_addr is None: - self.host_ip_addr = local_ip - - # Get the port that the Hue bridge will listen on - self.listen_port = conf.get(CONF_LISTEN_PORT) - if not isinstance(self.listen_port, int): - self.listen_port = DEFAULT_LISTEN_PORT - _LOGGER.info( - "Listen port not specified, defaulting to %s", self.listen_port - ) - - # Get whether or not UPNP binds to multicast address (239.255.255.250) - # or to the unicast address (host_ip_addr) - self.upnp_bind_multicast = conf.get( - CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST - ) - - # Get domains that cause both "on" and "off" commands to map to "on" - # This is primarily useful for things like scenes or scripts, which - # don't really have a concept of being off - self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS) - if not isinstance(self.off_maps_to_on_domains, list): - self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS - - # Get whether or not entities should be exposed by default, or if only - # explicitly marked ones will be exposed - self.expose_by_default = conf.get( - CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT - ) - - # Get domains that are exposed by default when expose_by_default is - # True - self.exposed_domains = set( - conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) - ) - - # Calculated effective advertised IP and port for network isolation - self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr - - self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port - - self.entities = conf.get(CONF_ENTITIES, {}) - - self._entities_with_hidden_attr_in_config = {} - for entity_id in self.entities: - hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN) - if hidden_value is not None: - self._entities_with_hidden_attr_in_config[entity_id] = hidden_value - - # Get whether all non-dimmable lights should be reported as dimmable - # for compatibility with older installations. - self.lights_all_dimmable = conf.get(CONF_LIGHTS_ALL_DIMMABLE) - - async def async_setup(self): - """Set up and migrate to storage.""" - self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) - self.numbers = ( - await storage.async_migrator( - self.hass, self.hass.config.path(NUMBERS_FILE), self.store - ) - or {} - ) - - def entity_id_to_number(self, entity_id): - """Get a unique number for the entity id.""" - if self.type == TYPE_ALEXA: - return entity_id - - # Google Home - for number, ent_id in self.numbers.items(): - if entity_id == ent_id: - return number - - number = "1" - if self.numbers: - number = str(max(int(k) for k in self.numbers) + 1) - self.numbers[number] = entity_id - self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY) - return number - - def number_to_entity_id(self, number): - """Convert unique number to entity id.""" - if self.type == TYPE_ALEXA: - return number - - # Google Home - assert isinstance(number, str) - return self.numbers.get(number) - - def get_entity_name(self, entity): - """Get the name of an entity.""" - if ( - entity.entity_id in self.entities - and CONF_ENTITY_NAME in self.entities[entity.entity_id] - ): - return self.entities[entity.entity_id][CONF_ENTITY_NAME] - - return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name) - - def is_entity_exposed(self, entity): - """Cache determine if an entity should be exposed on the emulated bridge.""" - entity_id = entity.entity_id - if entity_id not in self._exposed_cache: - self._exposed_cache[entity_id] = self._is_entity_exposed(entity) - return self._exposed_cache[entity_id] - - def filter_exposed_entities(self, states): - """Filter a list of all states down to exposed entities.""" - exposed = [] - for entity in states: - entity_id = entity.entity_id - if entity_id not in self._exposed_cache: - self._exposed_cache[entity_id] = self._is_entity_exposed(entity) - if self._exposed_cache[entity_id]: - exposed.append(entity) - return exposed - - def _is_entity_exposed(self, entity): - """Determine if an entity should be exposed on the emulated bridge. - - Async friendly. - """ - if entity.attributes.get("view") is not None: - # Ignore entities that are views - return False - - if entity.entity_id in self._entities_with_hidden_attr_in_config: - return not self._entities_with_hidden_attr_in_config[entity.entity_id] - - if not self.expose_by_default: - return False - # Expose an entity if the entity's domain is exposed by default and - # the configuration doesn't explicitly exclude it from being - # exposed, or if the entity is explicitly exposed - if entity.domain in self.exposed_domains: - return True - - return False diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py new file mode 100644 index 00000000000..e39ec9839c8 --- /dev/null +++ b/homeassistant/components/emulated_hue/config.py @@ -0,0 +1,213 @@ +"""Support for local control of entities by emulating a Philips Hue bridge.""" +from __future__ import annotations + +from collections.abc import Iterable +import logging + +from homeassistant.const import CONF_ENTITIES, CONF_TYPE +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import storage +from homeassistant.helpers.typing import ConfigType + +TYPE_ALEXA = "alexa" +TYPE_GOOGLE = "google_home" + + +NUMBERS_FILE = "emulated_hue_ids.json" +DATA_KEY = "emulated_hue.ids" +DATA_VERSION = "1" +SAVE_DELAY = 60 + +CONF_ADVERTISE_IP = "advertise_ip" +CONF_ADVERTISE_PORT = "advertise_port" +CONF_ENTITY_HIDDEN = "hidden" +CONF_ENTITY_NAME = "name" +CONF_EXPOSE_BY_DEFAULT = "expose_by_default" +CONF_EXPOSED_DOMAINS = "exposed_domains" +CONF_HOST_IP = "host_ip" +CONF_LIGHTS_ALL_DIMMABLE = "lights_all_dimmable" +CONF_LISTEN_PORT = "listen_port" +CONF_OFF_MAPS_TO_ON_DOMAINS = "off_maps_to_on_domains" +CONF_UPNP_BIND_MULTICAST = "upnp_bind_multicast" + + +DEFAULT_LIGHTS_ALL_DIMMABLE = False +DEFAULT_LISTEN_PORT = 8300 +DEFAULT_UPNP_BIND_MULTICAST = True +DEFAULT_OFF_MAPS_TO_ON_DOMAINS = {"script", "scene"} +DEFAULT_EXPOSE_BY_DEFAULT = True +DEFAULT_EXPOSED_DOMAINS = [ + "switch", + "light", + "group", + "input_boolean", + "media_player", + "fan", +] +DEFAULT_TYPE = TYPE_GOOGLE + +ATTR_EMULATED_HUE_NAME = "emulated_hue_name" + + +_LOGGER = logging.getLogger(__name__) + + +class Config: + """Hold configuration variables for the emulated hue bridge.""" + + def __init__( + self, hass: HomeAssistant, conf: ConfigType, local_ip: str | None + ) -> None: + """Initialize the instance.""" + self.hass = hass + self.type = conf.get(CONF_TYPE) + self.numbers: dict[str, str] = {} + self.store: storage.Store | None = None + self.cached_states: dict[str, list] = {} + self._exposed_cache: dict[str, bool] = {} + + if self.type == TYPE_ALEXA: + _LOGGER.warning( + "Emulated Hue running in legacy mode because type has been " + "specified. More info at https://goo.gl/M6tgz8" + ) + + # Get the IP address that will be passed to the Echo during discovery + self.host_ip_addr = conf.get(CONF_HOST_IP) + if self.host_ip_addr is None: + self.host_ip_addr = local_ip + + # Get the port that the Hue bridge will listen on + self.listen_port = conf.get(CONF_LISTEN_PORT) + if not isinstance(self.listen_port, int): + self.listen_port = DEFAULT_LISTEN_PORT + _LOGGER.info( + "Listen port not specified, defaulting to %s", self.listen_port + ) + + # Get whether or not UPNP binds to multicast address (239.255.255.250) + # or to the unicast address (host_ip_addr) + self.upnp_bind_multicast = conf.get( + CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST + ) + + # Get domains that cause both "on" and "off" commands to map to "on" + # This is primarily useful for things like scenes or scripts, which + # don't really have a concept of being off + off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS) + if isinstance(off_maps_to_on_domains, list): + self.off_maps_to_on_domains = set(off_maps_to_on_domains) + else: + self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS + + # Get whether or not entities should be exposed by default, or if only + # explicitly marked ones will be exposed + self.expose_by_default = conf.get( + CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT + ) + + # Get domains that are exposed by default when expose_by_default is + # True + self.exposed_domains = set( + conf.get(CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS) + ) + + # Calculated effective advertised IP and port for network isolation + self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr + + self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port + + self.entities = conf.get(CONF_ENTITIES, {}) + + self._entities_with_hidden_attr_in_config = {} + for entity_id in self.entities: + hidden_value = self.entities[entity_id].get(CONF_ENTITY_HIDDEN) + if hidden_value is not None: + self._entities_with_hidden_attr_in_config[entity_id] = hidden_value + + # Get whether all non-dimmable lights should be reported as dimmable + # for compatibility with older installations. + self.lights_all_dimmable = conf.get(CONF_LIGHTS_ALL_DIMMABLE) + + async def async_setup(self) -> None: + """Set up and migrate to storage.""" + self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type] + self.numbers = ( + await storage.async_migrator( + self.hass, self.hass.config.path(NUMBERS_FILE), self.store + ) + or {} + ) + + def entity_id_to_number(self, entity_id: str) -> str: + """Get a unique number for the entity id.""" + if self.type == TYPE_ALEXA: + return entity_id + + # Google Home + for number, ent_id in self.numbers.items(): + if entity_id == ent_id: + return number + + number = "1" + if self.numbers: + number = str(max(int(k) for k in self.numbers) + 1) + self.numbers[number] = entity_id + assert self.store is not None + self.store.async_delay_save(lambda: self.numbers, SAVE_DELAY) + return number + + def number_to_entity_id(self, number: str) -> str | None: + """Convert unique number to entity id.""" + if self.type == TYPE_ALEXA: + return number + + # Google Home + return self.numbers.get(number) + + def get_entity_name(self, entity: State) -> str: + """Get the name of an entity.""" + if ( + entity.entity_id in self.entities + and CONF_ENTITY_NAME in self.entities[entity.entity_id] + ): + return self.entities[entity.entity_id][CONF_ENTITY_NAME] + + return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name) + + def is_entity_exposed(self, entity: State) -> bool: + """Cache determine if an entity should be exposed on the emulated bridge.""" + if (exposed := self._exposed_cache.get(entity.entity_id)) is not None: + return exposed + exposed = self._is_entity_exposed(entity) + self._exposed_cache[entity.entity_id] = exposed + return exposed + + def filter_exposed_entities(self, states: Iterable[State]) -> list[State]: + """Filter a list of all states down to exposed entities.""" + exposed: list[State] = [ + state for state in states if self.is_entity_exposed(state) + ] + return exposed + + def _is_entity_exposed(self, entity: State) -> bool: + """Determine if an entity should be exposed on the emulated bridge. + + Async friendly. + """ + if entity.attributes.get("view") is not None: + # Ignore entities that are views + return False + + if entity.entity_id in self._entities_with_hidden_attr_in_config: + return not self._entities_with_hidden_attr_in_config[entity.entity_id] + + if not self.expose_by_default: + return False + # Expose an entity if the entity's domain is exposed by default and + # the configuration doesn't explicitly exclude it from being + # exposed, or if the entity is explicitly exposed + if entity.domain in self.exposed_domains: + return True + + return False diff --git a/homeassistant/components/emulated_hue/const.py b/homeassistant/components/emulated_hue/const.py index bfd58c5a0e1..2bcd8cbac19 100644 --- a/homeassistant/components/emulated_hue/const.py +++ b/homeassistant/components/emulated_hue/const.py @@ -2,3 +2,5 @@ HUE_SERIAL_NUMBER = "001788FFFE23BFC2" HUE_UUID = "2f402f80-da50-11e1-9b23-001788255acc" + +DOMAIN = "emulated_hue" diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index b4f926afd31..e7a4876730c 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -1,10 +1,15 @@ """Support for a Hue API to control Home Assistant.""" +from __future__ import annotations + import asyncio import hashlib from http import HTTPStatus from ipaddress import ip_address import logging import time +from typing import Any + +from aiohttp import web from homeassistant import core from homeassistant.components import ( @@ -58,9 +63,12 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) +from homeassistant.core import State from homeassistant.helpers.event import async_track_state_change_event from homeassistant.util.network import is_local +from .config import Config + _LOGGER = logging.getLogger(__name__) # How long to wait for a state change to happen @@ -111,7 +119,7 @@ class HueUnauthorizedUser(HomeAssistantView): extra_urls = ["/api/"] requires_auth = False - async def get(self, request): + async def get(self, request: web.Request) -> web.Response: """Handle a GET request.""" return self.json(UNAUTHORIZED_USER) @@ -124,8 +132,9 @@ class HueUsernameView(HomeAssistantView): extra_urls = ["/api/"] requires_auth = False - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle a POST request.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -147,13 +156,14 @@ class HueAllGroupsStateView(HomeAssistantView): name = "emulated_hue:all_groups:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username): + def get(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Brilliant Lightpad work.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -167,13 +177,14 @@ class HueGroupView(HomeAssistantView): name = "emulated_hue:groups:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def put(self, request, username): + def put(self, request: web.Request, username: str) -> web.Response: """Process a request to make the Logitech Pop working.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -197,13 +208,14 @@ class HueAllLightsStateView(HomeAssistantView): name = "emulated_hue:lights:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username): + def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -217,13 +229,14 @@ class HueFullStateView(HomeAssistantView): name = "emulated_hue:username:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username): + def get(self, request: web.Request, username: str) -> web.Response: """Process a request to get the list of available lights.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) if username != HUE_API_USERNAME: @@ -245,13 +258,14 @@ class HueConfigView(HomeAssistantView): name = "emulated_hue:username:config" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username=""): + def get(self, request: web.Request, username: str = "") -> web.Response: """Process a request to get the configuration.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("only local IPs allowed", HTTPStatus.UNAUTHORIZED) @@ -267,17 +281,18 @@ class HueOneLightStateView(HomeAssistantView): name = "emulated_hue:light:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request, username, entity_id): + def get(self, request: web.Request, username: str, entity_id: str) -> web.Response: """Process a request to get the state of an individual light.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) - hass = request.app["hass"] + hass: core.HomeAssistant = request.app["hass"] hass_entity_id = self.config.number_to_entity_id(entity_id) if hass_entity_id is None: @@ -307,17 +322,20 @@ class HueOneLightChangeView(HomeAssistantView): name = "emulated_hue:light:state" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config - async def put(self, request, username, entity_number): # noqa: C901 + async def put( # noqa: C901 + self, request: web.Request, username: str, entity_number: str + ) -> web.Response: """Process a request to set the state of an individual light.""" + assert request.remote is not None if not is_local(ip_address(request.remote)): return self.json_message("Only local IPs allowed", HTTPStatus.UNAUTHORIZED) config = self.config - hass = request.app["hass"] + hass: core.HomeAssistant = request.app["hass"] entity_id = config.number_to_entity_id(entity_number) if entity_id is None: @@ -344,7 +362,7 @@ class HueOneLightChangeView(HomeAssistantView): color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) # Parse the request - parsed = { + parsed: dict[str, Any] = { STATE_ON: False, STATE_BRIGHTNESS: None, STATE_HUE: None, @@ -416,10 +434,10 @@ class HueOneLightChangeView(HomeAssistantView): turn_on_needed = False # Convert the resulting "on" status into the service we need to call - service = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF + service: str | None = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF # Construct what we need to send to the service - data = {ATTR_ENTITY_ID: entity_id} + data: dict[str, Any] = {ATTR_ENTITY_ID: entity_id} # If the requested entity is a light, set the brightness, hue, # saturation and color temp @@ -596,7 +614,7 @@ class HueOneLightChangeView(HomeAssistantView): return self.json(json_response) -def get_entity_state(config, entity): +def get_entity_state(config: Config, entity: State) -> dict[str, Any]: """Retrieve and convert state and brightness values for an entity.""" cached_state_entry = config.cached_states.get(entity.entity_id, None) cached_state = None @@ -617,7 +635,7 @@ def get_entity_state(config, entity): # Remove the now stale cached entry. config.cached_states.pop(entity.entity_id) - data = { + data: dict[str, Any] = { STATE_ON: False, STATE_BRIGHTNESS: None, STATE_HUE: None, @@ -700,7 +718,7 @@ def get_entity_state(config, entity): return data -def entity_to_json(config, entity): +def entity_to_json(config: Config, entity: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) @@ -709,7 +727,7 @@ def entity_to_json(config, entity): state = get_entity_state(config, entity) - retval = { + retval: dict[str, Any] = { "state": { HUE_API_STATE_ON: state[STATE_ON], "reachable": entity.state != STATE_UNAVAILABLE, @@ -793,13 +811,15 @@ def entity_to_json(config, entity): return retval -def create_hue_success_response(entity_number, attr, value): +def create_hue_success_response( + entity_number: str, attr: str, value: str +) -> dict[str, Any]: """Create a success response for an attribute set on a light.""" success_key = f"/lights/{entity_number}/state/{attr}" return {"success": {success_key: value}} -def create_config_model(config, request): +def create_config_model(config: Config, request: web.Request) -> dict[str, Any]: """Create a config resource.""" return { "mac": "00:00:00:00:00:00", @@ -811,29 +831,29 @@ def create_config_model(config, request): } -def create_list_of_entities(config, request): +def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]: """Create a list of all entities.""" - hass = request.app["hass"] - json_response = {} - - for entity in config.filter_exposed_entities(hass.states.async_all()): - number = config.entity_id_to_number(entity.entity_id) - json_response[number] = entity_to_json(config, entity) - + hass: core.HomeAssistant = request.app["hass"] + json_response: dict[str, Any] = { + config.entity_id_to_number(entity.entity_id): entity_to_json(config, entity) + for entity in config.filter_exposed_entities(hass.states.async_all()) + } return json_response -def hue_brightness_to_hass(value): +def hue_brightness_to_hass(value: int) -> int: """Convert hue brightness 1..254 to hass format 0..255.""" return min(255, round((value / HUE_API_STATE_BRI_MAX) * 255)) -def hass_to_hue_brightness(value): +def hass_to_hue_brightness(value: int) -> int: """Convert hass brightness 0..255 to hue 1..254 scale.""" return max(1, round((value / 255) * HUE_API_STATE_BRI_MAX)) -async def wait_for_state_change_or_timeout(hass, entity_id, timeout): +async def wait_for_state_change_or_timeout( + hass: core.HomeAssistant, entity_id: str, timeout: float +) -> None: """Wait for an entity to change state.""" ev = asyncio.Event() diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 93bf8c0631f..024b0f3ddf7 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -1,13 +1,14 @@ """Test the Emulated Hue component.""" from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from homeassistant.components.emulated_hue import ( +from homeassistant.components.emulated_hue.config import ( DATA_KEY, DATA_VERSION, SAVE_DELAY, Config, ) +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component from homeassistant.util import utcnow @@ -121,6 +122,13 @@ async def test_setup_works(hass): """Test setup works.""" hass.config.components.add("network") with patch( - "homeassistant.components.emulated_hue.create_upnp_datagram_endpoint" - ), patch("homeassistant.components.emulated_hue.async_get_source_ip"): + "homeassistant.components.emulated_hue.create_upnp_datagram_endpoint", + AsyncMock(), + ) as mock_create_upnp_datagram_endpoint, patch( + "homeassistant.components.emulated_hue.async_get_source_ip" + ): assert await async_setup_component(hass, "emulated_hue", {}) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert len(mock_create_upnp_datagram_endpoint.mock_calls) == 2 diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index ec04ee7e19c..79daaadbbc9 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -2,6 +2,7 @@ from http import HTTPStatus import json import unittest +from unittest.mock import patch from aiohttp import web import defusedxml.ElementTree as ET @@ -52,11 +53,13 @@ def hue_client(aiohttp_client): async def setup_hue(hass): """Set up the emulated_hue integration.""" - assert await setup.async_setup_component( - hass, - emulated_hue.DOMAIN, - {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, - ) + with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"): + assert await setup.async_setup_component( + hass, + emulated_hue.DOMAIN, + {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}}, + ) + await hass.async_block_till_done() def test_upnp_discovery_basic(): From 92be8b4f8e9c947e687aff9b60db024c8ceda0d4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 May 2022 12:29:21 -0400 Subject: [PATCH 066/947] Make tomorrowio API rate limit handling more robust (#70412) * Use the max request limit when setting tomorrowio update interval * tests * reduce lines * simplify * refactor * Make Coordinator.async_setup_entry more efficient at determining when to refresh data and schedule refresh * clean up * clean up * Remove unnecessary type definition * typo * fix logic ot be more deterministic * Another fix * Comment * Reduce wasted API calls by doing partial updates when new entries get added with a new key * Simplify and use asyncio event so that config entries only load after initial coordinator refresh * Remove commented out piece * Comment * Remove unnecessary variable * More cleanup * Make future merge easier * remove dupe * switch order * add comment * Remove unnecessary error handling * make code easier to read * review feedback for code * Fix logic * Update test based on review * Tweak comments * reset mock so asertions are more clear * Remove update interval check --- .../components/tomorrowio/__init__.py | 243 +++++++++++------- homeassistant/components/tomorrowio/sensor.py | 3 +- .../components/tomorrowio/weather.py | 9 +- tests/components/tomorrowio/conftest.py | 14 +- tests/components/tomorrowio/test_init.py | 69 ++++- 5 files changed, 231 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index d8decc1aea3..cef4662d1a4 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -1,6 +1,7 @@ """The Tomorrow.io integration.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from math import ceil @@ -23,7 +24,6 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, - CONF_NAME, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -40,7 +40,6 @@ from .const import ( CONF_TIMESTEP, DOMAIN, INTEGRATION_NAME, - MAX_REQUESTS_PER_DAY, TMRW_ATTR_CARBON_MONOXIDE, TMRW_ATTR_CHINA_AQI, TMRW_ATTR_CHINA_HEALTH_CONCERN, @@ -85,36 +84,33 @@ PLATFORMS = [SENSOR_DOMAIN, WEATHER_DOMAIN] @callback -def async_set_update_interval( - hass: HomeAssistant, current_entry: ConfigEntry -) -> timedelta: - """Recalculate update_interval based on existing Tomorrow.io instances and update them.""" - api_calls = 2 - # We check how many Tomorrow.io configured instances are using the same API key and - # calculate interval to not exceed allowed numbers of requests. Divide 90% of - # MAX_REQUESTS_PER_DAY by the number of API calls because we want a buffer in the - # number of API calls left at the end of the day. - other_instance_entry_ids = [ - entry.entry_id +def async_get_entries_by_api_key( + hass: HomeAssistant, api_key: str, exclude_entry: ConfigEntry | None = None +) -> list[ConfigEntry]: + """Get all entries for a given API key.""" + return [ + entry for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id != current_entry.entry_id - and entry.data[CONF_API_KEY] == current_entry.data[CONF_API_KEY] + if entry.data[CONF_API_KEY] == api_key + and (exclude_entry is None or exclude_entry != entry) ] - interval = timedelta( - minutes=( - ceil( - (24 * 60 * (len(other_instance_entry_ids) + 1) * api_calls) - / (MAX_REQUESTS_PER_DAY * 0.9) - ) - ) + +@callback +def async_set_update_interval( + hass: HomeAssistant, api: TomorrowioV4, exclude_entry: ConfigEntry | None = None +) -> timedelta: + """Calculate update_interval.""" + # We check how many Tomorrow.io configured instances are using the same API key and + # calculate interval to not exceed allowed numbers of requests. Divide 90% of + # max_requests by the number of API calls because we want a buffer in the + # number of API calls left at the end of the day. + entries = async_get_entries_by_api_key(hass, api.api_key, exclude_entry) + minutes = ceil( + (24 * 60 * len(entries) * api.num_api_requests) + / (api.max_requests_per_day * 0.9) ) - - for entry_id in other_instance_entry_ids: - if entry_id in hass.data[DOMAIN]: - hass.data[DOMAIN][entry_id].update_interval = interval - - return interval + return timedelta(minutes=minutes) @callback @@ -197,24 +193,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if entry.source == SOURCE_IMPORT and "old_config_entry_id" in entry.data: async_migrate_entry_from_climacell(hass, dev_reg, entry, device) - api = TomorrowioV4( - entry.data[CONF_API_KEY], - entry.data[CONF_LOCATION][CONF_LATITUDE], - entry.data[CONF_LOCATION][CONF_LONGITUDE], - unit_system="metric", - session=async_get_clientsession(hass), - ) + api_key = entry.data[CONF_API_KEY] + # If coordinator already exists for this API key, we'll use that, otherwise + # we have to create a new one + if not (coordinator := hass.data[DOMAIN].get(api_key)): + session = async_get_clientsession(hass) + # we will not use the class's lat and long so we can pass in garbage + # lats and longs + api = TomorrowioV4(api_key, 361.0, 361.0, unit_system="metric", session=session) + coordinator = TomorrowioDataUpdateCoordinator(hass, api) + hass.data[DOMAIN][api_key] = coordinator - coordinator = TomorrowioDataUpdateCoordinator( - hass, - entry, - api, - async_set_update_interval(hass, entry), - ) - - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id] = coordinator + await coordinator.async_setup_entry(entry) hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -227,9 +217,13 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> config_entry, PLATFORMS ) - hass.data[DOMAIN].pop(config_entry.entry_id) - if not hass.data[DOMAIN]: - hass.data.pop(DOMAIN) + api_key = config_entry.data[CONF_API_KEY] + coordinator: TomorrowioDataUpdateCoordinator = hass.data[DOMAIN][api_key] + # If this is true, we can remove the coordinator + if await coordinator.async_unload_entry(config_entry): + hass.data[DOMAIN].pop(api_key) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) return unload_ok @@ -237,44 +231,90 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator): """Define an object to hold Tomorrow.io data.""" - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - api: TomorrowioV4, - update_interval: timedelta, - ) -> None: + def __init__(self, hass: HomeAssistant, api: TomorrowioV4) -> None: """Initialize.""" - - self._config_entry = config_entry self._api = api - self.name = config_entry.data[CONF_NAME] self.data = {CURRENT: {}, FORECASTS: {}} + self.entry_id_to_location_dict: dict[str, str] = {} + self._coordinator_ready: asyncio.Event | None = None - super().__init__( - hass, - _LOGGER, - name=config_entry.data[CONF_NAME], - update_interval=update_interval, - ) + super().__init__(hass, _LOGGER, name=f"{DOMAIN}_{self._api.api_key}") + + def add_entry_to_location_dict(self, entry: ConfigEntry) -> None: + """Add an entry to the location dict.""" + latitude = entry.data[CONF_LOCATION][CONF_LATITUDE] + longitude = entry.data[CONF_LOCATION][CONF_LONGITUDE] + self.entry_id_to_location_dict[entry.entry_id] = f"{latitude},{longitude}" + + async def async_setup_entry(self, entry: ConfigEntry) -> None: + """Load config entry into coordinator.""" + # If we haven't loaded any data yet, register all entries with this API key and + # get the initial data for all of them. We do this because another config entry + # may start setup before we finish setting the initial data and we don't want + # to do multiple refreshes on startup. + if self._coordinator_ready is None: + self._coordinator_ready = asyncio.Event() + for entry_ in async_get_entries_by_api_key(self.hass, self._api.api_key): + self.add_entry_to_location_dict(entry_) + await self.async_config_entry_first_refresh() + self._coordinator_ready.set() + else: + # If we have an event, we need to wait for it to be set before we proceed + await self._coordinator_ready.wait() + # If we're not getting new data because we already know this entry, we + # don't need to schedule a refresh + if entry.entry_id in self.entry_id_to_location_dict: + return + # We need a refresh, but it's going to be a partial refresh so we can + # minimize repeat API calls + self.add_entry_to_location_dict(entry) + await self.async_refresh() + + self.update_interval = async_set_update_interval(self.hass, self._api) + self._schedule_refresh() + + async def async_unload_entry(self, entry: ConfigEntry) -> bool | None: + """ + Unload a config entry from coordinator. + + Returns whether coordinator can be removed as well because there are no + config entries tied to it anymore. + """ + self.entry_id_to_location_dict.pop(entry.entry_id) + self.update_interval = async_set_update_interval(self.hass, self._api, entry) + return not self.entry_id_to_location_dict async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" - try: - return await self._api.realtime_and_all_forecasts( - [ - TMRW_ATTR_TEMPERATURE, - TMRW_ATTR_HUMIDITY, - TMRW_ATTR_PRESSURE, - TMRW_ATTR_WIND_SPEED, - TMRW_ATTR_WIND_DIRECTION, - TMRW_ATTR_CONDITION, - TMRW_ATTR_VISIBILITY, - TMRW_ATTR_OZONE, - TMRW_ATTR_WIND_GUST, - TMRW_ATTR_CLOUD_COVER, - TMRW_ATTR_PRECIPITATION_TYPE, - *( + data = {} + # If we are refreshing because of a new config entry that's not already in our + # data, we do a partial refresh to avoid wasted API calls. + if self.data and any( + entry_id not in self.data for entry_id in self.entry_id_to_location_dict + ): + data = self.data + + for entry_id, location in self.entry_id_to_location_dict.items(): + if entry_id in data: + continue + entry = self.hass.config_entries.async_get_entry(entry_id) + assert entry + try: + data[entry_id] = await self._api.realtime_and_all_forecasts( + [ + # Weather + TMRW_ATTR_TEMPERATURE, + TMRW_ATTR_HUMIDITY, + TMRW_ATTR_PRESSURE, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_VISIBILITY, + TMRW_ATTR_OZONE, + TMRW_ATTR_WIND_GUST, + TMRW_ATTR_CLOUD_COVER, + TMRW_ATTR_PRECIPITATION_TYPE, + # Sensors TMRW_ATTR_CARBON_MONOXIDE, TMRW_ATTR_CHINA_AQI, TMRW_ATTR_CHINA_HEALTH_CONCERN, @@ -300,26 +340,28 @@ class TomorrowioDataUpdateCoordinator(DataUpdateCoordinator): TMRW_ATTR_SOLAR_GHI, TMRW_ATTR_SULPHUR_DIOXIDE, TMRW_ATTR_WIND_GUST, - ), - ], - [ - TMRW_ATTR_TEMPERATURE_LOW, - TMRW_ATTR_TEMPERATURE_HIGH, - TMRW_ATTR_WIND_SPEED, - TMRW_ATTR_WIND_DIRECTION, - TMRW_ATTR_CONDITION, - TMRW_ATTR_PRECIPITATION, - TMRW_ATTR_PRECIPITATION_PROBABILITY, - ], - nowcast_timestep=self._config_entry.options[CONF_TIMESTEP], - ) - except ( - CantConnectException, - InvalidAPIKeyException, - RateLimitedException, - UnknownException, - ) as error: - raise UpdateFailed from error + ], + [ + TMRW_ATTR_TEMPERATURE_LOW, + TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_WIND_SPEED, + TMRW_ATTR_WIND_DIRECTION, + TMRW_ATTR_CONDITION, + TMRW_ATTR_PRECIPITATION, + TMRW_ATTR_PRECIPITATION_PROBABILITY, + ], + nowcast_timestep=entry.options[CONF_TIMESTEP], + location=location, + ) + except ( + CantConnectException, + InvalidAPIKeyException, + RateLimitedException, + UnknownException, + ) as error: + raise UpdateFailed from error + + return data class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): @@ -349,7 +391,8 @@ class TomorrowioEntity(CoordinatorEntity[TomorrowioDataUpdateCoordinator]): Used for V4 API. """ - return self.coordinator.data.get(CURRENT, {}).get(property_name) + entry_id = self._config_entry.entry_id + return self.coordinator.data[entry_id].get(CURRENT, {}).get(property_name) @property def attribution(self): diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index d221922df54..ed4ae915c1c 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_MILLION, + CONF_API_KEY, CONF_NAME, IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, IRRADIATION_WATTS_PER_SQUARE_METER, @@ -286,7 +287,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] entities = [ TomorrowioSensorEntity(hass, config_entry, coordinator, 4, description) for description in SENSOR_TYPES diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index bf687f8bdca..bde6e6b996b 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -19,6 +19,7 @@ from homeassistant.components.weather import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, LENGTH_KILOMETERS, LENGTH_MILLIMETERS, @@ -61,7 +62,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.data[CONF_API_KEY]] entities = [ TomorrowioWeatherEntity(config_entry, coordinator, 4, forecast_type) @@ -190,7 +191,11 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): def forecast(self): """Return the forecast.""" # Check if forecasts are available - raw_forecasts = self.coordinator.data.get(FORECASTS, {}).get(self.forecast_type) + raw_forecasts = ( + self.coordinator.data.get(self._config_entry.entry_id, {}) + .get(FORECASTS, {}) + .get(self.forecast_type) + ) if not raw_forecasts: return None diff --git a/tests/components/tomorrowio/conftest.py b/tests/components/tomorrowio/conftest.py index 65c69209f0e..9c1fa5baa06 100644 --- a/tests/components/tomorrowio/conftest.py +++ b/tests/components/tomorrowio/conftest.py @@ -1,6 +1,6 @@ """Configure py.test.""" import json -from unittest.mock import patch +from unittest.mock import PropertyMock, patch import pytest @@ -23,8 +23,16 @@ def tomorrowio_config_entry_update_fixture(): with patch( "homeassistant.components.tomorrowio.TomorrowioV4.realtime_and_all_forecasts", return_value=json.loads(load_fixture("v4.json", "tomorrowio")), - ): - yield + ) as mock_update, patch( + "homeassistant.components.tomorrowio.TomorrowioV4.max_requests_per_day", + new_callable=PropertyMock, + ) as mock_max_requests_per_day, patch( + "homeassistant.components.tomorrowio.TomorrowioV4.num_api_requests", + new_callable=PropertyMock, + ) as mock_num_api_requests: + mock_max_requests_per_day.return_value = 100 + mock_num_api_requests.return_value = 2 + yield mock_update @pytest.fixture(name="climacell_config_entry_update") diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index c9914bf95be..27372094092 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -1,4 +1,7 @@ """Tests for Tomorrow.io init.""" +from datetime import timedelta +from unittest.mock import patch + from homeassistant.components.climacell.const import CONF_TIMESTEP, DOMAIN as CC_DOMAIN from homeassistant.components.tomorrowio.config_flow import ( _get_config_schema, @@ -17,10 +20,11 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import dt as dt_util from .const import MIN_CONFIG -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.climacell.const import API_V3_ENTRY_DATA NEW_NAME = "New Name" @@ -47,6 +51,69 @@ async def test_load_and_unload(hass: HomeAssistant) -> None: assert len(hass.states.async_entity_ids(WEATHER_DOMAIN)) == 0 +async def test_update_intervals( + hass: HomeAssistant, tomorrowio_config_entry_update +) -> None: + """Test coordinator update intervals.""" + now = dt_util.utcnow() + data = _get_config_schema(hass, SOURCE_USER)(MIN_CONFIG) + data[CONF_NAME] = "test" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: 1}, + unique_id=_get_unique_id(hass, data), + version=1, + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + tomorrowio_config_entry_update.reset_mock() + + # Before the update interval, no updates yet + async_fire_time_changed(hass, now + timedelta(minutes=30)) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 + + # On the update interval, we get a new update + async_fire_time_changed(hass, now + timedelta(minutes=32)) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + tomorrowio_config_entry_update.reset_mock() + + with patch( + "homeassistant.helpers.update_coordinator.utcnow", + return_value=now + timedelta(minutes=32), + ): + # Adding a second config entry should cause the update interval to double + config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data=data, + options={CONF_TIMESTEP: 1}, + unique_id=f"{_get_unique_id(hass, data)}_1", + version=1, + ) + config_entry_2.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry_2.entry_id) + await hass.async_block_till_done() + assert config_entry.data[CONF_API_KEY] == config_entry_2.data[CONF_API_KEY] + # We should get an immediate call once the new config entry is setup for a + # partial update + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + tomorrowio_config_entry_update.reset_mock() + + # We should get no new calls on our old interval + async_fire_time_changed(hass, now + timedelta(minutes=64)) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 + + # We should get two calls on our new interval, one for each entry + async_fire_time_changed(hass, now + timedelta(minutes=96)) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 2 + + async def test_climacell_migration_logic( hass: HomeAssistant, climacell_config_entry_update ) -> None: From 5031c3c8b46ee7d01d696fd23c70b0c2cd7c97b4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 29 May 2022 12:30:00 -0400 Subject: [PATCH 067/947] Fix zwave_js custom trigger validation bug (#72656) * Fix zwave_js custom trigger validation bug * update comments * Switch to ValueError * Switch to ValueError --- .../components/zwave_js/triggers/event.py | 36 +-- .../zwave_js/triggers/value_updated.py | 12 +- tests/components/zwave_js/test_trigger.py | 217 ++++++++++++++++++ 3 files changed, 241 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 17bb52fb392..784ae74777b 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -8,7 +8,7 @@ import voluptuous as vol from zwave_js_server.client import Client from zwave_js_server.model.controller import CONTROLLER_EVENT_MODEL_MAP from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP -from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP, Node +from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP from homeassistant.components.automation import ( AutomationActionType, @@ -20,7 +20,6 @@ from homeassistant.components.zwave_js.const import ( ATTR_EVENT_DATA, ATTR_EVENT_SOURCE, ATTR_NODE_ID, - ATTR_NODES, ATTR_PARTIAL_DICT_MATCH, DATA_CLIENT, DOMAIN, @@ -116,22 +115,20 @@ async def async_validate_trigger_config( """Validate config.""" config = TRIGGER_SCHEMA(config) + if ATTR_CONFIG_ENTRY_ID in config: + entry_id = config[ATTR_CONFIG_ENTRY_ID] + if hass.config_entries.async_get_entry(entry_id) is None: + raise vol.Invalid(f"Config entry '{entry_id}' not found") + if async_bypass_dynamic_config_validation(hass, config): return config - if config[ATTR_EVENT_SOURCE] == "node": - config[ATTR_NODES] = async_get_nodes_from_targets(hass, config) - if not config[ATTR_NODES]: - raise vol.Invalid( - f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." - ) - - if ATTR_CONFIG_ENTRY_ID not in config: - return config - - entry_id = config[ATTR_CONFIG_ENTRY_ID] - if hass.config_entries.async_get_entry(entry_id) is None: - raise vol.Invalid(f"Config entry '{entry_id}' not found") + if config[ATTR_EVENT_SOURCE] == "node" and not async_get_nodes_from_targets( + hass, config + ): + raise vol.Invalid( + f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." + ) return config @@ -145,7 +142,12 @@ async def async_attach_trigger( platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - nodes: set[Node] = config.get(ATTR_NODES, {}) + dev_reg = dr.async_get(hass) + nodes = async_get_nodes_from_targets(hass, config, dev_reg=dev_reg) + if config[ATTR_EVENT_SOURCE] == "node" and not nodes: + raise ValueError( + f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." + ) event_source = config[ATTR_EVENT_SOURCE] event_name = config[ATTR_EVENT] @@ -200,8 +202,6 @@ async def async_attach_trigger( hass.async_run_hass_job(job, {"trigger": payload}) - dev_reg = dr.async_get(hass) - if not nodes: entry_id = config[ATTR_CONFIG_ENTRY_ID] client: Client = hass.data[DOMAIN][entry_id][DATA_CLIENT] diff --git a/homeassistant/components/zwave_js/triggers/value_updated.py b/homeassistant/components/zwave_js/triggers/value_updated.py index 4f15b87a6db..29b4b4d06d6 100644 --- a/homeassistant/components/zwave_js/triggers/value_updated.py +++ b/homeassistant/components/zwave_js/triggers/value_updated.py @@ -5,7 +5,6 @@ import functools import voluptuous as vol from zwave_js_server.const import CommandClass -from zwave_js_server.model.node import Node from zwave_js_server.model.value import Value, get_value_id from homeassistant.components.automation import ( @@ -20,7 +19,6 @@ from homeassistant.components.zwave_js.const import ( ATTR_CURRENT_VALUE_RAW, ATTR_ENDPOINT, ATTR_NODE_ID, - ATTR_NODES, ATTR_PREVIOUS_VALUE, ATTR_PREVIOUS_VALUE_RAW, ATTR_PROPERTY, @@ -79,8 +77,7 @@ async def async_validate_trigger_config( if async_bypass_dynamic_config_validation(hass, config): return config - config[ATTR_NODES] = async_get_nodes_from_targets(hass, config) - if not config[ATTR_NODES]: + if not async_get_nodes_from_targets(hass, config): raise vol.Invalid( f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." ) @@ -96,7 +93,11 @@ async def async_attach_trigger( platform_type: str = PLATFORM_TYPE, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - nodes: set[Node] = config[ATTR_NODES] + dev_reg = dr.async_get(hass) + if not (nodes := async_get_nodes_from_targets(hass, config, dev_reg=dev_reg)): + raise ValueError( + f"No nodes found for given {ATTR_DEVICE_ID}s or {ATTR_ENTITY_ID}s." + ) from_value = config[ATTR_FROM] to_value = config[ATTR_TO] @@ -163,7 +164,6 @@ async def async_attach_trigger( hass.async_run_hass_job(job, {"trigger": payload}) - dev_reg = dr.async_get(hass) for node in nodes: driver = node.client.driver assert driver is not None # The node comes from the driver. diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index 9758f566d81..48439eede0f 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -269,6 +269,122 @@ async def test_zwave_js_value_updated(hass, client, lock_schlage_be469, integrat await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) +async def test_zwave_js_value_updated_bypass_dynamic_validation( + hass, client, lock_schlage_be469, integration +): + """Test zwave_js.value_updated trigger when bypassing dynamic validation.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + + no_value_filter = async_capture_events(hass, "no_value_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.value_updated.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # no value filter + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + ] + }, + ) + + # Test that no value filter is triggered + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 1 + + +async def test_zwave_js_value_updated_bypass_dynamic_validation_no_nodes( + hass, client, lock_schlage_be469, integration +): + """Test value_updated trigger when bypassing dynamic validation with no nodes.""" + trigger_type = f"{DOMAIN}.value_updated" + node: Node = lock_schlage_be469 + + no_value_filter = async_capture_events(hass, "no_value_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.value_updated.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # no value filter + { + "trigger": { + "platform": trigger_type, + "entity_id": "sensor.test", + "command_class": CommandClass.DOOR_LOCK.value, + "property": "latchStatus", + }, + "action": { + "event": "no_value_filter", + }, + }, + ] + }, + ) + + # Test that no value filter is NOT triggered because automation failed setup + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Door Lock", + "commandClass": 98, + "endpoint": 0, + "property": "latchStatus", + "newValue": "boo", + "prevValue": "hiss", + "propertyName": "latchStatus", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(no_value_filter) == 0 + + async def test_zwave_js_event(hass, client, lock_schlage_be469, integration): """Test for zwave_js.event automation trigger.""" trigger_type = f"{DOMAIN}.event" @@ -644,6 +760,107 @@ async def test_zwave_js_event(hass, client, lock_schlage_be469, integration): await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True) +async def test_zwave_js_event_bypass_dynamic_validation( + hass, client, lock_schlage_be469, integration +): + """Test zwave_js.event trigger when bypassing dynamic config validation.""" + trigger_type = f"{DOMAIN}.event" + node: Node = lock_schlage_be469 + + node_no_event_data_filter = async_capture_events(hass, "node_no_event_data_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.event.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # node filter: no event data + { + "trigger": { + "platform": trigger_type, + "entity_id": SCHLAGE_BE469_LOCK_ENTITY, + "event_source": "node", + "event": "interview stage completed", + }, + "action": { + "event": "node_no_event_data_filter", + }, + }, + ] + }, + ) + + # Test that `node no event data filter` is triggered and `node event data filter` is not + event = Event( + type="interview stage completed", + data={ + "source": "node", + "event": "interview stage completed", + "stageName": "NodeInfo", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(node_no_event_data_filter) == 1 + + +async def test_zwave_js_event_bypass_dynamic_validation_no_nodes( + hass, client, lock_schlage_be469, integration +): + """Test event trigger when bypassing dynamic validation with no nodes.""" + trigger_type = f"{DOMAIN}.event" + node: Node = lock_schlage_be469 + + node_no_event_data_filter = async_capture_events(hass, "node_no_event_data_filter") + + with patch( + "homeassistant.components.zwave_js.triggers.event.async_bypass_dynamic_config_validation", + return_value=True, + ): + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + # node filter: no event data + { + "trigger": { + "platform": trigger_type, + "entity_id": "sensor.fake", + "event_source": "node", + "event": "interview stage completed", + }, + "action": { + "event": "node_no_event_data_filter", + }, + }, + ] + }, + ) + + # Test that `node no event data filter` is NOT triggered because automation failed + # setup + event = Event( + type="interview stage completed", + data={ + "source": "node", + "event": "interview stage completed", + "stageName": "NodeInfo", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + assert len(node_no_event_data_filter) == 0 + + async def test_zwave_js_event_invalid_config_entry_id( hass, client, integration, caplog ): From 1d57626ff022fb2017a0e23ff53deca38c9583f2 Mon Sep 17 00:00:00 2001 From: Shawn Saenger Date: Sun, 29 May 2022 10:33:33 -0600 Subject: [PATCH 068/947] Incorporate various improvements for the ws66i integration (#71717) * Improve readability and remove unused code * Remove ws66i custom services. Scenes can be used instead. * Unmute WS66i Zone when volume changes * Raise CannotConnect instead of ConnectionError in validation method * Move _verify_connection() method to module level --- homeassistant/components/ws66i/__init__.py | 5 +- homeassistant/components/ws66i/config_flow.py | 48 +- homeassistant/components/ws66i/const.py | 6 +- homeassistant/components/ws66i/coordinator.py | 11 +- .../components/ws66i/media_player.py | 67 +-- homeassistant/components/ws66i/models.py | 2 - homeassistant/components/ws66i/services.yaml | 15 - homeassistant/components/ws66i/strings.json | 3 - .../components/ws66i/translations/en.json | 3 - tests/components/ws66i/test_config_flow.py | 6 +- tests/components/ws66i/test_init.py | 80 ++++ tests/components/ws66i/test_media_player.py | 414 +++++------------- 12 files changed, 251 insertions(+), 409 deletions(-) delete mode 100644 homeassistant/components/ws66i/services.yaml create mode 100644 tests/components/ws66i/test_init.py diff --git a/homeassistant/components/ws66i/__init__.py b/homeassistant/components/ws66i/__init__.py index 232c4390f19..dea1b470b9e 100644 --- a/homeassistant/components/ws66i/__init__.py +++ b/homeassistant/components/ws66i/__init__.py @@ -94,8 +94,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: zones=zones, ) + @callback def shutdown(event): - """Close the WS66i connection to the amplifier and save snapshots.""" + """Close the WS66i connection to the amplifier.""" ws66i.close() entry.async_on_unload(entry.add_update_listener(_update_listener)) @@ -119,6 +120,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): +async def _update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index a8f098faadd..b84872da036 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -1,5 +1,6 @@ """Config flow for WS66i 6-Zone Amplifier integration.""" import logging +from typing import Any from pyws66i import WS66i, get_ws66i import voluptuous as vol @@ -50,22 +51,34 @@ def _sources_from_config(data): } -async def validate_input(hass: core.HomeAssistant, input_data): - """Validate the user input allows us to connect. +def _verify_connection(ws66i: WS66i) -> bool: + """Verify a connection can be made to the WS66i.""" + try: + ws66i.open() + except ConnectionError as err: + raise CannotConnect from err + + # Connection successful. Verify correct port was opened + # Test on FIRST_ZONE because this zone will always be valid + ret_val = ws66i.zone_status(FIRST_ZONE) + + ws66i.close() + + return bool(ret_val) + + +async def validate_input( + hass: core.HomeAssistant, input_data: dict[str, Any] +) -> dict[str, Any]: + """Validate the user input. Data has the keys from DATA_SCHEMA with values provided by the user. """ ws66i: WS66i = get_ws66i(input_data[CONF_IP_ADDRESS]) - await hass.async_add_executor_job(ws66i.open) - # No exception. run a simple test to make sure we opened correct port - # Test on FIRST_ZONE because this zone will always be valid - ret_val = await hass.async_add_executor_job(ws66i.zone_status, FIRST_ZONE) - if ret_val is None: - ws66i.close() - raise ConnectionError("Not a valid WS66i connection") - # Validation done. No issues. Close the connection - ws66i.close() + is_valid: bool = await hass.async_add_executor_job(_verify_connection, ws66i) + if not is_valid: + raise CannotConnect("Not a valid WS66i connection") # Return info that you want to store in the config entry. return {CONF_IP_ADDRESS: input_data[CONF_IP_ADDRESS]} @@ -82,17 +95,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: info = await validate_input(self.hass, user_input) - # Data is valid. Add default values for options flow. + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Data is valid. Create a config entry. return self.async_create_entry( title="WS66i Amp", data=info, options={CONF_SOURCES: INIT_OPTIONS_DEFAULT}, ) - except ConnectionError: - errors["base"] = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/ws66i/const.py b/homeassistant/components/ws66i/const.py index ec4439a690d..f824d991c1d 100644 --- a/homeassistant/components/ws66i/const.py +++ b/homeassistant/components/ws66i/const.py @@ -1,4 +1,5 @@ """Constants for the Soundavo WS66i 6-Zone Amplifier Media Player component.""" +from datetime import timedelta DOMAIN = "ws66i" @@ -20,5 +21,6 @@ INIT_OPTIONS_DEFAULT = { "6": "Source 6", } -SERVICE_SNAPSHOT = "snapshot" -SERVICE_RESTORE = "restore" +POLL_INTERVAL = timedelta(seconds=30) + +MAX_VOL = 38 diff --git a/homeassistant/components/ws66i/coordinator.py b/homeassistant/components/ws66i/coordinator.py index a9a274756b5..be8ae3aad38 100644 --- a/homeassistant/components/ws66i/coordinator.py +++ b/homeassistant/components/ws66i/coordinator.py @@ -1,7 +1,6 @@ """Coordinator for WS66i.""" from __future__ import annotations -from datetime import timedelta import logging from pyws66i import WS66i, ZoneStatus @@ -9,12 +8,12 @@ from pyws66i import WS66i, ZoneStatus from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from .const import POLL_INTERVAL + _LOGGER = logging.getLogger(__name__) -POLL_INTERVAL = timedelta(seconds=30) - -class Ws66iDataUpdateCoordinator(DataUpdateCoordinator): +class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): """DataUpdateCoordinator to gather data for WS66i Zones.""" def __init__( @@ -43,11 +42,9 @@ class Ws66iDataUpdateCoordinator(DataUpdateCoordinator): data.append(data_zone) - # HA will call my entity's _handle_coordinator_update() return data async def _async_update_data(self) -> list[ZoneStatus]: """Fetch data for each of the zones.""" - # HA will call my entity's _handle_coordinator_update() - # The data I pass back here can be accessed through coordinator.data. + # The data that is returned here can be accessed through coordinator.data. return await self.hass.async_add_executor_job(self._update_all_zones) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index c0e62fe773c..7cd897e9c1a 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -1,6 +1,4 @@ """Support for interfacing with WS66i 6 zone home audio controller.""" -from copy import deepcopy - from pyws66i import WS66i, ZoneStatus from homeassistant.components.media_player import ( @@ -10,22 +8,16 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import ( - AddEntitiesCallback, - async_get_current_platform, -) +from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SERVICE_RESTORE, SERVICE_SNAPSHOT +from .const import DOMAIN, MAX_VOL from .coordinator import Ws66iDataUpdateCoordinator from .models import Ws66iData PARALLEL_UPDATES = 1 -MAX_VOL = 38 - async def async_setup_entry( hass: HomeAssistant, @@ -48,23 +40,8 @@ async def async_setup_entry( for idx, zone_id in enumerate(ws66i_data.zones) ) - # Set up services - platform = async_get_current_platform() - platform.async_register_entity_service( - SERVICE_SNAPSHOT, - {}, - "snapshot", - ) - - platform.async_register_entity_service( - SERVICE_RESTORE, - {}, - "async_restore", - ) - - -class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): +class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity): """Representation of a WS66i amplifier zone.""" def __init__( @@ -82,8 +59,6 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): self._ws66i_data: Ws66iData = ws66i_data self._zone_id: int = zone_id self._zone_id_idx: int = data_idx - self._coordinator = coordinator - self._snapshot: ZoneStatus = None self._status: ZoneStatus = coordinator.data[data_idx] self._attr_source_list = ws66i_data.sources.name_list self._attr_unique_id = f"{entry_id}_{self._zone_id}" @@ -131,20 +106,6 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): self._set_attrs_from_status() self.async_write_ha_state() - @callback - def snapshot(self): - """Save zone's current state.""" - self._snapshot = deepcopy(self._status) - - async def async_restore(self): - """Restore saved state.""" - if not self._snapshot: - raise HomeAssistantError("There is no snapshot to restore") - - await self.hass.async_add_executor_job(self._ws66i.restore_zone, self._snapshot) - self._status = self._snapshot - self._async_update_attrs_write_ha_state() - async def async_select_source(self, source): """Set input source.""" idx = self._ws66i_data.sources.name_id[source] @@ -180,24 +141,30 @@ class Ws66iZone(CoordinatorEntity, MediaPlayerEntity): async def async_set_volume_level(self, volume): """Set volume level, range 0..1.""" - await self.hass.async_add_executor_job( - self._ws66i.set_volume, self._zone_id, int(volume * MAX_VOL) - ) - self._status.volume = int(volume * MAX_VOL) + await self.hass.async_add_executor_job(self._set_volume, int(volume * MAX_VOL)) self._async_update_attrs_write_ha_state() async def async_volume_up(self): """Volume up the media player.""" await self.hass.async_add_executor_job( - self._ws66i.set_volume, self._zone_id, min(self._status.volume + 1, MAX_VOL) + self._set_volume, min(self._status.volume + 1, MAX_VOL) ) - self._status.volume = min(self._status.volume + 1, MAX_VOL) self._async_update_attrs_write_ha_state() async def async_volume_down(self): """Volume down media player.""" await self.hass.async_add_executor_job( - self._ws66i.set_volume, self._zone_id, max(self._status.volume - 1, 0) + self._set_volume, max(self._status.volume - 1, 0) ) - self._status.volume = max(self._status.volume - 1, 0) self._async_update_attrs_write_ha_state() + + def _set_volume(self, volume: int) -> None: + """Set the volume of the media player.""" + # Can't set a new volume level when this zone is muted. + # Follow behavior of keypads, where zone is unmuted when volume changes. + if self._status.mute: + self._ws66i.set_mute(self._zone_id, False) + self._status.mute = False + + self._ws66i.set_volume(self._zone_id, volume) + self._status.volume = volume diff --git a/homeassistant/components/ws66i/models.py b/homeassistant/components/ws66i/models.py index d84ee56a4a1..84f481b9a4a 100644 --- a/homeassistant/components/ws66i/models.py +++ b/homeassistant/components/ws66i/models.py @@ -7,8 +7,6 @@ from pyws66i import WS66i from .coordinator import Ws66iDataUpdateCoordinator -# A dataclass is basically a struct in C/C++ - @dataclass class SourceRep: diff --git a/homeassistant/components/ws66i/services.yaml b/homeassistant/components/ws66i/services.yaml deleted file mode 100644 index cedd1d3546a..00000000000 --- a/homeassistant/components/ws66i/services.yaml +++ /dev/null @@ -1,15 +0,0 @@ -snapshot: - name: Snapshot - description: Take a snapshot of the media player zone. - target: - entity: - integration: ws66i - domain: media_player - -restore: - name: Restore - description: Restore a snapshot of the media player zone. - target: - entity: - integration: ws66i - domain: media_player diff --git a/homeassistant/components/ws66i/strings.json b/homeassistant/components/ws66i/strings.json index fcfa64d7e22..ec5bc621a89 100644 --- a/homeassistant/components/ws66i/strings.json +++ b/homeassistant/components/ws66i/strings.json @@ -11,9 +11,6 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } }, "options": { diff --git a/homeassistant/components/ws66i/translations/en.json b/homeassistant/components/ws66i/translations/en.json index 30ef1e4205a..fd4b170b378 100644 --- a/homeassistant/components/ws66i/translations/en.json +++ b/homeassistant/components/ws66i/translations/en.json @@ -1,8 +1,5 @@ { "config": { - "abort": { - "already_configured": "Device is already configured" - }, "error": { "cannot_connect": "Failed to connect", "unknown": "Unexpected error" diff --git a/tests/components/ws66i/test_config_flow.py b/tests/components/ws66i/test_config_flow.py index d426e62c012..4fe3554941d 100644 --- a/tests/components/ws66i/test_config_flow.py +++ b/tests/components/ws66i/test_config_flow.py @@ -1,7 +1,7 @@ """Test the WS66i 6-Zone Amplifier config flow.""" from unittest.mock import patch -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import config_entries, data_entry_flow from homeassistant.components.ws66i.const import ( CONF_SOURCE_1, CONF_SOURCE_2, @@ -15,15 +15,15 @@ from homeassistant.components.ws66i.const import ( ) from homeassistant.const import CONF_IP_ADDRESS +from .test_media_player import AttrDict + from tests.common import MockConfigEntry -from tests.components.ws66i.test_media_player import AttrDict CONFIG = {CONF_IP_ADDRESS: "1.1.1.1"} async def test_form(hass): """Test we get the form.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/ws66i/test_init.py b/tests/components/ws66i/test_init.py new file mode 100644 index 00000000000..557c53e97aa --- /dev/null +++ b/tests/components/ws66i/test_init.py @@ -0,0 +1,80 @@ +"""Test the WS66i 6-Zone Amplifier init file.""" +from unittest.mock import patch + +from homeassistant.components.ws66i.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState + +from .test_media_player import ( + MOCK_CONFIG, + MOCK_DEFAULT_OPTIONS, + MOCK_OPTIONS, + MockWs66i, +) + +from tests.common import MockConfigEntry + +ZONE_1_ID = "media_player.zone_11" + + +async def test_cannot_connect(hass): + """Test connection error.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ws66i.get_ws66i", + new=lambda *a: MockWs66i(fail_open=True), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert hass.states.get(ZONE_1_ID) is None + + +async def test_cannot_connect_2(hass): + """Test connection error pt 2.""" + # Another way to test same case as test_cannot_connect + ws66i = MockWs66i() + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_DEFAULT_OPTIONS + ) + config_entry.add_to_hass(hass) + + with patch.object(MockWs66i, "open", side_effect=ConnectionError): + with patch( + "homeassistant.components.ws66i.get_ws66i", + new=lambda *a: ws66i, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert hass.states.get(ZONE_1_ID) is None + + +async def test_unload_config_entry(hass): + """Test unloading config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.ws66i.get_ws66i", + new=lambda *a: MockWs66i(), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert hass.data[DOMAIN][config_entry.entry_id] + + with patch.object(MockWs66i, "close") as method_call: + await config_entry.async_unload(hass) + await hass.async_block_till_done() + + assert method_call.called + + assert not hass.data[DOMAIN] diff --git a/tests/components/ws66i/test_media_player.py b/tests/components/ws66i/test_media_player.py index 6fc1e00d827..fbe6a7b2782 100644 --- a/tests/components/ws66i/test_media_player.py +++ b/tests/components/ws66i/test_media_player.py @@ -2,28 +2,22 @@ from collections import defaultdict from unittest.mock import patch -import pytest - +from homeassistant.components.media_player import MediaPlayerEntityFeature from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE, - SUPPORT_SELECT_SOURCE, - SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, - SUPPORT_VOLUME_SET, - SUPPORT_VOLUME_STEP, ) from homeassistant.components.ws66i.const import ( CONF_SOURCES, DOMAIN, INIT_OPTIONS_DEFAULT, - SERVICE_RESTORE, - SERVICE_SNAPSHOT, + MAX_VOL, + POLL_INTERVAL, ) +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( CONF_IP_ADDRESS, SERVICE_TURN_OFF, @@ -35,10 +29,10 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, ) -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed MOCK_SOURCE_DIC = { "1": "one", @@ -125,47 +119,52 @@ class MockWs66i: async def test_setup_success(hass): """Test connection success.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + with patch( "homeassistant.components.ws66i.get_ws66i", new=lambda *a: MockWs66i(), ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert hass.states.get(ZONE_1_ID) is not None + + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.get(ZONE_1_ID) is not None async def _setup_ws66i(hass, ws66i) -> MockConfigEntry: + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_DEFAULT_OPTIONS + ) + config_entry.add_to_hass(hass) + with patch( "homeassistant.components.ws66i.get_ws66i", new=lambda *a: ws66i, ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_DEFAULT_OPTIONS - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return config_entry + return config_entry async def _setup_ws66i_with_options(hass, ws66i) -> MockConfigEntry: + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS + ) + config_entry.add_to_hass(hass) + with patch( "homeassistant.components.ws66i.get_ws66i", new=lambda *a: ws66i, ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS - ) - config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - return config_entry + return config_entry async def _call_media_player_service(hass, name, data): @@ -174,172 +173,10 @@ async def _call_media_player_service(hass, name, data): ) -async def _call_ws66i_service(hass, name, data): - await hass.services.async_call(DOMAIN, name, service_data=data, blocking=True) - - -async def test_cannot_connect(hass): - """Test connection error.""" - with patch( - "homeassistant.components.ws66i.get_ws66i", - new=lambda *a: MockWs66i(fail_open=True), - ): - config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert hass.states.get(ZONE_1_ID) is None - - -async def test_cannot_connect_2(hass): - """Test connection error pt 2.""" - # Another way to test same case as test_cannot_connect - ws66i = MockWs66i() - - with patch.object(MockWs66i, "open", side_effect=ConnectionError): - await _setup_ws66i(hass, ws66i) - assert hass.states.get(ZONE_1_ID) is None - - -async def test_service_calls_with_entity_id(hass): - """Test snapshot save/restore service calls.""" - _ = await _setup_ws66i_with_options(hass, MockWs66i()) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - # Saving existing values - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": ZONE_1_ID}) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"} - ) - await hass.async_block_till_done() - - # Restoring other media player to its previous state - # The zone should not be restored - with pytest.raises(HomeAssistantError): - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_2_ID}) - await hass.async_block_till_done() - - # Checking that values were not (!) restored - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 1.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "three" - - # Restoring media player to its previous state - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" - - -async def test_service_calls_with_all_entities(hass): - """Test snapshot save/restore service calls with entity id all.""" - _ = await _setup_ws66i_with_options(hass, MockWs66i()) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - # Saving existing values - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": "all"}) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"} - ) - - # await coordinator.async_refresh() - # await hass.async_block_till_done() - - # Restoring media player to its previous state - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": "all"}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" - - -async def test_service_calls_without_relevant_entities(hass): - """Test snapshot save/restore service calls with bad entity id.""" - config_entry = await _setup_ws66i_with_options(hass, MockWs66i()) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - await coordinator.async_refresh() - await hass.async_block_till_done() - - # Saving existing values - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": "all"}) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "three"} - ) - - await coordinator.async_refresh() - await hass.async_block_till_done() - - # Restoring media player to its previous state - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": "light.demo"}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 1.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "three" - - -async def test_restore_without_snapshot(hass): - """Test restore when snapshot wasn't called.""" - await _setup_ws66i(hass, MockWs66i()) - - with patch.object(MockWs66i, "restore_zone") as method_call: - with pytest.raises(HomeAssistantError): - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() - - assert not method_call.called - - async def test_update(hass): """Test updating values from ws66i.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) + _ = await _setup_ws66i_with_options(hass, ws66i) # Changing media player to new state await _call_media_player_service( @@ -350,13 +187,10 @@ async def test_update(hass): ) ws66i.set_source(11, 3) - ws66i.set_volume(11, 38) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator + ws66i.set_volume(11, MAX_VOL) with patch.object(MockWs66i, "open") as method_call: - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() assert not method_call.called @@ -371,7 +205,7 @@ async def test_update(hass): async def test_failed_update(hass): """Test updating failure from ws66i.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) + _ = await _setup_ws66i_with_options(hass, ws66i) # Changing media player to new state await _call_media_player_service( @@ -382,26 +216,25 @@ async def test_failed_update(hass): ) ws66i.set_source(11, 3) - ws66i.set_volume(11, 38) - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - await coordinator.async_refresh() + ws66i.set_volume(11, MAX_VOL) + + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # Failed update, close called with patch.object(MockWs66i, "zone_status", return_value=None): - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() assert hass.states.is_state(ZONE_1_ID, STATE_UNAVAILABLE) # A connection re-attempt fails with patch.object(MockWs66i, "zone_status", return_value=None): - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # A connection re-attempt succeeds - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # confirm entity is back on @@ -418,12 +251,12 @@ async def test_supported_features(hass): state = hass.states.get(ZONE_1_ID) assert ( - SUPPORT_VOLUME_MUTE - | SUPPORT_VOLUME_SET - | SUPPORT_VOLUME_STEP - | SUPPORT_TURN_ON - | SUPPORT_TURN_OFF - | SUPPORT_SELECT_SOURCE + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE == state.attributes["supported_features"] ) @@ -462,15 +295,13 @@ async def test_select_source(hass): async def test_source_select(hass): - """Test behavior when device has unknown source.""" + """Test source selection simulated from keypad.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) + _ = await _setup_ws66i_with_options(hass, ws66i) ws66i.set_source(11, 5) - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() state = hass.states.get(ZONE_1_ID) @@ -512,10 +343,7 @@ async def test_mute_volume(hass): async def test_volume_up_down(hass): """Test increasing volume by one.""" ws66i = MockWs66i() - config_entry = await _setup_ws66i(hass, ws66i) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator + _ = await _setup_ws66i(hass, ws66i) await _call_media_player_service( hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} @@ -525,34 +353,89 @@ async def test_volume_up_down(hass): await _call_media_player_service( hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} ) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() # should not go below zero assert ws66i.zones[11].volume == 0 await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() assert ws66i.zones[11].volume == 1 await _call_media_player_service( hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} ) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() - assert ws66i.zones[11].volume == 38 + assert ws66i.zones[11].volume == MAX_VOL await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) - await coordinator.async_refresh() + async_fire_time_changed(hass, utcnow() + POLL_INTERVAL) await hass.async_block_till_done() - # should not go above 38 - assert ws66i.zones[11].volume == 38 + # should not go above 38 (MAX_VOL) + assert ws66i.zones[11].volume == MAX_VOL await _call_media_player_service( hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} ) - assert ws66i.zones[11].volume == 37 + assert ws66i.zones[11].volume == MAX_VOL - 1 + + +async def test_volume_while_mute(hass): + """Test increasing volume by one.""" + ws66i = MockWs66i() + _ = await _setup_ws66i(hass, ws66i) + + # Set vol to a known value + await _call_media_player_service( + hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} + ) + assert ws66i.zones[11].volume == 0 + + # Set mute to a known value, False + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": False} + ) + assert not ws66i.zones[11].mute + + # Mute the zone + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": True} + ) + assert ws66i.zones[11].mute + + # Increase volume. Mute state should go back to unmutted + await _call_media_player_service(hass, SERVICE_VOLUME_UP, {"entity_id": ZONE_1_ID}) + assert ws66i.zones[11].volume == 1 + assert not ws66i.zones[11].mute + + # Mute the zone again + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": True} + ) + assert ws66i.zones[11].mute + + # Decrease volume. Mute state should go back to unmutted + await _call_media_player_service( + hass, SERVICE_VOLUME_DOWN, {"entity_id": ZONE_1_ID} + ) + assert ws66i.zones[11].volume == 0 + assert not ws66i.zones[11].mute + + # Mute the zone again + await _call_media_player_service( + hass, SERVICE_VOLUME_MUTE, {"entity_id": ZONE_1_ID, "is_volume_muted": True} + ) + assert ws66i.zones[11].mute + + # Set to max volume. Mute state should go back to unmutted + await _call_media_player_service( + hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} + ) + assert ws66i.zones[11].volume == MAX_VOL + assert not ws66i.zones[11].mute async def test_first_run_with_available_zones(hass): @@ -611,82 +494,3 @@ async def test_register_entities_in_1_amp_only(hass): entry = registry.async_get(ZONE_7_ID) assert entry is None - - -async def test_unload_config_entry(hass): - """Test unloading config entry.""" - with patch( - "homeassistant.components.ws66i.get_ws66i", - new=lambda *a: MockWs66i(), - ): - config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS - ) - config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert hass.data[DOMAIN][config_entry.entry_id] - - with patch.object(MockWs66i, "close") as method_call: - await config_entry.async_unload(hass) - await hass.async_block_till_done() - - assert method_call.called - - assert not hass.data[DOMAIN] - - -async def test_restore_snapshot_on_reconnect(hass): - """Test restoring a saved snapshot when reconnecting to amp.""" - ws66i = MockWs66i() - config_entry = await _setup_ws66i_with_options(hass, ws66i) - - # Changing media player to new state - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 0.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "one"} - ) - - # Save a snapshot - await _call_ws66i_service(hass, SERVICE_SNAPSHOT, {"entity_id": ZONE_1_ID}) - - ws66i_data = hass.data[DOMAIN][config_entry.entry_id] - coordinator = ws66i_data.coordinator - - # Failed update, - with patch.object(MockWs66i, "zone_status", return_value=None): - await coordinator.async_refresh() - await hass.async_block_till_done() - - assert hass.states.is_state(ZONE_1_ID, STATE_UNAVAILABLE) - - # A connection re-attempt succeeds - await coordinator.async_refresh() - await hass.async_block_till_done() - - # confirm entity is back on - state = hass.states.get(ZONE_1_ID) - - assert hass.states.is_state(ZONE_1_ID, STATE_ON) - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" - - # Change states - await _call_media_player_service( - hass, SERVICE_VOLUME_SET, {"entity_id": ZONE_1_ID, "volume_level": 1.0} - ) - await _call_media_player_service( - hass, SERVICE_SELECT_SOURCE, {"entity_id": ZONE_1_ID, "source": "six"} - ) - - # Now confirm that the snapshot before the disconnect works - await _call_ws66i_service(hass, SERVICE_RESTORE, {"entity_id": ZONE_1_ID}) - await hass.async_block_till_done() - - state = hass.states.get(ZONE_1_ID) - - assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.0 - assert state.attributes[ATTR_INPUT_SOURCE] == "one" From 7ff1b53d4fadc488fcfcfd2b631c8bbc2148b8e7 Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 30 May 2022 02:54:23 +0800 Subject: [PATCH 069/947] Fix yolink device unavailable on startup (#72579) * fetch device state on startup * Suggest change * suggest fix * fix * fix * Fix suggest * suggest fix --- homeassistant/components/yolink/__init__.py | 68 ++++++++++++--- .../components/yolink/binary_sensor.py | 44 ++++++---- homeassistant/components/yolink/const.py | 2 +- .../components/yolink/coordinator.py | 86 +++---------------- homeassistant/components/yolink/entity.py | 20 +++-- homeassistant/components/yolink/sensor.py | 38 ++++---- homeassistant/components/yolink/siren.py | 39 +++++---- homeassistant/components/yolink/switch.py | 41 +++++---- 8 files changed, 177 insertions(+), 161 deletions(-) diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 2c85344c54b..7eb6b0229f0 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -1,25 +1,28 @@ """The yolink integration.""" from __future__ import annotations +import asyncio from datetime import timedelta -import logging +import async_timeout from yolink.client import YoLinkClient +from yolink.device import YoLinkDevice +from yolink.exception import YoLinkAuthFailError, YoLinkClientError +from yolink.model import BRDP from yolink.mqtt_client import MqttClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow from . import api -from .const import ATTR_CLIENT, ATTR_COORDINATOR, ATTR_MQTT_CLIENT, DOMAIN +from .const import ATTR_CLIENT, ATTR_COORDINATORS, ATTR_DEVICE, ATTR_MQTT_CLIENT, DOMAIN from .coordinator import YoLinkCoordinator SCAN_INTERVAL = timedelta(minutes=5) -_LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SIREN, Platform.SWITCH] @@ -41,18 +44,63 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: yolink_http_client = YoLinkClient(auth_mgr) yolink_mqtt_client = MqttClient(auth_mgr) - coordinator = YoLinkCoordinator(hass, yolink_http_client, yolink_mqtt_client) - await coordinator.init_coordinator() + + def on_message_callback(message: tuple[str, BRDP]) -> None: + data = message[1] + device_id = message[0] + if data.event is None: + return + event_param = data.event.split(".") + event_type = event_param[len(event_param) - 1] + if event_type not in ( + "Report", + "Alert", + "StatusChange", + "getState", + ): + return + resolved_state = data.data + if resolved_state is None: + return + entry_data = hass.data[DOMAIN].get(entry.entry_id) + if entry_data is None: + return + device_coordinators = entry_data.get(ATTR_COORDINATORS) + if device_coordinators is None: + return + device_coordinator = device_coordinators.get(device_id) + if device_coordinator is None: + return + device_coordinator.async_set_updated_data(resolved_state) + try: - await coordinator.async_config_entry_first_refresh() - except ConfigEntryNotReady as ex: - _LOGGER.error("Fetching initial data failed: %s", ex) + async with async_timeout.timeout(10): + device_response = await yolink_http_client.get_auth_devices() + home_info = await yolink_http_client.get_general_info() + await yolink_mqtt_client.init_home_connection( + home_info.data["id"], on_message_callback + ) + except YoLinkAuthFailError as yl_auth_err: + raise ConfigEntryAuthFailed from yl_auth_err + except (YoLinkClientError, asyncio.TimeoutError) as err: + raise ConfigEntryNotReady from err hass.data[DOMAIN][entry.entry_id] = { ATTR_CLIENT: yolink_http_client, ATTR_MQTT_CLIENT: yolink_mqtt_client, - ATTR_COORDINATOR: coordinator, } + auth_devices = device_response.data[ATTR_DEVICE] + device_coordinators = {} + for device_info in auth_devices: + device = YoLinkDevice(device_info, yolink_http_client) + device_coordinator = YoLinkCoordinator(hass, device) + try: + await device_coordinator.async_config_entry_first_refresh() + except ConfigEntryNotReady: + # Not failure by fetching device state + device_coordinator.data = {} + device_coordinators[device.device_id] = device_coordinator + hass.data[DOMAIN][entry.entry_id][ATTR_COORDINATORS] = device_coordinators hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 42899e08a2c..cacba484fe9 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from yolink.device import YoLinkDevice @@ -16,7 +17,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - ATTR_COORDINATOR, + ATTR_COORDINATORS, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, @@ -32,7 +33,7 @@ class YoLinkBinarySensorEntityDescription(BinarySensorEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True state_key: str = "state" - value: Callable[[str], bool | None] = lambda _: None + value: Callable[[Any], bool | None] = lambda _: None SENSOR_DEVICE_TYPE = [ @@ -47,14 +48,14 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( icon="mdi:door", device_class=BinarySensorDeviceClass.DOOR, name="State", - value=lambda value: value == "open", + value=lambda value: value == "open" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR], ), YoLinkBinarySensorEntityDescription( key="motion_state", device_class=BinarySensorDeviceClass.MOTION, name="Motion", - value=lambda value: value == "alert", + value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_MOTION_SENSOR], ), YoLinkBinarySensorEntityDescription( @@ -62,7 +63,7 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( name="Leak", icon="mdi:water", device_class=BinarySensorDeviceClass.MOISTURE, - value=lambda value: value == "alert", + value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_LEAK_SENSOR], ), ) @@ -74,18 +75,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - sensor_devices = [ - device - for device in coordinator.yl_devices - if device.device_type in SENSOR_DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + binary_sensor_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in SENSOR_DEVICE_TYPE ] entities = [] - for sensor_device in sensor_devices: + for binary_sensor_device_coordinator in binary_sensor_device_coordinators: for description in SENSOR_TYPES: - if description.exists_fn(sensor_device): + if description.exists_fn(binary_sensor_device_coordinator.device): entities.append( - YoLinkBinarySensorEntity(coordinator, description, sensor_device) + YoLinkBinarySensorEntity( + binary_sensor_device_coordinator, description + ) ) async_add_entities(entities) @@ -99,18 +102,21 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): self, coordinator: YoLinkCoordinator, description: YoLinkBinarySensorEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) @callback - def update_entity_state(self, state: dict) -> None: + def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state[self.entity_description.state_key] + state.get(self.entity_description.state_key) ) self.async_write_ha_state() diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 00d6d6d028e..97252c5c989 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -5,7 +5,7 @@ MANUFACTURER = "YoLink" HOME_ID = "homeId" HOME_SUBSCRIPTION = "home_subscription" ATTR_PLATFORM_SENSOR = "sensor" -ATTR_COORDINATOR = "coordinator" +ATTR_COORDINATORS = "coordinators" ATTR_DEVICE = "devices" ATTR_DEVICE_TYPE = "type" ATTR_DEVICE_NAME = "name" diff --git a/homeassistant/components/yolink/coordinator.py b/homeassistant/components/yolink/coordinator.py index e5578eae4b2..68a1aef42f7 100644 --- a/homeassistant/components/yolink/coordinator.py +++ b/homeassistant/components/yolink/coordinator.py @@ -1,22 +1,18 @@ """YoLink DataUpdateCoordinator.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging import async_timeout -from yolink.client import YoLinkClient from yolink.device import YoLinkDevice from yolink.exception import YoLinkAuthFailError, YoLinkClientError -from yolink.model import BRDP -from yolink.mqtt_client import MqttClient from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import ATTR_DEVICE, ATTR_DEVICE_STATE, DOMAIN +from .const import ATTR_DEVICE_STATE, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -24,9 +20,7 @@ _LOGGER = logging.getLogger(__name__) class YoLinkCoordinator(DataUpdateCoordinator[dict]): """YoLink DataUpdateCoordinator.""" - def __init__( - self, hass: HomeAssistant, yl_client: YoLinkClient, yl_mqtt_client: MqttClient - ) -> None: + def __init__(self, hass: HomeAssistant, device: YoLinkDevice) -> None: """Init YoLink DataUpdateCoordinator. fetch state every 30 minutes base on yolink device heartbeat interval @@ -35,75 +29,17 @@ class YoLinkCoordinator(DataUpdateCoordinator[dict]): super().__init__( hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=30) ) - self._client = yl_client - self._mqtt_client = yl_mqtt_client - self.yl_devices: list[YoLinkDevice] = [] - self.data = {} + self.device = device - def on_message_callback(self, message: tuple[str, BRDP]): - """On message callback.""" - data = message[1] - if data.event is None: - return - event_param = data.event.split(".") - event_type = event_param[len(event_param) - 1] - if event_type not in ( - "Report", - "Alert", - "StatusChange", - "getState", - ): - return - resolved_state = data.data - if resolved_state is None: - return - self.data[message[0]] = resolved_state - self.async_set_updated_data(self.data) - - async def init_coordinator(self): - """Init coordinator.""" + async def _async_update_data(self) -> dict: + """Fetch device state.""" try: async with async_timeout.timeout(10): - home_info = await self._client.get_general_info() - await self._mqtt_client.init_home_connection( - home_info.data["id"], self.on_message_callback - ) - async with async_timeout.timeout(10): - device_response = await self._client.get_auth_devices() - - except YoLinkAuthFailError as yl_auth_err: - raise ConfigEntryAuthFailed from yl_auth_err - - except (YoLinkClientError, asyncio.TimeoutError) as err: - raise ConfigEntryNotReady from err - - yl_devices: list[YoLinkDevice] = [] - - for device_info in device_response.data[ATTR_DEVICE]: - yl_devices.append(YoLinkDevice(device_info, self._client)) - - self.yl_devices = yl_devices - - async def fetch_device_state(self, device: YoLinkDevice): - """Fetch Device State.""" - try: - async with async_timeout.timeout(10): - device_state_resp = await device.fetch_state_with_api() - if ATTR_DEVICE_STATE in device_state_resp.data: - self.data[device.device_id] = device_state_resp.data[ - ATTR_DEVICE_STATE - ] + device_state_resp = await self.device.fetch_state_with_api() except YoLinkAuthFailError as yl_auth_err: raise ConfigEntryAuthFailed from yl_auth_err except YoLinkClientError as yl_client_err: - raise UpdateFailed( - f"Error communicating with API: {yl_client_err}" - ) from yl_client_err - - async def _async_update_data(self) -> dict: - fetch_tasks = [] - for yl_device in self.yl_devices: - fetch_tasks.append(self.fetch_device_state(yl_device)) - if fetch_tasks: - await asyncio.gather(*fetch_tasks) - return self.data + raise UpdateFailed from yl_client_err + if ATTR_DEVICE_STATE in device_state_resp.data: + return device_state_resp.data[ATTR_DEVICE_STATE] + return {} diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 6954b117728..5365681739e 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -3,8 +3,6 @@ from __future__ import annotations from abc import abstractmethod -from yolink.device import YoLinkDevice - from homeassistant.core import callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -19,20 +17,24 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def __init__( self, coordinator: YoLinkCoordinator, - device_info: YoLinkDevice, ) -> None: """Init YoLink Entity.""" super().__init__(coordinator) - self.device = device_info @property def device_id(self) -> str: """Return the device id of the YoLink device.""" - return self.device.device_id + return self.coordinator.device.device_id + + async def async_added_to_hass(self) -> None: + """Update state.""" + await super().async_added_to_hass() + return self._handle_coordinator_update() @callback def _handle_coordinator_update(self) -> None: - data = self.coordinator.data.get(self.device.device_id) + """Update state.""" + data = self.coordinator.data if data is not None: self.update_entity_state(data) @@ -40,10 +42,10 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def device_info(self) -> DeviceInfo: """Return the device info for HA.""" return DeviceInfo( - identifiers={(DOMAIN, self.device.device_id)}, + identifiers={(DOMAIN, self.coordinator.device.device_id)}, manufacturer=MANUFACTURER, - model=self.device.device_type, - name=self.device.device_name, + model=self.coordinator.device.device_type, + name=self.coordinator.device.device_name, ) @callback diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index e33772c24be..463d8b14da4 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import percentage from .const import ( - ATTR_COORDINATOR, + ATTR_COORDINATORS, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, @@ -54,7 +54,9 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, value=lambda value: percentage.ordered_list_item_to_percentage( [1, 2, 3, 4], value - ), + ) + if value is not None + else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_MOTION_SENSOR], ), @@ -89,18 +91,21 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - sensor_devices = [ - device - for device in coordinator.yl_devices - if device.device_type in SENSOR_DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + sensor_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in SENSOR_DEVICE_TYPE ] entities = [] - for sensor_device in sensor_devices: + for sensor_device_coordinator in sensor_device_coordinators: for description in SENSOR_TYPES: - if description.exists_fn(sensor_device): + if description.exists_fn(sensor_device_coordinator.device): entities.append( - YoLinkSensorEntity(coordinator, description, sensor_device) + YoLinkSensorEntity( + sensor_device_coordinator, + description, + ) ) async_add_entities(entities) @@ -114,18 +119,21 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): self, coordinator: YoLinkCoordinator, description: YoLinkSensorEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) @callback def update_entity_state(self, state: dict) -> None: """Update HA Entity State.""" self._attr_native_value = self.entity_description.value( - state[self.entity_description.key] + state.get(self.entity_description.key) ) self.async_write_ha_state() diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 7a621db6eca..7e67dfb12f1 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_COORDINATOR, ATTR_DEVICE_SIREN, DOMAIN +from .const import ATTR_COORDINATORS, ATTR_DEVICE_SIREN, DOMAIN from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -28,14 +28,14 @@ class YoLinkSirenEntityDescription(SirenEntityDescription): """YoLink SirenEntityDescription.""" exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - value: Callable[[str], bool | None] = lambda _: None + value: Callable[[Any], bool | None] = lambda _: None DEVICE_TYPES: tuple[YoLinkSirenEntityDescription, ...] = ( YoLinkSirenEntityDescription( key="state", name="State", - value=lambda value: value == "alert", + value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_SIREN], ), ) @@ -49,16 +49,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink siren from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - devices = [ - device for device in coordinator.yl_devices if device.device_type in DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + siren_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in DEVICE_TYPE ] entities = [] - for device in devices: + for siren_device_coordinator in siren_device_coordinators: for description in DEVICE_TYPES: - if description.exists_fn(device): + if description.exists_fn(siren_device_coordinator.device): entities.append( - YoLinkSirenEntity(config_entry, coordinator, description, device) + YoLinkSirenEntity( + config_entry, siren_device_coordinator, description + ) ) async_add_entities(entities) @@ -73,23 +77,26 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSirenEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Siren.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.config_entry = config_entry self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) self._attr_supported_features = ( SirenEntityFeature.TURN_ON | SirenEntityFeature.TURN_OFF ) @callback - def update_entity_state(self, state: dict) -> None: + def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state[self.entity_description.key] + state.get(self.entity_description.key) ) self.async_write_ha_state() @@ -97,7 +104,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): """Call setState api to change siren state.""" try: # call_device_http_api will check result, fail by raise YoLinkClientError - await self.device.call_device_http_api( + await self.coordinator.device.call_device_http_api( "setState", {"state": {"alarm": state}} ) except YoLinkAuthFailError as yl_auth_err: diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index b3756efb74c..f16dc781a9c 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_COORDINATOR, ATTR_DEVICE_OUTLET, DOMAIN +from .const import ATTR_COORDINATORS, ATTR_DEVICE_OUTLET, DOMAIN from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -28,7 +28,7 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription): """YoLink SwitchEntityDescription.""" exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True - value: Callable[[str], bool | None] = lambda _: None + value: Callable[[Any], bool | None] = lambda _: None DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( @@ -36,7 +36,7 @@ DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( key="state", device_class=SwitchDeviceClass.OUTLET, name="State", - value=lambda value: value == "open", + value=lambda value: value == "open" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_OUTLET], ), ) @@ -50,16 +50,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up YoLink Sensor from a config entry.""" - coordinator = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATOR] - devices = [ - device for device in coordinator.yl_devices if device.device_type in DEVICE_TYPE + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + switch_device_coordinators = [ + device_coordinator + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type in DEVICE_TYPE ] entities = [] - for device in devices: + for switch_device_coordinator in switch_device_coordinators: for description in DEVICE_TYPES: - if description.exists_fn(device): + if description.exists_fn(switch_device_coordinator.device): entities.append( - YoLinkSwitchEntity(config_entry, coordinator, description, device) + YoLinkSwitchEntity( + config_entry, switch_device_coordinator, description + ) ) async_add_entities(entities) @@ -74,20 +78,23 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSwitchEntityDescription, - device: YoLinkDevice, ) -> None: """Init YoLink Outlet.""" - super().__init__(coordinator, device) + super().__init__(coordinator) self.config_entry = config_entry self.entity_description = description - self._attr_unique_id = f"{device.device_id} {self.entity_description.key}" - self._attr_name = f"{device.device_name} ({self.entity_description.name})" + self._attr_unique_id = ( + f"{coordinator.device.device_id} {self.entity_description.key}" + ) + self._attr_name = ( + f"{coordinator.device.device_name} ({self.entity_description.name})" + ) @callback - def update_entity_state(self, state: dict) -> None: + def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state[self.entity_description.key] + state.get(self.entity_description.key) ) self.async_write_ha_state() @@ -95,7 +102,9 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): """Call setState api to change outlet state.""" try: # call_device_http_api will check result, fail by raise YoLinkClientError - await self.device.call_device_http_api("setState", {"state": state}) + await self.coordinator.device.call_device_http_api( + "setState", {"state": state} + ) except YoLinkAuthFailError as yl_auth_err: self.config_entry.async_start_reauth(self.hass) raise HomeAssistantError(yl_auth_err) from yl_auth_err From 1ed7e226c6d331d6b22abb57f46d57a8f4a28900 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Sun, 29 May 2022 20:57:47 +0200 Subject: [PATCH 070/947] Address late review comments for Tankerkoenig (#72672) * address late review comment from #72654 * use entry_id instead of unique_id * remove not needed `_hass` property * fix skiping failing stations * remove not neccessary error log * set DeviceEntryType.SERVICE * fix use entry_id instead of unique_id * apply suggestions on tests * add return value also to other tests * invert data check to early return user form --- .../components/tankerkoenig/__init__.py | 45 +++++++++++++------ .../components/tankerkoenig/binary_sensor.py | 18 +++----- .../components/tankerkoenig/config_flow.py | 19 ++++---- .../components/tankerkoenig/sensor.py | 18 ++------ .../tankerkoenig/test_config_flow.py | 9 ++-- 5 files changed, 55 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index 08520c8f5cc..e63add83fad 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + ATTR_ID, CONF_API_KEY, CONF_LATITUDE, CONF_LOCATION, @@ -24,8 +25,14 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, + UpdateFailed, +) from .const import ( CONF_FUEL_TYPES, @@ -109,9 +116,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set a tankerkoenig configuration entry up.""" hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][ - entry.unique_id - ] = coordinator = TankerkoenigDataUpdateCoordinator( + hass.data[DOMAIN][entry.entry_id] = coordinator = TankerkoenigDataUpdateCoordinator( hass, entry, _LOGGER, @@ -140,7 +145,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Tankerkoenig config entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(entry.unique_id) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -172,7 +177,6 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): self._api_key: str = entry.data[CONF_API_KEY] self._selected_stations: list[str] = entry.data[CONF_STATIONS] - self._hass = hass self.stations: dict[str, dict] = {} self.fuel_types: list[str] = entry.data[CONF_FUEL_TYPES] self.show_on_map: bool = entry.options[CONF_SHOW_ON_MAP] @@ -195,7 +199,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): station_id, station_data["message"], ) - return False + continue self.add_station(station_data["station"]) if len(self.stations) > 10: _LOGGER.warning( @@ -215,7 +219,7 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): # The API seems to only return at most 10 results, so split the list in chunks of 10 # and merge it together. for index in range(ceil(len(station_ids) / 10)): - data = await self._hass.async_add_executor_job( + data = await self.hass.async_add_executor_job( pytankerkoenig.getPriceList, self._api_key, station_ids[index * 10 : (index + 1) * 10], @@ -223,13 +227,11 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): _LOGGER.debug("Received data: %s", data) if not data["ok"]: - _LOGGER.error( - "Error fetching data from tankerkoenig.de: %s", data["message"] - ) raise UpdateFailed(data["message"]) if "prices" not in data: - _LOGGER.error("Did not receive price information from tankerkoenig.de") - raise UpdateFailed("No prices in data") + raise UpdateFailed( + "Did not receive price information from tankerkoenig.de" + ) prices.update(data["prices"]) return prices @@ -244,3 +246,20 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): self.stations[station_id] = station _LOGGER.debug("add_station called for station: %s", station) + + +class TankerkoenigCoordinatorEntity(CoordinatorEntity): + """Tankerkoenig base entity.""" + + def __init__( + self, coordinator: TankerkoenigDataUpdateCoordinator, station: dict + ) -> None: + """Initialize the Tankerkoenig base entity.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(ATTR_ID, station["id"])}, + name=f"{station['brand']} {station['street']} {station['houseNumber']}", + model=station["brand"], + configuration_url="https://www.tankerkoenig.de", + entry_type=DeviceEntryType.SERVICE, + ) diff --git a/homeassistant/components/tankerkoenig/binary_sensor.py b/homeassistant/components/tankerkoenig/binary_sensor.py index 9a2b048e0b8..5f10b54f704 100644 --- a/homeassistant/components/tankerkoenig/binary_sensor.py +++ b/homeassistant/components/tankerkoenig/binary_sensor.py @@ -8,13 +8,11 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TankerkoenigDataUpdateCoordinator +from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -25,7 +23,7 @@ async def async_setup_entry( ) -> None: """Set up the tankerkoenig binary sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.unique_id] + coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] stations = coordinator.stations.values() entities = [] @@ -41,7 +39,7 @@ async def async_setup_entry( async_add_entities(entities) -class StationOpenBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): +class StationOpenBinarySensorEntity(TankerkoenigCoordinatorEntity, BinarySensorEntity): """Shows if a station is open or closed.""" _attr_device_class = BinarySensorDeviceClass.DOOR @@ -53,18 +51,12 @@ class StationOpenBinarySensorEntity(CoordinatorEntity, BinarySensorEntity): show_on_map: bool, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, station) self._station_id = station["id"] self._attr_name = ( f"{station['brand']} {station['street']} {station['houseNumber']} status" ) self._attr_unique_id = f"{station['id']}_status" - self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], - configuration_url="https://www.tankerkoenig.de", - ) if show_on_map: self._attr_extra_state_attributes = { ATTR_LATITUDE: station["lat"], diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index af3b5273b16..345b034b027 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Tankerkoenig.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytankerkoenig import customException, getNearbyStations @@ -30,7 +31,7 @@ from .const import CONF_FUEL_TYPES, CONF_STATIONS, DEFAULT_RADIUS, DOMAIN, FUEL_ async def async_get_nearby_stations( - hass: HomeAssistant, data: dict[str, Any] + hass: HomeAssistant, data: Mapping[str, Any] ) -> dict[str, Any]: """Fetch nearby stations.""" try: @@ -114,14 +115,12 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self._show_form_user( user_input, errors={CONF_API_KEY: "invalid_auth"} ) - if stations := data.get("stations"): - for station in stations: - self._stations[ - station["id"] - ] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)" - - else: + if len(stations := data.get("stations", [])) == 0: return self._show_form_user(user_input, errors={CONF_RADIUS: "no_stations"}) + for station in stations: + self._stations[ + station["id"] + ] = f"{station['brand']} {station['street']} {station['houseNumber']} - ({station['dist']}km)" self._data = user_input @@ -180,7 +179,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_RADIUS, default=user_input.get(CONF_RADIUS, DEFAULT_RADIUS) ): NumberSelector( NumberSelectorConfig( - min=0.1, + min=1.0, max=25, step=0.1, unit_of_measurement=LENGTH_KILOMETERS, @@ -224,7 +223,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return self.async_create_entry(title="", data=user_input) nearby_stations = await async_get_nearby_stations( - self.hass, dict(self.config_entry.data) + self.hass, self.config_entry.data ) if stations := nearby_stations.get("stations"): for station in stations: diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index 898a38c3c14..c63b0ea0e7e 100644 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -7,17 +7,14 @@ from homeassistant.components.sensor import SensorEntity, SensorStateClass from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, - ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, CURRENCY_EURO, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import TankerkoenigDataUpdateCoordinator +from . import TankerkoenigCoordinatorEntity, TankerkoenigDataUpdateCoordinator from .const import ( ATTR_BRAND, ATTR_CITY, @@ -39,7 +36,7 @@ async def async_setup_entry( ) -> None: """Set up the tankerkoenig sensors.""" - coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.unique_id] + coordinator: TankerkoenigDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] stations = coordinator.stations.values() entities = [] @@ -62,7 +59,7 @@ async def async_setup_entry( async_add_entities(entities) -class FuelPriceSensor(CoordinatorEntity, SensorEntity): +class FuelPriceSensor(TankerkoenigCoordinatorEntity, SensorEntity): """Contains prices for fuel in a given station.""" _attr_state_class = SensorStateClass.MEASUREMENT @@ -70,19 +67,12 @@ class FuelPriceSensor(CoordinatorEntity, SensorEntity): def __init__(self, fuel_type, station, coordinator, show_on_map): """Initialize the sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, station) self._station_id = station["id"] self._fuel_type = fuel_type self._attr_name = f"{station['brand']} {station['street']} {station['houseNumber']} {FUEL_TYPES[fuel_type]}" self._attr_native_unit_of_measurement = CURRENCY_EURO self._attr_unique_id = f"{station['id']}_{fuel_type}" - self._attr_device_info = DeviceInfo( - identifiers={(ATTR_ID, station["id"])}, - name=f"{station['brand']} {station['street']} {station['houseNumber']}", - model=station["brand"], - configuration_url="https://www.tankerkoenig.de", - ) - attrs = { ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_BRAND: station["brand"], diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index b18df0eed24..f48a09fd64b 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -95,7 +95,7 @@ async def test_user(hass: HomeAssistant): assert result["step_id"] == "user" with patch( - "homeassistant.components.tankerkoenig.async_setup_entry" + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", return_value=MOCK_NEARVY_STATIONS_OK, @@ -147,6 +147,7 @@ async def test_user_already_configured(hass: HomeAssistant): ) assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_exception_security(hass: HomeAssistant): @@ -193,7 +194,7 @@ async def test_user_no_stations(hass: HomeAssistant): async def test_import(hass: HomeAssistant): """Test starting a flow by import.""" with patch( - "homeassistant.components.tankerkoenig.async_setup_entry" + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", return_value=MOCK_NEARVY_STATIONS_OK, @@ -233,12 +234,12 @@ async def test_options_flow(hass: HomeAssistant): mock_config.add_to_hass(hass) with patch( - "homeassistant.components.tankerkoenig.async_setup_entry" + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True ) as mock_setup_entry, patch( "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", return_value=MOCK_NEARVY_STATIONS_OK, ): - await mock_config.async_setup(hass) + await hass.config_entries.async_setup(mock_config.entry_id) await hass.async_block_till_done() assert mock_setup_entry.called From 3c5b778ee3507067d3a27b12c8ee06c48fa48e17 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 30 May 2022 00:27:06 +0000 Subject: [PATCH 071/947] [ci skip] Translation update --- .../components/generic/translations/el.json | 2 ++ .../components/google/translations/el.json | 9 ++++++++ .../components/hassio/translations/el.json | 1 + .../components/ialarm_xr/translations/el.json | 21 +++++++++++++++++++ .../components/recorder/translations/el.json | 2 ++ .../steam_online/translations/el.json | 3 +++ .../tankerkoenig/translations/ca.json | 3 ++- .../tankerkoenig/translations/el.json | 3 ++- .../tankerkoenig/translations/et.json | 3 ++- .../tankerkoenig/translations/hu.json | 3 ++- .../tankerkoenig/translations/id.json | 3 ++- .../tankerkoenig/translations/it.json | 3 ++- .../totalconnect/translations/el.json | 11 ++++++++++ .../components/ws66i/translations/en.json | 3 +++ .../components/zha/translations/zh-Hans.json | 2 +- 15 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/ialarm_xr/translations/el.json diff --git a/homeassistant/components/generic/translations/el.json b/homeassistant/components/generic/translations/el.json index be1c963740a..f97714a53c1 100644 --- a/homeassistant/components/generic/translations/el.json +++ b/homeassistant/components/generic/translations/el.json @@ -15,6 +15,7 @@ "stream_no_video": "\u0397 \u03c1\u03bf\u03ae \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03b2\u03af\u03bd\u03c4\u03b5\u03bf", "stream_not_permitted": "\u0394\u03b5\u03bd \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03c4\u03b1\u03b9 \u03b7 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03b5 \u03c1\u03bf\u03ae. \u039b\u03ac\u03b8\u03bf\u03c2 \u03c0\u03c1\u03c9\u03c4\u03cc\u03ba\u03bf\u03bb\u03bb\u03bf \u03bc\u03b5\u03c4\u03b1\u03c6\u03bf\u03c1\u03ac\u03c2 RTSP;", "stream_unauthorised": "\u0397 \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7 \u03b1\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03c4\u03b7 \u03c1\u03bf\u03ae", + "template_error": "\u03a3\u03c6\u03ac\u03bb\u03bc\u03b1 \u03b1\u03c0\u03cc\u03b4\u03bf\u03c3\u03b7\u03c2 \u03c0\u03c1\u03bf\u03c4\u03cd\u03c0\u03bf\u03c5. \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL", "unable_still_load": "\u0391\u03b4\u03c5\u03bd\u03b1\u03bc\u03af\u03b1 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7\u03c2 \u03ad\u03b3\u03ba\u03c5\u03c1\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 \u03b1\u03c0\u03cc \u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03b1\u03ba\u03af\u03bd\u03b7\u03c4\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 (\u03c0.\u03c7. \u03bc\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ba\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2, \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03ae \u03b1\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2). \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" @@ -57,6 +58,7 @@ "stream_no_video": "\u0397 \u03c1\u03bf\u03ae \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03b2\u03af\u03bd\u03c4\u03b5\u03bf", "stream_not_permitted": "\u0394\u03b5\u03bd \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03c4\u03b1\u03b9 \u03b7 \u03bb\u03b5\u03b9\u03c4\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03b5 \u03c1\u03bf\u03ae. \u039b\u03ac\u03b8\u03bf\u03c2 \u03c0\u03c1\u03c9\u03c4\u03cc\u03ba\u03bf\u03bb\u03bb\u03bf \u03bc\u03b5\u03c4\u03b1\u03c6\u03bf\u03c1\u03ac\u03c2 RTSP;", "stream_unauthorised": "\u0397 \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7 \u03b1\u03c0\u03ad\u03c4\u03c5\u03c7\u03b5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7\u03bd \u03c0\u03c1\u03bf\u03c3\u03c0\u03ac\u03b8\u03b5\u03b9\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2 \u03c3\u03c4\u03b7 \u03c1\u03bf\u03ae", + "template_error": "\u03a3\u03c6\u03ac\u03bb\u03bc\u03b1 \u03b1\u03c0\u03cc\u03b4\u03bf\u03c3\u03b7\u03c2 \u03c0\u03c1\u03bf\u03c4\u03cd\u03c0\u03bf\u03c5. \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL", "unable_still_load": "\u0391\u03b4\u03c5\u03bd\u03b1\u03bc\u03af\u03b1 \u03c6\u03cc\u03c1\u03c4\u03c9\u03c3\u03b7\u03c2 \u03ad\u03b3\u03ba\u03c5\u03c1\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 \u03b1\u03c0\u03cc \u03c4\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03b1\u03ba\u03af\u03bd\u03b7\u03c4\u03b7\u03c2 \u03b5\u03b9\u03ba\u03cc\u03bd\u03b1\u03c2 (\u03c0.\u03c7. \u03bc\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ba\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2, \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03ae \u03b1\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2). \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03bf \u03b1\u03c1\u03c7\u03b5\u03af\u03bf \u03ba\u03b1\u03c4\u03b1\u03b3\u03c1\u03b1\u03c6\u03ae\u03c2 \u03b3\u03b9\u03b1 \u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2.", "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" diff --git a/homeassistant/components/google/translations/el.json b/homeassistant/components/google/translations/el.json index 11c78f96a93..dd93a5ab3c8 100644 --- a/homeassistant/components/google/translations/el.json +++ b/homeassistant/components/google/translations/el.json @@ -27,5 +27,14 @@ "title": "\u0395\u03c0\u03b1\u03bd\u03b1\u03bb\u03b7\u03c0\u03c4\u03b9\u03ba\u03cc\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7\u03c2" } } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "\u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7 \u03c4\u03bf\u03c5 Home Assistant \u03c3\u03c4\u03bf \u0397\u03bc\u03b5\u03c1\u03bf\u03bb\u03cc\u03b3\u03b9\u03bf Google" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/hassio/translations/el.json b/homeassistant/components/hassio/translations/el.json index ba3a52f4bbd..9e9b32d7ce3 100644 --- a/homeassistant/components/hassio/translations/el.json +++ b/homeassistant/components/hassio/translations/el.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "agent_version": "\u0388\u03ba\u03b4\u03bf\u03c3\u03b7 Agent", "board": "\u03a0\u03bb\u03b1\u03ba\u03ad\u03c4\u03b1", "disk_total": "\u03a3\u03cd\u03bd\u03bf\u03bb\u03bf \u03b4\u03af\u03c3\u03ba\u03bf\u03c5", "disk_used": "\u0394\u03af\u03c3\u03ba\u03bf\u03c2 \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9", diff --git a/homeassistant/components/ialarm_xr/translations/el.json b/homeassistant/components/ialarm_xr/translations/el.json new file mode 100644 index 00000000000..067055d6654 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/el.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "user": { + "data": { + "host": "\u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2", + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "port": "\u0398\u03cd\u03c1\u03b1", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/recorder/translations/el.json b/homeassistant/components/recorder/translations/el.json index 6d541820c55..46c585c816d 100644 --- a/homeassistant/components/recorder/translations/el.json +++ b/homeassistant/components/recorder/translations/el.json @@ -2,6 +2,8 @@ "system_health": { "info": { "current_recorder_run": "\u03a4\u03c1\u03ad\u03c7\u03bf\u03c5\u03c3\u03b1 \u03ce\u03c1\u03b1 \u03ad\u03bd\u03b1\u03c1\u03be\u03b7\u03c2 \u03b5\u03ba\u03c4\u03ad\u03bb\u03b5\u03c3\u03b7\u03c2", + "database_engine": "\u039c\u03b7\u03c7\u03b1\u03bd\u03ae \u03b2\u03ac\u03c3\u03b7\u03c2 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd", + "database_version": "\u0388\u03ba\u03b4\u03bf\u03c3\u03b7 \u03b2\u03ac\u03c3\u03b7\u03c2 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd", "estimated_db_size": "\u0395\u03ba\u03c4\u03b9\u03bc\u03ce\u03bc\u03b5\u03bd\u03bf \u03bc\u03ad\u03b3\u03b5\u03b8\u03bf\u03c2 \u03b2\u03ac\u03c3\u03b7\u03c2 \u03b4\u03b5\u03b4\u03bf\u03bc\u03ad\u03bd\u03c9\u03bd (MiB)", "oldest_recorder_run": "\u03a0\u03b1\u03bb\u03b1\u03b9\u03cc\u03c4\u03b5\u03c1\u03b7 \u03ce\u03c1\u03b1 \u03ad\u03bd\u03b1\u03c1\u03be\u03b7\u03c2 \u03b5\u03ba\u03c4\u03ad\u03bb\u03b5\u03c3\u03b7\u03c2" } diff --git a/homeassistant/components/steam_online/translations/el.json b/homeassistant/components/steam_online/translations/el.json index 0f598dbc395..02405dc0215 100644 --- a/homeassistant/components/steam_online/translations/el.json +++ b/homeassistant/components/steam_online/translations/el.json @@ -25,6 +25,9 @@ } }, "options": { + "error": { + "unauthorized": "\u03a0\u03b5\u03c1\u03b9\u03bf\u03c1\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7 \u03bb\u03af\u03c3\u03c4\u03b1 \u03c6\u03af\u03bb\u03c9\u03bd: \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7 \u03b3\u03b9\u03b1 \u03c4\u03bf \u03c0\u03ce\u03c2 \u03bd\u03b1 \u03b4\u03b5\u03af\u03c4\u03b5 \u03cc\u03bb\u03bf\u03c5\u03c2 \u03c4\u03bf\u03c5\u03c2 \u03ac\u03bb\u03bb\u03bf\u03c5\u03c2 \u03c6\u03af\u03bb\u03bf\u03c5\u03c2" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/tankerkoenig/translations/ca.json b/homeassistant/components/tankerkoenig/translations/ca.json index 676bb1ccb55..4935e817ad4 100644 --- a/homeassistant/components/tankerkoenig/translations/ca.json +++ b/homeassistant/components/tankerkoenig/translations/ca.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Interval d'actualitzaci\u00f3", - "show_on_map": "Mostra les estacions al mapa" + "show_on_map": "Mostra les estacions al mapa", + "stations": "Estacions" }, "title": "Opcions de Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/el.json b/homeassistant/components/tankerkoenig/translations/el.json index 7f814b9760c..078001974ab 100644 --- a/homeassistant/components/tankerkoenig/translations/el.json +++ b/homeassistant/components/tankerkoenig/translations/el.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "\u0394\u03b9\u03ac\u03c3\u03c4\u03b7\u03bc\u03b1 \u03b5\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7\u03c2", - "show_on_map": "\u0395\u03bc\u03c6\u03ac\u03bd\u03b9\u03c3\u03b7 \u03c3\u03c4\u03b1\u03b8\u03bc\u03ce\u03bd \u03c3\u03c4\u03bf \u03c7\u03ac\u03c1\u03c4\u03b7" + "show_on_map": "\u0395\u03bc\u03c6\u03ac\u03bd\u03b9\u03c3\u03b7 \u03c3\u03c4\u03b1\u03b8\u03bc\u03ce\u03bd \u03c3\u03c4\u03bf \u03c7\u03ac\u03c1\u03c4\u03b7", + "stations": "\u03a3\u03c4\u03b1\u03b8\u03bc\u03bf\u03af" }, "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2 Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/et.json b/homeassistant/components/tankerkoenig/translations/et.json index c15e4b78ddf..b2cd4b42e06 100644 --- a/homeassistant/components/tankerkoenig/translations/et.json +++ b/homeassistant/components/tankerkoenig/translations/et.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "V\u00e4rskendamise intervall", - "show_on_map": "N\u00e4ita jaamu kaardil" + "show_on_map": "N\u00e4ita jaamu kaardil", + "stations": "Tanklad" }, "title": "Tankerkoenig valikud" } diff --git a/homeassistant/components/tankerkoenig/translations/hu.json b/homeassistant/components/tankerkoenig/translations/hu.json index e2c31e9e354..369f336f96f 100644 --- a/homeassistant/components/tankerkoenig/translations/hu.json +++ b/homeassistant/components/tankerkoenig/translations/hu.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Friss\u00edt\u00e9si id\u0151k\u00f6z", - "show_on_map": "\u00c1llom\u00e1sok megjelen\u00edt\u00e9se a t\u00e9rk\u00e9pen" + "show_on_map": "\u00c1llom\u00e1sok megjelen\u00edt\u00e9se a t\u00e9rk\u00e9pen", + "stations": "\u00c1llom\u00e1sok" }, "title": "Tankerkoenig be\u00e1ll\u00edt\u00e1sok" } diff --git a/homeassistant/components/tankerkoenig/translations/id.json b/homeassistant/components/tankerkoenig/translations/id.json index cddeb02b17f..8ac50c9760f 100644 --- a/homeassistant/components/tankerkoenig/translations/id.json +++ b/homeassistant/components/tankerkoenig/translations/id.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Interval pembaruan", - "show_on_map": "Tampilkan SPBU di peta" + "show_on_map": "Tampilkan SPBU di peta", + "stations": "SPBU" }, "title": "Opsi Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/it.json b/homeassistant/components/tankerkoenig/translations/it.json index e24c353d4f1..4b4cbf6d390 100644 --- a/homeassistant/components/tankerkoenig/translations/it.json +++ b/homeassistant/components/tankerkoenig/translations/it.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Intervallo di aggiornamento", - "show_on_map": "Mostra stazioni sulla mappa" + "show_on_map": "Mostra stazioni sulla mappa", + "stations": "Stazioni" }, "title": "Opzioni Tankerkoenig" } diff --git a/homeassistant/components/totalconnect/translations/el.json b/homeassistant/components/totalconnect/translations/el.json index 4f60b082484..323fd58274c 100644 --- a/homeassistant/components/totalconnect/translations/el.json +++ b/homeassistant/components/totalconnect/translations/el.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "\u0391\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03c0\u03b1\u03c1\u03ac\u03ba\u03b1\u03bc\u03c8\u03b7 \u03c7\u03b1\u03bc\u03b7\u03bb\u03ae\u03c2 \u03bc\u03c0\u03b1\u03c4\u03b1\u03c1\u03af\u03b1\u03c2" + }, + "description": "\u0391\u03c5\u03c4\u03cc\u03bc\u03b1\u03c4\u03b7 \u03c0\u03b1\u03c1\u03ac\u03ba\u03b1\u03bc\u03c8\u03b7 \u03c4\u03c9\u03bd \u03b6\u03c9\u03bd\u03ce\u03bd \u03c4\u03b7 \u03c3\u03c4\u03b9\u03b3\u03bc\u03ae \u03c0\u03bf\u03c5 \u03b1\u03bd\u03b1\u03c6\u03ad\u03c1\u03bf\u03c5\u03bd \u03c7\u03b1\u03bc\u03b7\u03bb\u03ae \u03bc\u03c0\u03b1\u03c4\u03b1\u03c1\u03af\u03b1.", + "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03c2 TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/ws66i/translations/en.json b/homeassistant/components/ws66i/translations/en.json index fd4b170b378..30ef1e4205a 100644 --- a/homeassistant/components/ws66i/translations/en.json +++ b/homeassistant/components/ws66i/translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Device is already configured" + }, "error": { "cannot_connect": "Failed to connect", "unknown": "Unexpected error" diff --git a/homeassistant/components/zha/translations/zh-Hans.json b/homeassistant/components/zha/translations/zh-Hans.json index 1d40d80d8a2..ab4b69efbb0 100644 --- a/homeassistant/components/zha/translations/zh-Hans.json +++ b/homeassistant/components/zha/translations/zh-Hans.json @@ -66,7 +66,7 @@ "device_flipped": "\u8bbe\u5907\u7ffb\u8f6c{subtype}", "device_knocked": "\u8bbe\u5907\u8f7b\u6572{subtype}", "device_offline": "\u8bbe\u5907\u79bb\u7ebf", - "device_rotated": "\u8bbe\u5907\u65cb\u8f6c{subtype}", + "device_rotated": "\u8bbe\u5907\u5411{subtype}\u65cb\u8f6c", "device_shaken": "\u8bbe\u5907\u6447\u4e00\u6447", "device_slid": "\u8bbe\u5907\u5e73\u79fb{subtype}", "device_tilted": "\u8bbe\u5907\u503e\u659c", From b9e93207e39cf23d7a14a23bdef4777ee77dc493 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 30 May 2022 03:14:43 +0200 Subject: [PATCH 072/947] Switch severity for gesture logging (#72668) --- homeassistant/components/nanoleaf/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py index 9e9cf1d6ca4..f6fb2f8112b 100644 --- a/homeassistant/components/nanoleaf/__init__.py +++ b/homeassistant/components/nanoleaf/__init__.py @@ -85,9 +85,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Receive touch event.""" gesture_type = TOUCH_GESTURE_TRIGGER_MAP.get(event.gesture_id) if gesture_type is None: - _LOGGER.debug("Received unknown touch gesture ID %s", event.gesture_id) + _LOGGER.warning( + "Received unknown touch gesture ID %s", event.gesture_id + ) return - _LOGGER.warning("Received touch gesture %s", gesture_type) + _LOGGER.debug("Received touch gesture %s", gesture_type) hass.bus.async_fire( NANOLEAF_EVENT, {CONF_DEVICE_ID: device_entry.id, CONF_TYPE: gesture_type}, From 75669dba6e6be226f6ea512cf09d413608e77897 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 30 May 2022 07:25:35 +0200 Subject: [PATCH 073/947] Use `pysnmplib` instead of `pysnmp` (#72645) * Use pysnmp and bump brother * Fix mypy errors * Bump brother version --- homeassistant/components/brother/config_flow.py | 2 +- homeassistant/components/brother/manifest.json | 2 +- homeassistant/components/snmp/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 39a196aa6cb..24e7d701ed0 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -42,7 +42,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize.""" - self.brother: Brother = None + self.brother: Brother self.host: str | None = None async def async_step_user( diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index aaf1af72db9..e14079f6dd9 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -3,7 +3,7 @@ "name": "Brother Printer", "documentation": "https://www.home-assistant.io/integrations/brother", "codeowners": ["@bieniu"], - "requirements": ["brother==1.1.0"], + "requirements": ["brother==1.2.3"], "zeroconf": [ { "type": "_printer._tcp.local.", diff --git a/homeassistant/components/snmp/manifest.json b/homeassistant/components/snmp/manifest.json index 76df9e18606..1ffcb04ebda 100644 --- a/homeassistant/components/snmp/manifest.json +++ b/homeassistant/components/snmp/manifest.json @@ -2,7 +2,7 @@ "domain": "snmp", "name": "SNMP", "documentation": "https://www.home-assistant.io/integrations/snmp", - "requirements": ["pysnmp==4.4.12"], + "requirements": ["pysnmplib==5.0.15"], "codeowners": [], "iot_class": "local_polling", "loggers": ["pyasn1", "pysmi", "pysnmp"] diff --git a/requirements_all.txt b/requirements_all.txt index 93c151fd843..53e6e28788e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -433,7 +433,7 @@ bravia-tv==1.0.11 broadlink==0.18.2 # homeassistant.components.brother -brother==1.1.0 +brother==1.2.3 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 @@ -1838,7 +1838,7 @@ pysmarty==0.8 pysml==0.0.7 # homeassistant.components.snmp -pysnmp==4.4.12 +pysnmplib==5.0.15 # homeassistant.components.soma pysoma==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 718e876aa2f..2688d1aa5c1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -330,7 +330,7 @@ bravia-tv==1.0.11 broadlink==0.18.2 # homeassistant.components.brother -brother==1.1.0 +brother==1.2.3 # homeassistant.components.brunt brunt==1.2.0 From 6e355e1074b3c72f88008ea74ccd8e93571e1bbd Mon Sep 17 00:00:00 2001 From: BigMoby Date: Mon, 30 May 2022 08:26:05 +0200 Subject: [PATCH 074/947] iAlarm XR integration refinements (#72616) * fixing after MartinHjelmare review * fixing after MartinHjelmare review conversion alarm state to hass state * fixing after MartinHjelmare review conversion alarm state to hass state * manage the status in the alarm control * simplyfing return function --- .../components/ialarm_xr/__init__.py | 6 ++--- .../ialarm_xr/alarm_control_panel.py | 22 ++++++++++++++++--- .../components/ialarm_xr/config_flow.py | 4 ++-- homeassistant/components/ialarm_xr/const.py | 15 ------------- .../components/ialarm_xr/manifest.json | 4 ++-- .../components/ialarm_xr/strings.json | 1 + .../components/ialarm_xr/translations/en.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/ialarm_xr/test_config_flow.py | 22 ++----------------- tests/components/ialarm_xr/test_init.py | 10 --------- 11 files changed, 32 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/ialarm_xr/__init__.py b/homeassistant/components/ialarm_xr/__init__.py index 9a41b5ebab7..193bbe4fffc 100644 --- a/homeassistant/components/ialarm_xr/__init__.py +++ b/homeassistant/components/ialarm_xr/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN, IALARMXR_TO_HASS +from .const import DOMAIN from .utils import async_get_ialarmxr_mac PLATFORMS = [Platform.ALARM_CONTROL_PANEL] @@ -74,7 +74,7 @@ class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator): def __init__(self, hass: HomeAssistant, ialarmxr: IAlarmXR, mac: str) -> None: """Initialize global iAlarm data updater.""" self.ialarmxr: IAlarmXR = ialarmxr - self.state: str | None = None + self.state: int | None = None self.host: str = ialarmxr.host self.mac: str = mac @@ -90,7 +90,7 @@ class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator): status: int = self.ialarmxr.get_status() _LOGGER.debug("iAlarmXR status: %s", status) - self.state = IALARMXR_TO_HASS.get(status) + self.state = status async def _async_update_data(self) -> None: """Fetch data from iAlarmXR.""" diff --git a/homeassistant/components/ialarm_xr/alarm_control_panel.py b/homeassistant/components/ialarm_xr/alarm_control_panel.py index 7b47ce3d7fa..b64edb74391 100644 --- a/homeassistant/components/ialarm_xr/alarm_control_panel.py +++ b/homeassistant/components/ialarm_xr/alarm_control_panel.py @@ -1,11 +1,19 @@ """Interfaces with iAlarmXR control panels.""" from __future__ import annotations +from pyialarmxr import IAlarmXR + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, + STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry from homeassistant.helpers.entity import DeviceInfo @@ -15,6 +23,13 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import IAlarmXRDataUpdateCoordinator from .const import DOMAIN +IALARMXR_TO_HASS = { + IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME, + IAlarmXR.DISARMED: STATE_ALARM_DISARMED, + IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED, +} + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -24,7 +39,9 @@ async def async_setup_entry( async_add_entities([IAlarmXRPanel(coordinator)]) -class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity): +class IAlarmXRPanel( + CoordinatorEntity[IAlarmXRDataUpdateCoordinator], AlarmControlPanelEntity +): """Representation of an iAlarmXR device.""" _attr_supported_features = ( @@ -37,7 +54,6 @@ class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity): def __init__(self, coordinator: IAlarmXRDataUpdateCoordinator) -> None: """Initialize the alarm panel.""" super().__init__(coordinator) - self.coordinator: IAlarmXRDataUpdateCoordinator = coordinator self._attr_unique_id = coordinator.mac self._attr_device_info = DeviceInfo( manufacturer="Antifurto365 - Meian", @@ -48,7 +64,7 @@ class IAlarmXRPanel(CoordinatorEntity, AlarmControlPanelEntity): @property def state(self) -> str | None: """Return the state of the device.""" - return self.coordinator.state + return IALARMXR_TO_HASS.get(self.coordinator.state) def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" diff --git a/homeassistant/components/ialarm_xr/config_flow.py b/homeassistant/components/ialarm_xr/config_flow.py index 06509a82eb5..2a9cc406733 100644 --- a/homeassistant/components/ialarm_xr/config_flow.py +++ b/homeassistant/components/ialarm_xr/config_flow.py @@ -72,13 +72,13 @@ class IAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): "IAlarmXRGenericException with message: [ %s ]", ialarmxr_exception.message, ) - errors["base"] = "unknown" + errors["base"] = "cannot_connect" except IAlarmXRSocketTimeoutException as ialarmxr_socket_timeout_exception: _LOGGER.debug( "IAlarmXRSocketTimeoutException with message: [ %s ]", ialarmxr_socket_timeout_exception.message, ) - errors["base"] = "unknown" + errors["base"] = "timeout" except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" diff --git a/homeassistant/components/ialarm_xr/const.py b/homeassistant/components/ialarm_xr/const.py index a208f5290b6..12122277340 100644 --- a/homeassistant/components/ialarm_xr/const.py +++ b/homeassistant/components/ialarm_xr/const.py @@ -1,18 +1,3 @@ """Constants for the iAlarmXR integration.""" -from pyialarmxr import IAlarmXR - -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, -) DOMAIN = "ialarm_xr" - -IALARMXR_TO_HASS = { - IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME, - IAlarmXR.DISARMED: STATE_ALARM_DISARMED, - IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED, -} diff --git a/homeassistant/components/ialarm_xr/manifest.json b/homeassistant/components/ialarm_xr/manifest.json index 4861e9c901f..f863f360242 100644 --- a/homeassistant/components/ialarm_xr/manifest.json +++ b/homeassistant/components/ialarm_xr/manifest.json @@ -1,8 +1,8 @@ { "domain": "ialarm_xr", "name": "Antifurto365 iAlarmXR", - "documentation": "https://www.home-assistant.io/integrations/ialarmxr", - "requirements": ["pyialarmxr==1.0.13"], + "documentation": "https://www.home-assistant.io/integrations/ialarm_xr", + "requirements": ["pyialarmxr==1.0.18"], "codeowners": ["@bigmoby"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/ialarm_xr/strings.json b/homeassistant/components/ialarm_xr/strings.json index 1650ae28c84..ea4f91fdbb9 100644 --- a/homeassistant/components/ialarm_xr/strings.json +++ b/homeassistant/components/ialarm_xr/strings.json @@ -12,6 +12,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/ialarm_xr/translations/en.json b/homeassistant/components/ialarm_xr/translations/en.json index bf2bf989dcd..be59a5a1dc4 100644 --- a/homeassistant/components/ialarm_xr/translations/en.json +++ b/homeassistant/components/ialarm_xr/translations/en.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Failed to connect", + "timeout": "Timeout establishing connection", "unknown": "Unexpected error" }, "step": { diff --git a/requirements_all.txt b/requirements_all.txt index 53e6e28788e..c28967caa33 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1550,7 +1550,7 @@ pyhomeworks==0.0.6 pyialarm==1.9.0 # homeassistant.components.ialarm_xr -pyialarmxr==1.0.13 +pyialarmxr==1.0.18 # homeassistant.components.icloud pyicloud==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2688d1aa5c1..3025404fc62 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1038,7 +1038,7 @@ pyhomematic==0.1.77 pyialarm==1.9.0 # homeassistant.components.ialarm_xr -pyialarmxr==1.0.13 +pyialarmxr==1.0.18 # homeassistant.components.icloud pyicloud==1.0.0 diff --git a/tests/components/ialarm_xr/test_config_flow.py b/tests/components/ialarm_xr/test_config_flow.py index 22a70bda067..804249dd5cb 100644 --- a/tests/components/ialarm_xr/test_config_flow.py +++ b/tests/components/ialarm_xr/test_config_flow.py @@ -56,24 +56,6 @@ async def test_form(hass): assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_cannot_connect(hass): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=ConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} - - async def test_form_exception(hass): """Test we handle unknown exception.""" result = await hass.config_entries.flow.async_init( @@ -125,7 +107,7 @@ async def test_form_cannot_connect_throwing_socket_timeout_exception(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"] == {"base": "timeout"} async def test_form_cannot_connect_throwing_generic_exception(hass): @@ -143,7 +125,7 @@ async def test_form_cannot_connect_throwing_generic_exception(hass): ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "unknown"} + assert result2["errors"] == {"base": "cannot_connect"} async def test_form_already_exists(hass): diff --git a/tests/components/ialarm_xr/test_init.py b/tests/components/ialarm_xr/test_init.py index 8486b7049e6..0898b6bebf8 100644 --- a/tests/components/ialarm_xr/test_init.py +++ b/tests/components/ialarm_xr/test_init.py @@ -48,16 +48,6 @@ async def test_setup_entry(hass, ialarmxr_api, mock_config_entry): assert mock_config_entry.state is ConfigEntryState.LOADED -async def test_setup_not_ready(hass, ialarmxr_api, mock_config_entry): - """Test setup failed because we can't connect to the alarm system.""" - ialarmxr_api.return_value.get_mac = Mock(side_effect=ConnectionError) - - mock_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - async def test_unload_entry(hass, ialarmxr_api, mock_config_entry): """Test being able to unload an entry.""" ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") From 1c334605b6dd092683303107361ea78034484608 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 29 May 2022 20:49:37 -1000 Subject: [PATCH 075/947] Enable strict typing to emulated_hue (#72676) * Add typing to emulated_hue part 2 * cleanups * adjust targets in test --- .strict-typing | 1 + .../components/emulated_hue/__init__.py | 97 ++++------ .../components/emulated_hue/config.py | 21 +-- .../components/emulated_hue/hue_api.py | 2 +- homeassistant/components/emulated_hue/upnp.py | 175 ++++++++++-------- mypy.ini | 11 ++ tests/components/emulated_hue/test_hue_api.py | 8 +- tests/components/emulated_hue/test_init.py | 8 +- tests/components/emulated_hue/test_upnp.py | 4 +- 9 files changed, 165 insertions(+), 162 deletions(-) diff --git a/.strict-typing b/.strict-typing index 7fe03203583..a2c6cd2d9da 100644 --- a/.strict-typing +++ b/.strict-typing @@ -83,6 +83,7 @@ homeassistant.components.dunehd.* homeassistant.components.efergy.* homeassistant.components.elgato.* homeassistant.components.elkm1.* +homeassistant.components.emulated_hue.* homeassistant.components.esphome.* homeassistant.components.energy.* homeassistant.components.evil_genius_labs.* diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 71f98abed80..ec06f70a3cc 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType @@ -48,11 +48,7 @@ from .hue_api import ( HueUnauthorizedUser, HueUsernameView, ) -from .upnp import ( - DescriptionXmlView, - UPNPResponderProtocol, - create_upnp_datagram_endpoint, -) +from .upnp import DescriptionXmlView, async_create_upnp_datagram_endpoint _LOGGER = logging.getLogger(__name__) @@ -93,6 +89,40 @@ CONFIG_SCHEMA = vol.Schema( ) +async def start_emulated_hue_bridge( + hass: HomeAssistant, config: Config, app: web.Application +) -> None: + """Start the emulated hue bridge.""" + protocol = await async_create_upnp_datagram_endpoint( + config.host_ip_addr, + config.upnp_bind_multicast, + config.advertise_ip, + config.advertise_port or config.listen_port, + ) + + runner = web.AppRunner(app) + await runner.setup() + + site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) + + try: + await site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server at port %d: %s", config.listen_port, error + ) + protocol.close() + return + + async def stop_emulated_hue_bridge(event: Event) -> None: + """Stop the emulated hue bridge.""" + protocol.close() + await site.stop() + await runner.cleanup() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) + + async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: """Activate the emulated_hue component.""" local_ip = await async_get_source_ip(hass) @@ -108,9 +138,6 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: app._on_startup.freeze() await app.startup() - runner = None - site = None - DescriptionXmlView(config).register(app, app.router) HueUsernameView().register(app, app.router) HueConfigView(config).register(app, app.router) @@ -122,54 +149,10 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: HueGroupView(config).register(app, app.router) HueFullStateView(config).register(app, app.router) - listen = create_upnp_datagram_endpoint( - config.host_ip_addr, - config.upnp_bind_multicast, - config.advertise_ip, - config.advertise_port or config.listen_port, - ) - protocol: UPNPResponderProtocol | None = None + async def _start(event: Event) -> None: + """Start the bridge.""" + await start_emulated_hue_bridge(hass, config, app) - async def stop_emulated_hue_bridge(event): - """Stop the emulated hue bridge.""" - nonlocal protocol - nonlocal site - nonlocal runner - - if protocol: - protocol.close() - if site: - await site.stop() - if runner: - await runner.cleanup() - - async def start_emulated_hue_bridge(event): - """Start the emulated hue bridge.""" - nonlocal protocol - nonlocal site - nonlocal runner - - transport_protocol = await listen - protocol = transport_protocol[1] - - runner = web.AppRunner(app) - await runner.setup() - - site = web.TCPSite(runner, config.host_ip_addr, config.listen_port) - - try: - await site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server at port %d: %s", config.listen_port, error - ) - if protocol: - protocol.close() - else: - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge - ) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start) return True diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index e39ec9839c8..fce521eee55 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -55,9 +55,7 @@ _LOGGER = logging.getLogger(__name__) class Config: """Hold configuration variables for the emulated hue bridge.""" - def __init__( - self, hass: HomeAssistant, conf: ConfigType, local_ip: str | None - ) -> None: + def __init__(self, hass: HomeAssistant, conf: ConfigType, local_ip: str) -> None: """Initialize the instance.""" self.hass = hass self.type = conf.get(CONF_TYPE) @@ -73,17 +71,10 @@ class Config: ) # Get the IP address that will be passed to the Echo during discovery - self.host_ip_addr = conf.get(CONF_HOST_IP) - if self.host_ip_addr is None: - self.host_ip_addr = local_ip + self.host_ip_addr: str = conf.get(CONF_HOST_IP) or local_ip # Get the port that the Hue bridge will listen on - self.listen_port = conf.get(CONF_LISTEN_PORT) - if not isinstance(self.listen_port, int): - self.listen_port = DEFAULT_LISTEN_PORT - _LOGGER.info( - "Listen port not specified, defaulting to %s", self.listen_port - ) + self.listen_port: int = conf.get(CONF_LISTEN_PORT) or DEFAULT_LISTEN_PORT # Get whether or not UPNP binds to multicast address (239.255.255.250) # or to the unicast address (host_ip_addr) @@ -113,11 +104,11 @@ class Config: ) # Calculated effective advertised IP and port for network isolation - self.advertise_ip = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr + self.advertise_ip: str = conf.get(CONF_ADVERTISE_IP) or self.host_ip_addr - self.advertise_port = conf.get(CONF_ADVERTISE_PORT) or self.listen_port + self.advertise_port: int = conf.get(CONF_ADVERTISE_PORT) or self.listen_port - self.entities = conf.get(CONF_ENTITIES, {}) + self.entities: dict[str, dict[str, str]] = conf.get(CONF_ENTITIES, {}) self._entities_with_hidden_attr_in_config = {} for entity_id in self.entities: diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index e7a4876730c..d6ac67b6984 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -858,7 +858,7 @@ async def wait_for_state_change_or_timeout( ev = asyncio.Event() @core.callback - def _async_event_changed(_): + def _async_event_changed(event: core.Event) -> None: ev.set() unsub = async_track_state_change_event(hass, [entity_id], _async_event_changed) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index 797b22c22f7..ca8c0a45281 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -1,13 +1,17 @@ """Support UPNP discovery method that mimics Hue hubs.""" +from __future__ import annotations + import asyncio import logging import socket +from typing import cast from aiohttp import web from homeassistant import core from homeassistant.components.http import HomeAssistantView +from .config import Config from .const import HUE_SERIAL_NUMBER, HUE_UUID _LOGGER = logging.getLogger(__name__) @@ -23,12 +27,12 @@ class DescriptionXmlView(HomeAssistantView): name = "description:xml" requires_auth = False - def __init__(self, config): + def __init__(self, config: Config) -> None: """Initialize the instance of the view.""" self.config = config @core.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Handle a GET request.""" resp_text = f""" @@ -55,13 +59,91 @@ class DescriptionXmlView(HomeAssistantView): return web.Response(text=resp_text, content_type="text/xml") -@core.callback -def create_upnp_datagram_endpoint( - host_ip_addr, - upnp_bind_multicast, - advertise_ip, - advertise_port, -): +class UPNPResponderProtocol(asyncio.Protocol): + """Handle responding to UPNP/SSDP discovery requests.""" + + def __init__( + self, + loop: asyncio.AbstractEventLoop, + ssdp_socket: socket.socket, + advertise_ip: str, + advertise_port: int, + ) -> None: + """Initialize the class.""" + self.transport: asyncio.DatagramTransport | None = None + self._loop = loop + self._sock = ssdp_socket + self.advertise_ip = advertise_ip + self.advertise_port = advertise_port + self._upnp_root_response = self._prepare_response( + "upnp:rootdevice", f"uuid:{HUE_UUID}::upnp:rootdevice" + ) + self._upnp_device_response = self._prepare_response( + "urn:schemas-upnp-org:device:basic:1", f"uuid:{HUE_UUID}" + ) + + def connection_made(self, transport: asyncio.BaseTransport) -> None: + """Set the transport.""" + self.transport = cast(asyncio.DatagramTransport, transport) + + def connection_lost(self, exc: Exception | None) -> None: + """Handle connection lost.""" + + def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: + """Respond to msearch packets.""" + decoded_data = data.decode("utf-8", errors="ignore") + + if "M-SEARCH" not in decoded_data: + return + + _LOGGER.debug("UPNP Responder M-SEARCH method received: %s", data) + # SSDP M-SEARCH method received, respond to it with our info + response = self._handle_request(decoded_data) + _LOGGER.debug("UPNP Responder responding with: %s", response) + assert self.transport is not None + self.transport.sendto(response, addr) + + def error_received(self, exc: Exception) -> None: + """Log UPNP errors.""" + _LOGGER.error("UPNP Error received: %s", exc) + + def close(self) -> None: + """Stop the server.""" + _LOGGER.info("UPNP responder shutting down") + if self.transport: + self.transport.close() + self._loop.remove_writer(self._sock.fileno()) + self._loop.remove_reader(self._sock.fileno()) + self._sock.close() + + def _handle_request(self, decoded_data: str) -> bytes: + if "upnp:rootdevice" in decoded_data: + return self._upnp_root_response + + return self._upnp_device_response + + def _prepare_response(self, search_target: str, unique_service_name: str) -> bytes: + # Note that the double newline at the end of + # this string is required per the SSDP spec + response = f"""HTTP/1.1 200 OK +CACHE-CONTROL: max-age=60 +EXT: +LOCATION: http://{self.advertise_ip}:{self.advertise_port}/description.xml +SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 +hue-bridgeid: {HUE_SERIAL_NUMBER} +ST: {search_target} +USN: {unique_service_name} + +""" + return response.replace("\n", "\r\n").encode("utf-8") + + +async def async_create_upnp_datagram_endpoint( + host_ip_addr: str, + upnp_bind_multicast: bool, + advertise_ip: str, + advertise_port: int, +) -> UPNPResponderProtocol: """Create the UPNP socket and protocol.""" # Listen for UDP port 1900 packets sent to SSDP multicast address ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -84,79 +166,8 @@ def create_upnp_datagram_endpoint( loop = asyncio.get_event_loop() - return loop.create_datagram_endpoint( + transport_protocol = await loop.create_datagram_endpoint( lambda: UPNPResponderProtocol(loop, ssdp_socket, advertise_ip, advertise_port), sock=ssdp_socket, ) - - -class UPNPResponderProtocol: - """Handle responding to UPNP/SSDP discovery requests.""" - - def __init__(self, loop, ssdp_socket, advertise_ip, advertise_port): - """Initialize the class.""" - self.transport = None - self._loop = loop - self._sock = ssdp_socket - self.advertise_ip = advertise_ip - self.advertise_port = advertise_port - self._upnp_root_response = self._prepare_response( - "upnp:rootdevice", f"uuid:{HUE_UUID}::upnp:rootdevice" - ) - self._upnp_device_response = self._prepare_response( - "urn:schemas-upnp-org:device:basic:1", f"uuid:{HUE_UUID}" - ) - - def connection_made(self, transport): - """Set the transport.""" - self.transport = transport - - def connection_lost(self, exc): - """Handle connection lost.""" - - def datagram_received(self, data, addr): - """Respond to msearch packets.""" - decoded_data = data.decode("utf-8", errors="ignore") - - if "M-SEARCH" not in decoded_data: - return - - _LOGGER.debug("UPNP Responder M-SEARCH method received: %s", data) - # SSDP M-SEARCH method received, respond to it with our info - response = self._handle_request(decoded_data) - _LOGGER.debug("UPNP Responder responding with: %s", response) - self.transport.sendto(response, addr) - - def error_received(self, exc): - """Log UPNP errors.""" - _LOGGER.error("UPNP Error received: %s", exc) - - def close(self): - """Stop the server.""" - _LOGGER.info("UPNP responder shutting down") - if self.transport: - self.transport.close() - self._loop.remove_writer(self._sock.fileno()) - self._loop.remove_reader(self._sock.fileno()) - self._sock.close() - - def _handle_request(self, decoded_data): - if "upnp:rootdevice" in decoded_data: - return self._upnp_root_response - - return self._upnp_device_response - - def _prepare_response(self, search_target, unique_service_name): - # Note that the double newline at the end of - # this string is required per the SSDP spec - response = f"""HTTP/1.1 200 OK -CACHE-CONTROL: max-age=60 -EXT: -LOCATION: http://{self.advertise_ip}:{self.advertise_port}/description.xml -SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/1.16.0 -hue-bridgeid: {HUE_SERIAL_NUMBER} -ST: {search_target} -USN: {unique_service_name} - -""" - return response.replace("\n", "\r\n").encode("utf-8") + return transport_protocol[1] diff --git a/mypy.ini b/mypy.ini index e2159766fb4..fb47638d59d 100644 --- a/mypy.ini +++ b/mypy.ini @@ -676,6 +676,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.emulated_hue.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.esphome.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index dd27eed9771..87893f66e1f 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -108,7 +108,9 @@ def hass_hue(loop, hass): ) ) - with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"): + with patch( + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" + ): loop.run_until_complete( setup.async_setup_component( hass, @@ -314,7 +316,9 @@ async def test_lights_all_dimmable(hass, hass_client_no_auth): emulated_hue.CONF_EXPOSE_BY_DEFAULT: True, emulated_hue.CONF_LIGHTS_ALL_DIMMABLE: True, } - with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"): + with patch( + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" + ): await setup.async_setup_component( hass, emulated_hue.DOMAIN, diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py index 024b0f3ddf7..408a44cda00 100644 --- a/tests/components/emulated_hue/test_init.py +++ b/tests/components/emulated_hue/test_init.py @@ -122,13 +122,13 @@ async def test_setup_works(hass): """Test setup works.""" hass.config.components.add("network") with patch( - "homeassistant.components.emulated_hue.create_upnp_datagram_endpoint", + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint", AsyncMock(), ) as mock_create_upnp_datagram_endpoint, patch( "homeassistant.components.emulated_hue.async_get_source_ip" ): assert await async_setup_component(hass, "emulated_hue", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - assert len(mock_create_upnp_datagram_endpoint.mock_calls) == 2 + assert len(mock_create_upnp_datagram_endpoint.mock_calls) == 1 diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 79daaadbbc9..f392cfaf90d 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -53,7 +53,9 @@ def hue_client(aiohttp_client): async def setup_hue(hass): """Set up the emulated_hue integration.""" - with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"): + with patch( + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" + ): assert await setup.async_setup_component( hass, emulated_hue.DOMAIN, From 8d72891d83c0ea0e258ae40217cd526637ef17ec Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Mon, 30 May 2022 08:52:58 +0200 Subject: [PATCH 076/947] Bump bimmer_connected to 0.9.3 (#72677) Bump bimmer_connected to 0.9.3, fix retrieved units Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/coordinator.py | 3 ++- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- homeassistant/components/bmw_connected_drive/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index cff532ae3cb..47d1f358686 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -6,7 +6,7 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.vehicle.models import GPSPosition +from bimmer_connected.models import GPSPosition from httpx import HTTPError, TimeoutException from homeassistant.config_entries import ConfigEntry @@ -32,6 +32,7 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): entry.data[CONF_PASSWORD], get_region_from_name(entry.data[CONF_REGION]), observer_position=GPSPosition(hass.config.latitude, hass.config.longitude), + use_metric_units=hass.config.units.is_metric, ) self.read_only = entry.options[CONF_READ_ONLY] self._entry = entry diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c7130d12698..75ac3e982e8 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.2"], + "requirements": ["bimmer_connected==0.9.3"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 3021e180158..9f19673c398 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -6,8 +6,8 @@ from dataclasses import dataclass import logging from typing import cast +from bimmer_connected.models import ValueWithUnit from bimmer_connected.vehicle import MyBMWVehicle -from bimmer_connected.vehicle.models import ValueWithUnit from homeassistant.components.sensor import ( SensorDeviceClass, diff --git a/requirements_all.txt b/requirements_all.txt index c28967caa33..a7d649b4702 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -394,7 +394,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.2 +bimmer_connected==0.9.3 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3025404fc62..f461428c796 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.2 +bimmer_connected==0.9.3 # homeassistant.components.blebox blebox_uniapi==1.3.3 From 6bc09741c7d965c2be96c647fd1932b2af4531ba Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 May 2022 08:54:29 +0200 Subject: [PATCH 077/947] Adjust config-flow type hints in gogogate2 (#72445) --- .../components/gogogate2/config_flow.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/gogogate2/config_flow.py b/homeassistant/components/gogogate2/config_flow.py index e97b62102c4..344e8473984 100644 --- a/homeassistant/components/gogogate2/config_flow.py +++ b/homeassistant/components/gogogate2/config_flow.py @@ -1,12 +1,14 @@ """Config flow for Gogogate2.""" +from __future__ import annotations + import dataclasses import re +from typing import Any from ismartgate.common import AbstractInfoResponse, ApiError from ismartgate.const import GogoGate2ApiErrorCode, ISmartGateApiErrorCode import voluptuous as vol -from homeassistant import data_entry_flow from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( @@ -15,6 +17,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.data_entry_flow import AbortFlow, FlowResult from .common import get_api from .const import DEVICE_TYPE_GOGOGATE2, DEVICE_TYPE_ISMARTGATE, DOMAIN @@ -30,28 +33,26 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the config flow.""" - self._ip_address = None - self._device_type = None + self._ip_address: str | None = None + self._device_type: str | None = None async def async_step_homekit( self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle homekit discovery.""" await self.async_set_unique_id( discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] ) return await self._async_discovery_handler(discovery_info.host) - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" await self.async_set_unique_id(discovery_info.macaddress) return await self._async_discovery_handler(discovery_info.ip) - async def _async_discovery_handler(self, ip_address): + async def _async_discovery_handler(self, ip_address: str) -> FlowResult: """Start the user flow from any discovery.""" self.context[CONF_IP_ADDRESS] = ip_address self._abort_if_unique_id_configured({CONF_IP_ADDRESS: ip_address}) @@ -61,12 +62,14 @@ class Gogogate2FlowHandler(ConfigFlow, domain=DOMAIN): self._ip_address = ip_address for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_IP_ADDRESS) == self._ip_address: - raise data_entry_flow.AbortFlow("already_in_progress") + raise AbortFlow("already_in_progress") self._device_type = DEVICE_TYPE_ISMARTGATE return await self.async_step_user() - async def async_step_user(self, user_input: dict = None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user initiated flow.""" user_input = user_input or {} errors = {} From b417ae72e571b9b33acb591ca6da2a192fbc80f6 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 30 May 2022 09:22:37 +0200 Subject: [PATCH 078/947] Add generic parameters to HassJob (#70973) --- homeassistant/core.py | 20 +++++++++++--------- homeassistant/helpers/event.py | 34 +++++++++++++++++++++------------- 2 files changed, 32 insertions(+), 22 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index d7cae4e411e..b8f509abef3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -37,6 +37,7 @@ from typing import ( ) from urllib.parse import urlparse +from typing_extensions import ParamSpec import voluptuous as vol import yarl @@ -98,6 +99,7 @@ block_async_io.enable() _T = TypeVar("_T") _R = TypeVar("_R") _R_co = TypeVar("_R_co", covariant=True) +_P = ParamSpec("_P") # Internal; not helpers.typing.UNDEFINED due to circular dependency _UNDEF: dict[Any, Any] = {} _CallableT = TypeVar("_CallableT", bound=Callable[..., Any]) @@ -182,7 +184,7 @@ class HassJobType(enum.Enum): Executor = 3 -class HassJob(Generic[_R_co]): +class HassJob(Generic[_P, _R_co]): """Represent a job to be run later. We check the callable type in advance @@ -192,7 +194,7 @@ class HassJob(Generic[_R_co]): __slots__ = ("job_type", "target") - def __init__(self, target: Callable[..., _R_co]) -> None: + def __init__(self, target: Callable[_P, _R_co]) -> None: """Create a job object.""" self.target = target self.job_type = _get_hassjob_callable_job_type(target) @@ -416,20 +418,20 @@ class HomeAssistant: @overload @callback def async_add_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R]], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any ) -> asyncio.Future[_R] | None: ... @overload @callback def async_add_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: ... @callback def async_add_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: """Add a HassJob from within the event loop. @@ -512,20 +514,20 @@ class HomeAssistant: @overload @callback def async_run_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R]], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R]], *args: Any ) -> asyncio.Future[_R] | None: ... @overload @callback def async_run_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: ... @callback def async_run_hass_job( - self, hassjob: HassJob[Coroutine[Any, Any, _R] | _R], *args: Any + self, hassjob: HassJob[..., Coroutine[Any, Any, _R] | _R], *args: Any ) -> asyncio.Future[_R] | None: """Run a HassJob from within the event loop. @@ -814,7 +816,7 @@ class Event: class _FilterableJob(NamedTuple): """Event listener job to be executed with optional filter.""" - job: HassJob[None | Awaitable[None]] + job: HassJob[[Event], None | Awaitable[None]] event_filter: Callable[[Event], bool] | None run_immediately: bool diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c1229dc3e7c..c9b569c6601 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -258,7 +258,9 @@ def _async_track_state_change_event( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """async_track_state_change_event without lowercasing.""" - entity_callbacks = hass.data.setdefault(TRACK_STATE_CHANGE_CALLBACKS, {}) + entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_STATE_CHANGE_CALLBACKS, {} + ) if TRACK_STATE_CHANGE_LISTENER not in hass.data: @@ -319,10 +321,10 @@ def _async_remove_indexed_listeners( data_key: str, listener_key: str, storage_keys: Iterable[str], - job: HassJob[Any], + job: HassJob[[Event], Any], ) -> None: """Remove a listener.""" - callbacks = hass.data[data_key] + callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data[data_key] for storage_key in storage_keys: callbacks[storage_key].remove(job) @@ -347,7 +349,9 @@ def async_track_entity_registry_updated_event( if not (entity_ids := _async_string_to_lower_list(entity_ids)): return _remove_empty_listener - entity_callbacks = hass.data.setdefault(TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {}) + entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {} + ) if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: @@ -401,7 +405,7 @@ def async_track_entity_registry_updated_event( @callback def _async_dispatch_domain_event( - hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[Any]]] + hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]] ) -> None: domain = split_entity_id(event.data["entity_id"])[0] @@ -438,7 +442,9 @@ def _async_track_state_added_domain( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """async_track_state_added_domain without lowercasing.""" - domain_callbacks = hass.data.setdefault(TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {}) + domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {} + ) if TRACK_STATE_ADDED_DOMAIN_LISTENER not in hass.data: @@ -490,7 +496,9 @@ def async_track_state_removed_domain( if not (domains := _async_string_to_lower_list(domains)): return _remove_empty_listener - domain_callbacks = hass.data.setdefault(TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {}) + domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( + TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {} + ) if TRACK_STATE_REMOVED_DOMAIN_LISTENER not in hass.data: @@ -1249,7 +1257,7 @@ track_same_state = threaded_listener_factory(async_track_same_state) @bind_hass def async_track_point_in_time( hass: HomeAssistant, - action: HassJob[Awaitable[None] | None] + action: HassJob[[datetime], Awaitable[None] | None] | Callable[[datetime], Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: @@ -1271,7 +1279,7 @@ track_point_in_time = threaded_listener_factory(async_track_point_in_time) @bind_hass def async_track_point_in_utc_time( hass: HomeAssistant, - action: HassJob[Awaitable[None] | None] + action: HassJob[[datetime], Awaitable[None] | None] | Callable[[datetime], Awaitable[None] | None], point_in_time: datetime, ) -> CALLBACK_TYPE: @@ -1284,7 +1292,7 @@ def async_track_point_in_utc_time( cancel_callback: asyncio.TimerHandle | None = None @callback - def run_action(job: HassJob[Awaitable[None] | None]) -> None: + def run_action(job: HassJob[[datetime], Awaitable[None] | None]) -> None: """Call the action.""" nonlocal cancel_callback @@ -1324,7 +1332,7 @@ track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_tim def async_call_later( hass: HomeAssistant, delay: float | timedelta, - action: HassJob[Awaitable[None] | None] + action: HassJob[[datetime], Awaitable[None] | None] | Callable[[datetime], Awaitable[None] | None], ) -> CALLBACK_TYPE: """Add a listener that is called in .""" @@ -1345,7 +1353,7 @@ def async_track_time_interval( ) -> CALLBACK_TYPE: """Add a listener that fires repetitively at every timedelta interval.""" remove: CALLBACK_TYPE - interval_listener_job: HassJob[None] + interval_listener_job: HassJob[[datetime], None] job = HassJob(action) @@ -1382,7 +1390,7 @@ class SunListener: """Helper class to help listen to sun events.""" hass: HomeAssistant = attr.ib() - job: HassJob[Awaitable[None] | None] = attr.ib() + job: HassJob[[], Awaitable[None] | None] = attr.ib() event: str = attr.ib() offset: timedelta | None = attr.ib() _unsub_sun: CALLBACK_TYPE | None = attr.ib(default=None) From 7e2f4ebd5c5547ac4a94c6b5f849737b69326338 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Mon, 30 May 2022 09:45:33 +0200 Subject: [PATCH 079/947] Plugwise: correct config_flow strings (#72554) --- .../components/plugwise/strings.json | 20 +------------------ .../components/plugwise/translations/en.json | 20 +------------------ 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index a350543ee07..42dcee96196 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -1,24 +1,7 @@ { - "options": { - "step": { - "init": { - "description": "Adjust Plugwise Options", - "data": { - "scan_interval": "Scan Interval (seconds)" - } - } - } - }, "config": { "step": { "user": { - "title": "Plugwise type", - "description": "Product:", - "data": { - "flow_type": "Connection type" - } - }, - "user_gateway": { "title": "Connect to the Smile", "description": "Please enter", "data": { @@ -37,7 +20,6 @@ }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" - }, - "flow_title": "{name}" + } } } diff --git a/homeassistant/components/plugwise/translations/en.json b/homeassistant/components/plugwise/translations/en.json index 616e450cb11..48d3d2d0e46 100644 --- a/homeassistant/components/plugwise/translations/en.json +++ b/homeassistant/components/plugwise/translations/en.json @@ -9,16 +9,8 @@ "invalid_setup": "Add your Adam instead of your Anna, see the Home Assistant Plugwise integration documentation for more information", "unknown": "Unexpected error" }, - "flow_title": "{name}", "step": { "user": { - "data": { - "flow_type": "Connection type" - }, - "description": "Product:", - "title": "Plugwise type" - }, - "user_gateway": { "data": { "host": "IP Address", "password": "Smile ID", @@ -29,15 +21,5 @@ "title": "Connect to the Smile" } } - }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Scan Interval (seconds)" - }, - "description": "Adjust Plugwise Options" - } - } } -} \ No newline at end of file +} From c8f677ce4c8b19c2cf1cc028f2fa27a37bf34563 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 May 2022 11:40:36 +0200 Subject: [PATCH 080/947] Bump hatasmota to 0.5.1 (#72696) --- .../components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_cover.py | 45 ++++++++++++------- 4 files changed, 31 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 772105043fe..4268c4198b2 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -3,7 +3,7 @@ "name": "Tasmota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tasmota", - "requirements": ["hatasmota==0.5.0"], + "requirements": ["hatasmota==0.5.1"], "dependencies": ["mqtt"], "mqtt": ["tasmota/discovery/#"], "codeowners": ["@emontnemery"], diff --git a/requirements_all.txt b/requirements_all.txt index a7d649b4702..de4bcbef89e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -795,7 +795,7 @@ hass-nabucasa==0.54.0 hass_splunk==0.1.1 # homeassistant.components.tasmota -hatasmota==0.5.0 +hatasmota==0.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f461428c796..c236a33697b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -571,7 +571,7 @@ hangups==0.4.18 hass-nabucasa==0.54.0 # homeassistant.components.tasmota -hatasmota==0.5.0 +hatasmota==0.5.1 # homeassistant.components.jewish_calendar hdate==0.10.4 diff --git a/tests/components/tasmota/test_cover.py b/tests/components/tasmota/test_cover.py index 843fd72ecf1..06471e11757 100644 --- a/tests/components/tasmota/test_cover.py +++ b/tests/components/tasmota/test_cover.py @@ -111,7 +111,7 @@ async def test_tilt_support(hass, mqtt_mock, setup_tasmota): assert state.attributes["supported_features"] == COVER_SUPPORT -async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): +async def test_controlling_state_via_mqtt_tilt(hass, mqtt_mock, setup_tasmota): """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 3 @@ -281,7 +281,10 @@ async def test_controlling_state_via_mqtt(hass, mqtt_mock, setup_tasmota): assert state.attributes["current_position"] == 100 -async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmota): +@pytest.mark.parametrize("tilt", ("", ',"Tilt":0')) +async def test_controlling_state_via_mqtt_inverted( + hass, mqtt_mock, setup_tasmota, tilt +): """Test state update via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) config["rl"][0] = 3 @@ -310,7 +313,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":54,"Direction":-1}}', + '{"Shutter1":{"Position":54,"Direction":-1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" @@ -319,21 +322,25 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":100,"Direction":1}}', + '{"Shutter1":{"Position":100,"Direction":1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" assert state.attributes["current_position"] == 0 async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"Shutter1":{"Position":0,"Direction":0}}' + hass, + "tasmota_49A3BC/tele/SENSOR", + '{"Shutter1":{"Position":0,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" assert state.attributes["current_position"] == 100 async_fire_mqtt_message( - hass, "tasmota_49A3BC/tele/SENSOR", '{"Shutter1":{"Position":99,"Direction":0}}' + hass, + "tasmota_49A3BC/tele/SENSOR", + '{"Shutter1":{"Position":99,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -342,7 +349,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/tele/SENSOR", - '{"Shutter1":{"Position":100,"Direction":0}}', + '{"Shutter1":{"Position":100,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" @@ -352,7 +359,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":54,"Direction":-1}}}', + '{"StatusSNS":{"Shutter1":{"Position":54,"Direction":-1' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" @@ -361,7 +368,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":1}}}', + '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":1' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" @@ -370,7 +377,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":0,"Direction":0}}}', + '{"StatusSNS":{"Shutter1":{"Position":0,"Direction":0' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -379,7 +386,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":99,"Direction":0}}}', + '{"StatusSNS":{"Shutter1":{"Position":99,"Direction":0' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -388,7 +395,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/STATUS10", - '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":0}}}', + '{"StatusSNS":{"Shutter1":{"Position":100,"Direction":0' + tilt + "}}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" @@ -398,7 +405,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":54,"Direction":-1}}', + '{"Shutter1":{"Position":54,"Direction":-1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "opening" @@ -407,21 +414,25 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":100,"Direction":1}}', + '{"Shutter1":{"Position":100,"Direction":1' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closing" assert state.attributes["current_position"] == 0 async_fire_mqtt_message( - hass, "tasmota_49A3BC/stat/RESULT", '{"Shutter1":{"Position":0,"Direction":0}}' + hass, + "tasmota_49A3BC/stat/RESULT", + '{"Shutter1":{"Position":0,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" assert state.attributes["current_position"] == 100 async_fire_mqtt_message( - hass, "tasmota_49A3BC/stat/RESULT", '{"Shutter1":{"Position":1,"Direction":0}}' + hass, + "tasmota_49A3BC/stat/RESULT", + '{"Shutter1":{"Position":1,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "open" @@ -430,7 +441,7 @@ async def test_controlling_state_via_mqtt_inverted(hass, mqtt_mock, setup_tasmot async_fire_mqtt_message( hass, "tasmota_49A3BC/stat/RESULT", - '{"Shutter1":{"Position":100,"Direction":0}}', + '{"Shutter1":{"Position":100,"Direction":0' + tilt + "}}", ) state = hass.states.get("cover.tasmota_cover_1") assert state.state == "closed" From 3a0111e65de0150f3b487918944eb6067d38f139 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 30 May 2022 12:00:13 +0200 Subject: [PATCH 081/947] Use supervisor envs instead of hassio (#72601) --- homeassistant/bootstrap.py | 2 +- homeassistant/components/hassio/__init__.py | 4 ++-- homeassistant/components/hassio/auth.py | 2 +- homeassistant/components/hassio/handler.py | 2 +- homeassistant/components/hassio/http.py | 2 +- homeassistant/components/hassio/ingress.py | 2 +- homeassistant/components/hassio/system_health.py | 4 ++-- tests/components/hassio/__init__.py | 2 +- tests/components/hassio/conftest.py | 8 ++++---- tests/components/hassio/test_binary_sensor.py | 2 +- tests/components/hassio/test_diagnostics.py | 2 +- tests/components/hassio/test_init.py | 4 ++-- tests/components/hassio/test_sensor.py | 2 +- tests/components/hassio/test_update.py | 2 +- tests/components/http/test_ban.py | 4 ++-- tests/components/onboarding/test_views.py | 4 ++-- tests/test_bootstrap.py | 2 +- 17 files changed, 25 insertions(+), 25 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 986171cbee7..eabbbb49362 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -398,7 +398,7 @@ def _get_domains(hass: core.HomeAssistant, config: dict[str, Any]) -> set[str]: domains.update(hass.config_entries.async_domains()) # Make sure the Hass.io component is loaded - if "HASSIO" in os.environ: + if "SUPERVISOR" in os.environ: domains.add("hassio") return domains diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 93d902e4bae..cab17c94f0c 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -505,7 +505,7 @@ def get_supervisor_ip() -> str: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901 """Set up the Hass.io component.""" # Check local setup - for env in ("HASSIO", "HASSIO_TOKEN"): + for env in ("SUPERVISOR", "SUPERVISOR_TOKEN"): if os.environ.get(env): continue _LOGGER.error("Missing %s environment variable", env) @@ -517,7 +517,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: async_load_websocket_api(hass) - host = os.environ["HASSIO"] + host = os.environ["SUPERVISOR"] websession = async_get_clientsession(hass) hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host) diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index 2d76a758096..f52a8ef0617 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -42,7 +42,7 @@ class HassIOBaseAuth(HomeAssistantView): def _check_access(self, request: web.Request): """Check if this call is from Supervisor.""" # Check caller IP - hassio_ip = os.environ["HASSIO"].split(":")[0] + hassio_ip = os.environ["SUPERVISOR"].split(":")[0] if ip_address(request.transport.get_extra_info("peername")[0]) != ip_address( hassio_ip ): diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 4146753b753..ba1b3bfaf35 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -246,7 +246,7 @@ class HassIO: method, f"http://{self._ip}{command}", json=payload, - headers={X_HASSIO: os.environ.get("HASSIO_TOKEN", "")}, + headers={X_HASSIO: os.environ.get("SUPERVISOR_TOKEN", "")}, timeout=aiohttp.ClientTimeout(total=timeout), ) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 532b947ac49..63ac1521cc5 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -124,7 +124,7 @@ class HassIOView(HomeAssistantView): def _init_header(request: web.Request) -> dict[str, str]: """Create initial header.""" headers = { - X_HASSIO: os.environ.get("HASSIO_TOKEN", ""), + X_HASSIO: os.environ.get("SUPERVISOR_TOKEN", ""), CONTENT_TYPE: request.content_type, } diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 284ba42b3c1..c8b56e6f1bb 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -183,7 +183,7 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st headers[name] = value # Inject token / cleanup later on Supervisor - headers[X_HASSIO] = os.environ.get("HASSIO_TOKEN", "") + headers[X_HASSIO] = os.environ.get("SUPERVISOR_TOKEN", "") # Ingress information headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" diff --git a/homeassistant/components/hassio/system_health.py b/homeassistant/components/hassio/system_health.py index 1039a0237a8..b1fc208de80 100644 --- a/homeassistant/components/hassio/system_health.py +++ b/homeassistant/components/hassio/system_health.py @@ -6,8 +6,8 @@ from homeassistant.core import HomeAssistant, callback from . import get_host_info, get_info, get_os_info, get_supervisor_info -SUPERVISOR_PING = f"http://{os.environ['HASSIO']}/supervisor/ping" -OBSERVER_URL = f"http://{os.environ['HASSIO']}:4357" +SUPERVISOR_PING = f"http://{os.environ['SUPERVISOR']}/supervisor/ping" +OBSERVER_URL = f"http://{os.environ['SUPERVISOR']}:4357" @callback diff --git a/tests/components/hassio/__init__.py b/tests/components/hassio/__init__.py index 79520c6fd12..76aecd64098 100644 --- a/tests/components/hassio/__init__.py +++ b/tests/components/hassio/__init__.py @@ -1,2 +1,2 @@ """Tests for Hass.io component.""" -HASSIO_TOKEN = "123456" +SUPERVISOR_TOKEN = "123456" diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 89a8c6f5c51..a6cd956c95e 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -9,16 +9,16 @@ from homeassistant.core import CoreState from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.setup import async_setup_component -from . import HASSIO_TOKEN +from . import SUPERVISOR_TOKEN @pytest.fixture def hassio_env(): """Fixture to inject hassio env.""" - with patch.dict(os.environ, {"HASSIO": "127.0.0.1"}), patch( + with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value={"result": "ok", "data": {}}, - ), patch.dict(os.environ, {"HASSIO_TOKEN": HASSIO_TOKEN}), patch( + ), patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}), patch( "homeassistant.components.hassio.HassIO.get_info", Mock(side_effect=HassioAPIError()), ): @@ -75,5 +75,5 @@ def hassio_handler(hass, aioclient_mock): websession = hass.loop.run_until_complete(get_client_session()) - with patch.dict(os.environ, {"HASSIO_TOKEN": HASSIO_TOKEN}): + with patch.dict(os.environ, {"SUPERVISOR_TOKEN": SUPERVISOR_TOKEN}): yield HassIO(hass.loop, websession, "127.0.0.1") diff --git a/tests/components/hassio/test_binary_sensor.py b/tests/components/hassio/test_binary_sensor.py index 0f4691e2795..ba9bcb2afdf 100644 --- a/tests/components/hassio/test_binary_sensor.py +++ b/tests/components/hassio/test_binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/hassio/test_diagnostics.py b/tests/components/hassio/test_diagnostics.py index 7bbc768681a..1f915e17e61 100644 --- a/tests/components/hassio/test_diagnostics.py +++ b/tests/components/hassio/test_diagnostics.py @@ -13,7 +13,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.diagnostics import get_diagnostics_for_config_entry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index ff595aaa602..c47b3bfbeca 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -17,7 +17,7 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture() @@ -336,7 +336,7 @@ async def test_setup_core_push_timezone(hass, aioclient_mock): async def test_setup_hassio_no_additional_data(hass, aioclient_mock): """Test setup with API push default data.""" with patch.dict(os.environ, MOCK_ENVIRON), patch.dict( - os.environ, {"HASSIO_TOKEN": "123456"} + os.environ, {"SUPERVISOR_TOKEN": "123456"} ): result = await async_setup_component(hass, "hassio", {"hassio": {}}) assert result diff --git a/tests/components/hassio/test_sensor.py b/tests/components/hassio/test_sensor.py index 382d804eaac..868448cec2d 100644 --- a/tests/components/hassio/test_sensor.py +++ b/tests/components/hassio/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/hassio/test_update.py b/tests/components/hassio/test_update.py index 14d1d06ef38..48f6d894de0 100644 --- a/tests/components/hassio/test_update.py +++ b/tests/components/hassio/test_update.py @@ -12,7 +12,7 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -MOCK_ENVIRON = {"HASSIO": "127.0.0.1", "HASSIO_TOKEN": "abcdefgh"} +MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture(autouse=True) diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index fbd545e0506..5e482d16248 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -33,10 +33,10 @@ BANNED_IPS_WITH_SUPERVISOR = BANNED_IPS + [SUPERVISOR_IP] @pytest.fixture(name="hassio_env") def hassio_env_fixture(): """Fixture to inject hassio env.""" - with patch.dict(os.environ, {"HASSIO": "127.0.0.1"}), patch( + with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value={"result": "ok", "data": {}}, - ), patch.dict(os.environ, {"HASSIO_TOKEN": "123456"}): + ), patch.dict(os.environ, {"SUPERVISOR_TOKEN": "123456"}): yield diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 025459e73b7..982f5b86e65 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -57,7 +57,7 @@ async def mock_supervisor_fixture(hass, aioclient_mock): """Mock supervisor.""" aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) - with patch.dict(os.environ, {"HASSIO": "127.0.0.1"}), patch( + with patch.dict(os.environ, {"SUPERVISOR": "127.0.0.1"}), patch( "homeassistant.components.hassio.HassIO.is_connected", return_value=True, ), patch( @@ -79,7 +79,7 @@ async def mock_supervisor_fixture(hass, aioclient_mock): "homeassistant.components.hassio.HassIO.get_ingress_panels", return_value={"panels": {}}, ), patch.dict( - os.environ, {"HASSIO_TOKEN": "123456"} + os.environ, {"SUPERVISOR_TOKEN": "123456"} ): yield diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index c6c507a0f73..232d8fb6bbf 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -84,7 +84,7 @@ async def test_load_hassio(hass): with patch.dict(os.environ, {}, clear=True): assert bootstrap._get_domains(hass, {}) == set() - with patch.dict(os.environ, {"HASSIO": "1"}): + with patch.dict(os.environ, {"SUPERVISOR": "1"}): assert bootstrap._get_domains(hass, {}) == {"hassio"} From 42bcd0263ca3357d40e9c29cde9ab706494e841a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 May 2022 03:38:52 -0700 Subject: [PATCH 082/947] Allow removing a ring device (#72665) --- homeassistant/components/ring/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index a1ed1ac017b..0d8f87eef3c 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -16,6 +16,7 @@ from ring_doorbell import Auth, Ring from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform, __version__ from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util.async_ import run_callback_threadsafe @@ -146,6 +147,13 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + return True + + class GlobalDataUpdater: """Data storage for single API endpoint.""" From 342ccb5bf1d0c134b4aea2309764566c7ea296e1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 May 2022 14:21:20 +0200 Subject: [PATCH 083/947] Improve handling of MQTT overridden settings (#72698) * Improve handling of MQTT overridden settings * Don't warn unless config entry overrides yaml --- homeassistant/components/mqtt/__init__.py | 13 +++++++------ tests/components/mqtt/test_init.py | 5 ----- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 78f64387435..1728dd7f2c7 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -685,14 +685,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # User has configuration.yaml config, warn about config entry overrides elif any(key in conf for key in entry.data): shared_keys = conf.keys() & entry.data.keys() - override = {k: entry.data[k] for k in shared_keys} + override = {k: entry.data[k] for k in shared_keys if conf[k] != entry.data[k]} if CONF_PASSWORD in override: override[CONF_PASSWORD] = "********" - _LOGGER.warning( - "Deprecated configuration settings found in configuration.yaml. " - "These settings from your configuration entry will override: %s", - override, - ) + if override: + _LOGGER.warning( + "Deprecated configuration settings found in configuration.yaml. " + "These settings from your configuration entry will override: %s", + override, + ) # Merge advanced configuration values from configuration.yaml conf = _merge_extended_config(entry, conf) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a370bd67ec1..07c39d70df0 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1715,11 +1715,6 @@ async def test_update_incomplete_entry( "The 'broker' option is deprecated, please remove it from your configuration" in caplog.text ) - assert ( - "Deprecated configuration settings found in configuration.yaml. These settings " - "from your configuration entry will override: {'broker': 'yaml_broker'}" - in caplog.text - ) # Discover a device to verify the entry was setup correctly async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) From 84243cf560fef865d51167256c7b2c0d09ad5b8d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 30 May 2022 14:25:36 +0200 Subject: [PATCH 084/947] Tweak MQTT hassio discovery flow (#72699) --- homeassistant/components/mqtt/config_flow.py | 1 - tests/components/mqtt/test_config_flow.py | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 6697b17dfdc..0a763e850e5 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -120,7 +120,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): CONF_PORT: data[CONF_PORT], CONF_USERNAME: data.get(CONF_USERNAME), CONF_PASSWORD: data.get(CONF_PASSWORD), - CONF_PROTOCOL: data.get(CONF_PROTOCOL), CONF_DISCOVERY: DEFAULT_DISCOVERY, }, ) diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f0e02ad8a3a..565fa7fda53 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -235,7 +235,8 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "port": 1883, "username": "mock-user", "password": "mock-pass", - "protocol": "3.1.1", + "protocol": "3.1.1", # Set by the addon's discovery, ignored by HA + "ssl": False, # Set by the addon's discovery, ignored by HA } ), context={"source": config_entries.SOURCE_HASSIO}, @@ -255,7 +256,6 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set "port": 1883, "username": "mock-user", "password": "mock-pass", - "protocol": "3.1.1", "discovery": True, } # Check we tried the connection From b7040efef6f07c38c39fed27cddb3ec9d6e34778 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 30 May 2022 14:26:01 +0200 Subject: [PATCH 085/947] Cleanup and use new MQTT_BASE_SCHEMA constants (#72283) * Use new MQTT_BASE_SCHEMA constants * Update constants for mqtt_room and manual_mqtt * Revert removing platform key --- .../manual_mqtt/alarm_control_panel.py | 2 +- homeassistant/components/mqtt/__init__.py | 18 ------------------ .../components/mqtt/device_automation.py | 6 ++++-- .../components/mqtt/device_trigger.py | 19 +++++++++++++------ homeassistant/components/mqtt/tag.py | 2 +- homeassistant/components/mqtt_room/sensor.py | 2 +- 6 files changed, 20 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 730d7ae1f9e..5b74af49a91 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -110,7 +110,7 @@ def _state_schema(state): PLATFORM_SCHEMA = vol.Schema( vol.All( - mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( + mqtt.MQTT_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "manual_mqtt", vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 1728dd7f2c7..46eb7052f4f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -261,24 +261,6 @@ SCHEMA_BASE = { MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE) -# Will be removed when all platforms support a modern platform schema -MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE) -# Will be removed when all platforms support a modern platform schema -MQTT_RO_PLATFORM_SCHEMA = MQTT_BASE_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - } -) -# Will be removed when all platforms support a modern platform schema -MQTT_RW_PLATFORM_SCHEMA = MQTT_BASE_PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, - } -) - # Sensor type platforms subscribe to MQTT events MQTT_RO_SCHEMA = MQTT_BASE_SCHEMA.extend( { diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index cafbd66b098..002ae6e3991 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -3,6 +3,8 @@ import functools import voluptuous as vol +import homeassistant.helpers.config_validation as cv + from . import device_trigger from .. import mqtt from .mixins import async_setup_entry_helper @@ -12,10 +14,10 @@ AUTOMATION_TYPES = [AUTOMATION_TYPE_TRIGGER] AUTOMATION_TYPES_SCHEMA = vol.In(AUTOMATION_TYPES) CONF_AUTOMATION_TYPE = "automation_type" -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( {vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA}, extra=vol.ALLOW_EXTRA, -) +).extend(mqtt.MQTT_BASE_SCHEMA.schema) async def async_setup_entry(hass, config_entry): diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 42ffcee1644..2c6c6ecc3ba 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -30,7 +30,14 @@ from homeassistant.helpers.typing import ConfigType from . import debug_info, trigger as mqtt_trigger from .. import mqtt -from .const import ATTR_DISCOVERY_HASH, CONF_PAYLOAD, CONF_QOS, CONF_TOPIC, DOMAIN +from .const import ( + ATTR_DISCOVERY_HASH, + CONF_ENCODING, + CONF_PAYLOAD, + CONF_QOS, + CONF_TOPIC, + DOMAIN, +) from .discovery import MQTT_DISCOVERY_DONE from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, @@ -64,7 +71,7 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( } ) -TRIGGER_DISCOVERY_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( +TRIGGER_DISCOVERY_SCHEMA = mqtt.MQTT_BASE_SCHEMA.extend( { vol.Required(CONF_AUTOMATION_TYPE): str, vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, @@ -94,10 +101,10 @@ class TriggerInstance: async def async_attach_trigger(self) -> None: """Attach MQTT trigger.""" mqtt_config = { - mqtt_trigger.CONF_PLATFORM: mqtt.DOMAIN, - mqtt_trigger.CONF_TOPIC: self.trigger.topic, - mqtt_trigger.CONF_ENCODING: DEFAULT_ENCODING, - mqtt_trigger.CONF_QOS: self.trigger.qos, + CONF_PLATFORM: mqtt.DOMAIN, + CONF_TOPIC: self.trigger.topic, + CONF_ENCODING: DEFAULT_ENCODING, + CONF_QOS: self.trigger.qos, } if self.trigger.payload: mqtt_config[CONF_PAYLOAD] = self.trigger.payload diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 5bfbbd73bce..25e49524b8f 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -30,7 +30,7 @@ LOG_NAME = "Tag" TAG = "tag" TAGS = "mqtt_tags" -PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( +PLATFORM_SCHEMA = mqtt.MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, vol.Optional(CONF_PLATFORM): "mqtt", diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 8f7455eb998..54de561c11e 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -43,7 +43,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_AWAY_TIMEOUT, default=DEFAULT_AWAY_TIMEOUT): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } -).extend(mqtt.MQTT_RO_PLATFORM_SCHEMA.schema) +).extend(mqtt.MQTT_RO_SCHEMA.schema) MQTT_PAYLOAD = vol.Schema( vol.All( From ce94168c50fadd5549516db99d632c3e0de6fe22 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 30 May 2022 16:25:02 +0300 Subject: [PATCH 086/947] Remove YAML support for glances (#72706) --- homeassistant/components/glances/__init__.py | 52 ++----------------- .../components/glances/config_flow.py | 5 -- 2 files changed, 3 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py index 6272015e73c..571214deb20 100644 --- a/homeassistant/components/glances/__init__.py +++ b/homeassistant/components/glances/__init__.py @@ -3,17 +3,12 @@ from datetime import timedelta import logging from glances_api import Glances, exceptions -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_NAME, - CONF_PASSWORD, - CONF_PORT, CONF_SCAN_INTERVAL, - CONF_SSL, - CONF_USERNAME, CONF_VERIFY_SSL, Platform, ) @@ -23,55 +18,14 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.helpers.typing import ConfigType -from .const import ( - CONF_VERSION, - DATA_UPDATED, - DEFAULT_HOST, - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DEFAULT_VERSION, - DOMAIN, -) +from .const import DATA_UPDATED, DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -GLANCES_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]), - } - ) -) - -CONFIG_SCHEMA = vol.Schema( - vol.All(cv.deprecated(DOMAIN), {DOMAIN: vol.All(cv.ensure_list, [GLANCES_SCHEMA])}), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Configure Glances using config flow only.""" - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 1ee7c2fa476..72bfa6dd917 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -82,11 +82,6 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_import(self, import_config): - """Import from Glances sensor config.""" - - return await self.async_step_user(user_input=import_config) - class GlancesOptionsFlowHandler(config_entries.OptionsFlow): """Handle Glances client options.""" From dd5b1681e7bbc26da963809b861dbe20edf5d287 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 30 May 2022 16:34:28 +0300 Subject: [PATCH 087/947] Remove YAML configuration from mikrotik (#72581) --- homeassistant/components/mikrotik/__init__.py | 65 +---------------- .../components/mikrotik/config_flow.py | 8 --- tests/components/mikrotik/__init__.py | 33 ++++++--- tests/components/mikrotik/test_config_flow.py | 70 ++++++++----------- tests/components/mikrotik/test_hub.py | 26 ++++--- 5 files changed, 73 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 1ef250a3f4e..25aa2eb1468 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -1,71 +1,12 @@ """The Mikrotik component.""" -import voluptuous as vol - -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_MANUFACTURER, - CONF_ARP_PING, - CONF_DETECTION_TIME, - CONF_FORCE_DHCP, - DEFAULT_API_PORT, - DEFAULT_DETECTION_TIME, - DEFAULT_NAME, - DOMAIN, - PLATFORMS, -) +from .const import ATTR_MANUFACTURER, DOMAIN, PLATFORMS from .hub import MikrotikHub -MIKROTIK_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_API_PORT): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=False): cv.boolean, - vol.Optional(CONF_ARP_PING, default=False): cv.boolean, - vol.Optional(CONF_FORCE_DHCP, default=False): cv.boolean, - vol.Optional( - CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME - ): cv.time_period, - } - ) -) - - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), {DOMAIN: vol.All(cv.ensure_list, [MIKROTIK_SCHEMA])} - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Mikrotik component from config.""" - - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 922df221d5a..11117d22842 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -74,14 +74,6 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config): - """Import Miktortik from config.""" - - import_config[CONF_DETECTION_TIME] = import_config[ - CONF_DETECTION_TIME - ].total_seconds() - return await self.async_step_user(user_input=import_config) - class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): """Handle Mikrotik options.""" diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index ae8013eff4b..885fc9c8d83 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -1,19 +1,32 @@ """Tests for the Mikrotik component.""" -from homeassistant.components import mikrotik +from homeassistant.components.mikrotik.const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DEFAULT_DETECTION_TIME, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) MOCK_DATA = { - mikrotik.CONF_NAME: "Mikrotik", - mikrotik.CONF_HOST: "0.0.0.0", - mikrotik.CONF_USERNAME: "user", - mikrotik.CONF_PASSWORD: "pass", - mikrotik.CONF_PORT: 8278, - mikrotik.CONF_VERIFY_SSL: False, + CONF_NAME: "Mikrotik", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, } MOCK_OPTIONS = { - mikrotik.CONF_ARP_PING: False, - mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.CONF_DETECTION_TIME: mikrotik.DEFAULT_DETECTION_TIME, + CONF_ARP_PING: False, + CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: DEFAULT_DETECTION_TIME, } DEVICE_1_DHCP = { diff --git a/tests/components/mikrotik/test_config_flow.py b/tests/components/mikrotik/test_config_flow.py index 411408e8c98..b4c087a436d 100644 --- a/tests/components/mikrotik/test_config_flow.py +++ b/tests/components/mikrotik/test_config_flow.py @@ -6,7 +6,12 @@ import librouteros import pytest from homeassistant import config_entries, data_entry_flow -from homeassistant.components import mikrotik +from homeassistant.components.mikrotik.const import ( + CONF_ARP_PING, + CONF_DETECTION_TIME, + CONF_FORCE_DHCP, + DOMAIN, +) from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -34,9 +39,9 @@ DEMO_CONFIG = { CONF_PASSWORD: "password", CONF_PORT: 8278, CONF_VERIFY_SSL: False, - mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.CONF_ARP_PING: False, - mikrotik.CONF_DETECTION_TIME: timedelta(seconds=30), + CONF_FORCE_DHCP: False, + CONF_ARP_PING: False, + CONF_DETECTION_TIME: timedelta(seconds=30), } DEMO_CONFIG_ENTRY = { @@ -46,9 +51,9 @@ DEMO_CONFIG_ENTRY = { CONF_PASSWORD: "password", CONF_PORT: 8278, CONF_VERIFY_SSL: False, - mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.CONF_ARP_PING: False, - mikrotik.CONF_DETECTION_TIME: 30, + CONF_FORCE_DHCP: False, + CONF_ARP_PING: False, + CONF_DETECTION_TIME: 30, } @@ -78,29 +83,11 @@ def mock_api_connection_error(): yield -async def test_import(hass, api): - """Test import step.""" - result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=DEMO_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Home router" - assert result["data"][CONF_NAME] == "Home router" - assert result["data"][CONF_HOST] == "0.0.0.0" - assert result["data"][CONF_USERNAME] == "username" - assert result["data"][CONF_PASSWORD] == "password" - assert result["data"][CONF_PORT] == 8278 - assert result["data"][CONF_VERIFY_SSL] is False - - async def test_flow_works(hass, api): """Test config flow.""" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -118,11 +105,14 @@ async def test_flow_works(hass, api): assert result["data"][CONF_PORT] == 8278 -async def test_options(hass): +async def test_options(hass, api): """Test updating options.""" - entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(entry.entry_id) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -131,28 +121,28 @@ async def test_options(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - mikrotik.CONF_DETECTION_TIME: 30, - mikrotik.CONF_ARP_PING: True, - mikrotik.const.CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: 30, + CONF_ARP_PING: True, + CONF_FORCE_DHCP: False, }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { - mikrotik.CONF_DETECTION_TIME: 30, - mikrotik.CONF_ARP_PING: True, - mikrotik.const.CONF_FORCE_DHCP: False, + CONF_DETECTION_TIME: 30, + CONF_ARP_PING: True, + CONF_FORCE_DHCP: False, } async def test_host_already_configured(hass, auth_error): """Test host already configured.""" - entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT @@ -164,13 +154,13 @@ async def test_host_already_configured(hass, auth_error): async def test_name_exists(hass, api): """Test name already configured.""" - entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=DEMO_CONFIG_ENTRY) + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) entry.add_to_hass(hass) user_input = DEMO_USER_INPUT.copy() user_input[CONF_HOST] = "0.0.0.1" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=user_input @@ -184,7 +174,7 @@ async def test_connection_error(hass, conn_error): """Test error when connection is unsuccessful.""" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT @@ -197,7 +187,7 @@ async def test_wrong_credentials(hass, auth_error): """Test error when credentials are wrong.""" result = await hass.config_entries.flow.async_init( - mikrotik.DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": config_entries.SOURCE_USER} ) result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=DEMO_USER_INPUT diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index 2159b58293b..2116d73826f 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -5,6 +5,14 @@ import librouteros from homeassistant import config_entries from homeassistant.components import mikrotik +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA @@ -56,17 +64,17 @@ async def test_hub_setup_successful(hass): hub = await setup_mikrotik_entry(hass) assert hub.config_entry.data == { - mikrotik.CONF_NAME: "Mikrotik", - mikrotik.CONF_HOST: "0.0.0.0", - mikrotik.CONF_USERNAME: "user", - mikrotik.CONF_PASSWORD: "pass", - mikrotik.CONF_PORT: 8278, - mikrotik.CONF_VERIFY_SSL: False, + CONF_NAME: "Mikrotik", + CONF_HOST: "0.0.0.0", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", + CONF_PORT: 8278, + CONF_VERIFY_SSL: False, } assert hub.config_entry.options == { - mikrotik.hub.CONF_FORCE_DHCP: False, - mikrotik.CONF_ARP_PING: False, - mikrotik.CONF_DETECTION_TIME: 300, + mikrotik.const.CONF_FORCE_DHCP: False, + mikrotik.const.CONF_ARP_PING: False, + mikrotik.const.CONF_DETECTION_TIME: 300, } assert hub.api.available is True From c10a52305539869493d158ade3ba32884f352ceb Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 30 May 2022 15:53:57 +0200 Subject: [PATCH 088/947] Sync fibaro entity visible state (#72379) --- homeassistant/components/fibaro/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index fdb0f894a40..bd7c3a09ec0 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -490,6 +490,9 @@ class FibaroDevice(Entity): self.ha_id = fibaro_device.ha_id self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str + # propagate hidden attribute set in fibaro home center to HA + if "visible" in fibaro_device and fibaro_device.visible is False: + self._attr_entity_registry_visible_default = False async def async_added_to_hass(self): """Call when entity is added to hass.""" From 30e71dd96f6fdfa875b2d209f67c10252e5d832a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 30 May 2022 09:09:14 -0500 Subject: [PATCH 089/947] Add support for Sonos loudness switch (#72572) --- homeassistant/components/sonos/speaker.py | 4 ++++ homeassistant/components/sonos/switch.py | 4 ++++ tests/components/sonos/conftest.py | 1 + tests/components/sonos/test_switch.py | 5 +++++ 4 files changed, 14 insertions(+) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 5d4199ec905..bd217cc9029 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -137,6 +137,7 @@ class SonosSpeaker: self.cross_fade: bool | None = None self.bass: int | None = None self.treble: int | None = None + self.loudness: bool | None = None # Home theater self.audio_delay: int | None = None @@ -506,6 +507,9 @@ class SonosSpeaker: if "mute" in variables: self.muted = variables["mute"]["Master"] == "1" + if loudness := variables.get("loudness"): + self.loudness = loudness["Master"] == "1" + for bool_var in ( "dialog_level", "night_mode", diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index b5fea08e418..29abb097df7 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -38,6 +38,7 @@ ATTR_VOLUME = "volume" ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" ATTR_CROSSFADE = "cross_fade" +ATTR_LOUDNESS = "loudness" ATTR_NIGHT_SOUND = "night_mode" ATTR_SPEECH_ENHANCEMENT = "dialog_level" ATTR_STATUS_LIGHT = "status_light" @@ -48,6 +49,7 @@ ATTR_TOUCH_CONTROLS = "buttons_enabled" ALL_FEATURES = ( ATTR_TOUCH_CONTROLS, ATTR_CROSSFADE, + ATTR_LOUDNESS, ATTR_NIGHT_SOUND, ATTR_SPEECH_ENHANCEMENT, ATTR_SUB_ENABLED, @@ -64,6 +66,7 @@ POLL_REQUIRED = ( FRIENDLY_NAMES = { ATTR_CROSSFADE: "Crossfade", + ATTR_LOUDNESS: "Loudness", ATTR_NIGHT_SOUND: "Night Sound", ATTR_SPEECH_ENHANCEMENT: "Speech Enhancement", ATTR_STATUS_LIGHT: "Status Light", @@ -73,6 +76,7 @@ FRIENDLY_NAMES = { } FEATURE_ICONS = { + ATTR_LOUDNESS: "mdi:bullhorn-variant", ATTR_NIGHT_SOUND: "mdi:chat-sleep", ATTR_SPEECH_ENHANCEMENT: "mdi:ear-hearing", ATTR_CROSSFADE: "mdi:swap-horizontal", diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 8c804f466d4..d493c9d50c9 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -108,6 +108,7 @@ def soco_fixture( mock_soco.mute = False mock_soco.night_mode = True mock_soco.dialog_level = True + mock_soco.loudness = True mock_soco.volume = 19 mock_soco.audio_delay = 2 mock_soco.bass = 1 diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 586ccf213b8..f224a1e187e 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -29,6 +29,7 @@ async def test_entity_registry(hass, async_autosetup_sonos): assert "media_player.zone_a" in entity_registry.entities assert "switch.sonos_alarm_14" in entity_registry.entities assert "switch.zone_a_status_light" in entity_registry.entities + assert "switch.zone_a_loudness" in entity_registry.entities assert "switch.zone_a_night_sound" in entity_registry.entities assert "switch.zone_a_speech_enhancement" in entity_registry.entities assert "switch.zone_a_subwoofer_enabled" in entity_registry.entities @@ -55,6 +56,10 @@ async def test_switch_attributes(hass, async_autosetup_sonos, soco): night_sound_state = hass.states.get(night_sound.entity_id) assert night_sound_state.state == STATE_ON + loudness = entity_registry.entities["switch.zone_a_loudness"] + loudness_state = hass.states.get(loudness.entity_id) + assert loudness_state.state == STATE_ON + speech_enhancement = entity_registry.entities["switch.zone_a_speech_enhancement"] speech_enhancement_state = hass.states.get(speech_enhancement.entity_id) assert speech_enhancement_state.state == STATE_ON From 3d19d2d24fe4a54e2032f2758bbefac9fcff52ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 May 2022 16:29:47 +0200 Subject: [PATCH 090/947] Adjust config flow type hints in withings (#72504) --- homeassistant/components/withings/config_flow.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index b447973af97..a4ac6597248 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -1,12 +1,15 @@ """Config flow for Withings.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any import voluptuous as vol from withings_api.common import AuthScope from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util import slugify @@ -29,7 +32,7 @@ class WithingsFlowHandler( return logging.getLogger(__name__) @property - def extra_authorize_data(self) -> dict: + def extra_authorize_data(self) -> dict[str, str]: """Extra data that needs to be appended to the authorize url.""" return { "scope": ",".join( @@ -42,12 +45,12 @@ class WithingsFlowHandler( ) } - async def async_oauth_create_entry(self, data: dict) -> dict: + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Override the create entry so user can select a profile.""" self._current_data = data return await self.async_step_profile(data) - async def async_step_profile(self, data: dict) -> dict: + async def async_step_profile(self, data: dict[str, Any]) -> FlowResult: """Prompt the user to select a user profile.""" errors = {} reauth_profile = ( @@ -77,7 +80,7 @@ class WithingsFlowHandler( errors=errors, ) - async def async_step_reauth(self, data: dict = None) -> dict: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Prompt user to re-authenticate.""" if data is not None: return await self.async_step_user() @@ -91,7 +94,7 @@ class WithingsFlowHandler( description_placeholders=placeholders, ) - async def async_step_finish(self, data: dict) -> dict: + async def async_step_finish(self, data: dict[str, Any]) -> FlowResult: """Finish the flow.""" self._current_data = {} From c48591ff29360ccf935d745da99f2d3740b058fb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 May 2022 16:30:11 +0200 Subject: [PATCH 091/947] Adjust config-flow type hints in denonavr (#72477) --- homeassistant/components/denonavr/config_flow.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/denonavr/config_flow.py b/homeassistant/components/denonavr/config_flow.py index 238c87bbf5e..fe6c05b3aca 100644 --- a/homeassistant/components/denonavr/config_flow.py +++ b/homeassistant/components/denonavr/config_flow.py @@ -48,7 +48,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict[str, Any] | None = None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -86,7 +88,7 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the Denon AVR flow.""" self.host = None self.serial_number = None @@ -105,7 +107,9 @@ class DenonAvrFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def async_step_user(self, user_input: dict[str, Any] | None = None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: From 5273e3ea9d7cb7c5e5f1ed262db98d2695a4c0bb Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 May 2022 16:30:41 +0200 Subject: [PATCH 092/947] Adjust config-flow type hints in motion_blinds (#72444) --- .../components/motion_blinds/config_flow.py | 34 +++++++++++++------ 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/motion_blinds/config_flow.py b/homeassistant/components/motion_blinds/config_flow.py index e40f22296cb..d861c989ee0 100644 --- a/homeassistant/components/motion_blinds/config_flow.py +++ b/homeassistant/components/motion_blinds/config_flow.py @@ -1,4 +1,8 @@ """Config flow to configure Motion Blinds using their WLAN API.""" +from __future__ import annotations + +from typing import Any + from motionblinds import MotionDiscovery import voluptuous as vol @@ -33,9 +37,11 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: return self.async_create_entry(title="", data=user_input) @@ -60,15 +66,17 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize the Motion Blinds flow.""" - self._host = None - self._ips = [] + self._host: str | None = None + self._ips: list[str] = [] self._config_settings = None @staticmethod @callback - def async_get_options_flow(config_entry) -> OptionsFlowHandler: + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler(config_entry) @@ -87,7 +95,9 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._host = discovery_info.ip return await self.async_step_connect() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} if user_input is not None: @@ -114,7 +124,9 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=CONFIG_SCHEMA, errors=errors ) - async def async_step_select(self, user_input=None): + async def async_step_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle multiple motion gateways found.""" if user_input is not None: self._host = user_input["select_ip"] @@ -124,9 +136,11 @@ class MotionBlindsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="select", data_schema=select_schema) - async def async_step_connect(self, user_input=None): + async def async_step_connect( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Connect to the Motion Gateway.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: key = user_input[CONF_API_KEY] From f2fde5c1f91f408c2d761f9b548ee5451e3fa654 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 May 2022 16:35:50 +0200 Subject: [PATCH 093/947] Adjust config-flow type hints in sharkiq (#72688) --- .../components/sharkiq/config_flow.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py index 4875e2b25e1..b0aae5259dd 100644 --- a/homeassistant/components/sharkiq/config_flow.py +++ b/homeassistant/components/sharkiq/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from typing import Any import aiohttp import async_timeout @@ -10,6 +12,7 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -19,7 +22,9 @@ SHARKIQ_SCHEMA = vol.Schema( ) -async def validate_input(hass: core.HomeAssistant, data): +async def _validate_input( + hass: core.HomeAssistant, data: Mapping[str, Any] +) -> dict[str, str]: """Validate the user input allows us to connect.""" ayla_api = get_ayla_api( username=data[CONF_USERNAME], @@ -45,14 +50,16 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def _async_validate_input(self, user_input): + async def _async_validate_input( + self, user_input: Mapping[str, Any] + ) -> tuple[dict[str, str] | None, dict[str, str]]: """Validate form input.""" errors = {} info = None # noinspection PyBroadException try: - info = await validate_input(self.hass, user_input) + info = await _validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -62,9 +69,11 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" return info, errors - async def async_step_user(self, user_input: dict | None = None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: info, errors = await self._async_validate_input(user_input) if info: @@ -76,9 +85,9 @@ class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=SHARKIQ_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input: dict | None = None): + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Handle re-auth if login is invalid.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: _, errors = await self._async_validate_input(user_input) From 57ed66725775c62ea9b7754cbb2ef043f7b270ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 May 2022 16:46:55 +0200 Subject: [PATCH 094/947] Remove YAML configuration from nzbget (#72424) --- homeassistant/components/nzbget/__init__.py | 60 ++----------------- .../components/nzbget/config_flow.py | 31 +++------- tests/components/nzbget/__init__.py | 18 ------ tests/components/nzbget/test_config_flow.py | 9 +-- tests/components/nzbget/test_init.py | 26 +------- 5 files changed, 18 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index cb906495d58..a29ea829bbc 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -1,31 +1,18 @@ """The NZBGet integration.""" import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_SSL, - CONF_USERNAME, - Platform, -) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL, Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_SPEED, DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, - DEFAULT_NAME, - DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SPEED_LIMIT, - DEFAULT_SSL, DOMAIN, SERVICE_PAUSE, SERVICE_RESUME, @@ -35,54 +22,17 @@ from .coordinator import NZBGetDataUpdateCoordinator PLATFORMS = [Platform.SENSOR, Platform.SWITCH] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - } - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) SPEED_LIMIT_SCHEMA = vol.Schema( {vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.positive_int} ) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the NZBGet integration.""" - hass.data.setdefault(DOMAIN, {}) - - if hass.config_entries.async_entries(DOMAIN): - return True - - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NZBGet from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + if not entry.options: options = { CONF_SCAN_INTERVAL: entry.data.get( diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index c7a1699a86c..732ef879762 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( @@ -33,7 +33,7 @@ from .coordinator import NZBGetAPI, NZBGetAPIException _LOGGER = logging.getLogger(__name__) -def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: +def _validate_input(data: dict[str, Any]) -> None: """Validate the user input allows us to connect. Data has the keys from DATA_SCHEMA with values provided by the user. @@ -49,8 +49,6 @@ def validate_input(hass: HomeAssistant, data: dict) -> dict[str, Any]: nzbget_api.version() - return True - class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for NZBGet.""" @@ -59,21 +57,10 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> NZBGetOptionsFlowHandler: """Get the options flow for this handler.""" return NZBGetOptionsFlowHandler(config_entry) - async def async_step_import( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle a flow initiated by configuration file.""" - if CONF_SCAN_INTERVAL in user_input: - user_input[CONF_SCAN_INTERVAL] = user_input[ - CONF_SCAN_INTERVAL - ].total_seconds() - - return await self.async_step_user(user_input) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -88,9 +75,7 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): user_input[CONF_VERIFY_SSL] = DEFAULT_VERIFY_SSL try: - await self.hass.async_add_executor_job( - validate_input, self.hass, user_input - ) + await self.hass.async_add_executor_job(_validate_input, user_input) except NZBGetAPIException: errors["base"] = "cannot_connect" except Exception: # pylint: disable=broad-except @@ -126,11 +111,13 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): class NZBGetOptionsFlowHandler(OptionsFlow): """Handle NZBGet client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict[str, Any] | None = None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage NZBGet options.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) diff --git a/tests/components/nzbget/__init__.py b/tests/components/nzbget/__init__.py index 9993bdaff1e..331b45e3de8 100644 --- a/tests/components/nzbget/__init__.py +++ b/tests/components/nzbget/__init__.py @@ -1,5 +1,4 @@ """Tests for the NZBGet integration.""" -from datetime import timedelta from unittest.mock import patch from homeassistant.components.nzbget.const import DOMAIN @@ -37,16 +36,6 @@ USER_INPUT = { CONF_USERNAME: "", } -YAML_CONFIG = { - CONF_HOST: "10.10.10.30", - CONF_NAME: "GetNZBsTest", - CONF_PASSWORD: "", - CONF_PORT: 6789, - CONF_SCAN_INTERVAL: timedelta(seconds=5), - CONF_SSL: False, - CONF_USERNAME: "", -} - MOCK_VERSION = "21.0" MOCK_STATUS = { @@ -84,13 +73,6 @@ async def init_integration( return entry -def _patch_async_setup(return_value=True): - return patch( - "homeassistant.components.nzbget.async_setup", - return_value=return_value, - ) - - def _patch_async_setup_entry(return_value=True): return patch( "homeassistant.components.nzbget.async_setup_entry", diff --git a/tests/components/nzbget/test_config_flow.py b/tests/components/nzbget/test_config_flow.py index f6e91d13d9e..8799e3adcf0 100644 --- a/tests/components/nzbget/test_config_flow.py +++ b/tests/components/nzbget/test_config_flow.py @@ -15,7 +15,6 @@ from homeassistant.data_entry_flow import ( from . import ( ENTRY_CONFIG, USER_INPUT, - _patch_async_setup, _patch_async_setup_entry, _patch_history, _patch_status, @@ -34,7 +33,7 @@ async def test_user_form(hass): assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} - with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], USER_INPUT, @@ -45,7 +44,6 @@ async def test_user_form(hass): assert result["title"] == "10.10.10.30" assert result["data"] == {**USER_INPUT, CONF_VERIFY_SSL: False} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -63,7 +61,7 @@ async def test_user_form_show_advanced_options(hass): CONF_VERIFY_SSL: True, } - with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry: + with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup_entry() as mock_setup_entry: result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input_advanced, @@ -74,7 +72,6 @@ async def test_user_form_show_advanced_options(hass): assert result["title"] == "10.10.10.30" assert result["data"] == {**USER_INPUT, CONF_VERIFY_SSL: True} - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 @@ -149,7 +146,7 @@ async def test_options_flow(hass, nzbget_api): assert result["type"] == RESULT_TYPE_FORM assert result["step_id"] == "init" - with _patch_async_setup(), _patch_async_setup_entry(): + with _patch_async_setup_entry(): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_SCAN_INTERVAL: 15}, diff --git a/tests/components/nzbget/test_init.py b/tests/components/nzbget/test_init.py index e83672769da..fbb65a4f8b2 100644 --- a/tests/components/nzbget/test_init.py +++ b/tests/components/nzbget/test_init.py @@ -5,36 +5,12 @@ from pynzbgetapi import NZBGetAPIException from homeassistant.components.nzbget.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT -from homeassistant.setup import async_setup_component -from . import ( - ENTRY_CONFIG, - YAML_CONFIG, - _patch_async_setup_entry, - _patch_history, - _patch_status, - _patch_version, - init_integration, -) +from . import ENTRY_CONFIG, _patch_version, init_integration from tests.common import MockConfigEntry -async def test_import_from_yaml(hass) -> None: - """Test import from YAML.""" - with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup_entry(): - assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG}) - await hass.async_block_till_done() - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - - assert entries[0].data[CONF_NAME] == "GetNZBsTest" - assert entries[0].data[CONF_HOST] == "10.10.10.30" - assert entries[0].data[CONF_PORT] == 6789 - - async def test_unload_entry(hass, nzbget_api): """Test successful unload of entry.""" entry = await init_integration(hass) From 640f53ce21b26ca42989100273612945fd744bf5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 30 May 2022 17:07:18 +0200 Subject: [PATCH 095/947] Remove YAML configuration from upnp (#72410) --- homeassistant/components/upnp/__init__.py | 42 +-------- homeassistant/components/upnp/config_flow.py | 87 +----------------- tests/components/upnp/conftest.py | 5 +- tests/components/upnp/test_config_flow.py | 96 +------------------- 4 files changed, 11 insertions(+), 219 deletions(-) diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 07560f7413f..27d69f9c509 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -5,13 +5,10 @@ import asyncio from collections.abc import Mapping from dataclasses import dataclass from datetime import timedelta -from ipaddress import ip_address from typing import Any from async_upnp_client.exceptions import UpnpCommunicationError, UpnpConnectionError -import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.components.binary_sensor import BinarySensorEntityDescription from homeassistant.components.sensor import SensorEntityDescription @@ -19,10 +16,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -30,7 +25,6 @@ from homeassistant.helpers.update_coordinator import ( ) from .const import ( - CONF_LOCAL_IP, CONFIG_ENTRY_MAC_ADDRESS, CONFIG_ENTRY_ORIGINAL_UDN, CONFIG_ENTRY_ST, @@ -46,43 +40,15 @@ NOTIFICATION_TITLE = "UPnP/IGD Setup" PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - vol.All( - cv.deprecated(CONF_LOCAL_IP), - { - vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string), - }, - ) - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up UPnP component.""" - hass.data[DOMAIN] = {} - - # Only start if set up via configuration.yaml. - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up UPnP/IGD device from a config entry.""" LOGGER.debug("Setting up config entry: %s", entry.entry_id) + hass.data.setdefault(DOMAIN, {}) + udn = entry.data[CONFIG_ENTRY_UDN] st = entry.data[CONFIG_ENTRY_ST] # pylint: disable=invalid-name usn = f"{udn}::{st}" diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index b54098b6566..7fa37f589bf 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -1,7 +1,6 @@ """Config flow for UPNP.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from typing import Any, cast @@ -9,7 +8,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.components.ssdp import SsdpChange, SsdpServiceInfo +from homeassistant.components.ssdp import SsdpServiceInfo from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -21,7 +20,6 @@ from .const import ( CONFIG_ENTRY_UDN, DOMAIN, LOGGER, - SSDP_SEARCH_TIMEOUT, ST_IGD_V1, ST_IGD_V2, ) @@ -48,47 +46,6 @@ def _is_complete_discovery(discovery_info: ssdp.SsdpServiceInfo) -> bool: ) -async def _async_wait_for_discoveries(hass: HomeAssistant) -> bool: - """Wait for a device to be discovered.""" - device_discovered_event = asyncio.Event() - - async def device_discovered(info: SsdpServiceInfo, change: SsdpChange) -> None: - if change != SsdpChange.BYEBYE: - LOGGER.debug( - "Device discovered: %s, at: %s", - info.ssdp_usn, - info.ssdp_location, - ) - device_discovered_event.set() - - cancel_discovered_callback_1 = await ssdp.async_register_callback( - hass, - device_discovered, - { - ssdp.ATTR_SSDP_ST: ST_IGD_V1, - }, - ) - cancel_discovered_callback_2 = await ssdp.async_register_callback( - hass, - device_discovered, - { - ssdp.ATTR_SSDP_ST: ST_IGD_V2, - }, - ) - - try: - await asyncio.wait_for( - device_discovered_event.wait(), timeout=SSDP_SEARCH_TIMEOUT - ) - except asyncio.TimeoutError: - return False - finally: - cancel_discovered_callback_1() - cancel_discovered_callback_2() - - return True - - async def _async_discover_igd_devices( hass: HomeAssistant, ) -> list[ssdp.SsdpServiceInfo]: @@ -120,7 +77,9 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the UPnP/IGD config flow.""" self._discoveries: list[SsdpServiceInfo] | None = None - async def async_step_user(self, user_input: Mapping | None = None) -> FlowResult: + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: """Handle a flow start.""" LOGGER.debug("async_step_user: user_input: %s", user_input) @@ -172,42 +131,6 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, ) - async def async_step_import(self, import_info: Mapping | None) -> Mapping[str, Any]: - """Import a new UPnP/IGD device as a config entry. - - This flow is triggered by `async_setup`. If no device has been - configured before, find any device and create a config_entry for it. - Otherwise, do nothing. - """ - LOGGER.debug("async_step_import: import_info: %s", import_info) - - # Landed here via configuration.yaml entry. - # Any device already added, then abort. - if self._async_current_entries(): - LOGGER.debug("Already configured, aborting") - return self.async_abort(reason="already_configured") - - # Discover devices. - await _async_wait_for_discoveries(self.hass) - discoveries = await _async_discover_igd_devices(self.hass) - - # Ensure anything to add. If not, silently abort. - if not discoveries: - LOGGER.info("No UPnP devices discovered, aborting") - return self.async_abort(reason="no_devices_found") - - # Ensure complete discovery. - discovery = discoveries[0] - if not _is_complete_discovery(discovery): - LOGGER.debug("Incomplete discovery, ignoring") - return self.async_abort(reason="incomplete_discovery") - - # Ensure not already configuring/configured. - unique_id = discovery.ssdp_usn - await self.async_set_unique_id(unique_id) - - return await self._async_create_entry_from_discovery(discovery) - async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered UPnP/IGD device. @@ -275,7 +198,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_ssdp_confirm() async def async_step_ssdp_confirm( - self, user_input: Mapping | None = None + self, user_input: Mapping[str, Any] | None = None ) -> FlowResult: """Confirm integration via SSDP.""" LOGGER.debug("async_step_ssdp_confirm: user_input: %s", user_input) diff --git a/tests/components/upnp/conftest.py b/tests/components/upnp/conftest.py index 687518bb46d..0d3e869db35 100644 --- a/tests/components/upnp/conftest.py +++ b/tests/components/upnp/conftest.py @@ -156,10 +156,7 @@ async def ssdp_no_discovery(): ) as mock_register, patch( "homeassistant.components.ssdp.async_get_discovery_info_by_st", return_value=[], - ) as mock_get_info, patch( - "homeassistant.components.upnp.config_flow.SSDP_SEARCH_TIMEOUT", - 0.1, - ): + ) as mock_get_info: yield (mock_register, mock_get_info) diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index ea8b3381cd1..80847ec2737 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -1,7 +1,7 @@ """Test UPnP/IGD config flow.""" from copy import deepcopy -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest @@ -341,97 +341,3 @@ async def test_flow_user_no_discovery(hass: HomeAssistant): ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "no_devices_found" - - -@pytest.mark.usefixtures( - "ssdp_instant_discovery", - "mock_setup_entry", - "mock_get_source_ip", - "mock_mac_address_from_host", -) -async def test_flow_import(hass: HomeAssistant): - """Test config flow: configured through configuration.yaml.""" - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == TEST_FRIENDLY_NAME - assert result["data"] == { - CONFIG_ENTRY_ST: TEST_ST, - CONFIG_ENTRY_UDN: TEST_UDN, - CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, - CONFIG_ENTRY_LOCATION: TEST_LOCATION, - CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, - } - - -@pytest.mark.usefixtures( - "mock_get_source_ip", -) -async def test_flow_import_incomplete_discovery(hass: HomeAssistant): - """Test config flow: configured through configuration.yaml, but incomplete discovery.""" - incomplete_discovery = ssdp.SsdpServiceInfo( - ssdp_usn=TEST_USN, - ssdp_st=TEST_ST, - ssdp_location=TEST_LOCATION, - upnp={ - # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. - }, - ) - - async def register_callback(hass, callback, match_dict): - """Immediately do callback.""" - await callback(incomplete_discovery, ssdp.SsdpChange.ALIVE) - return MagicMock() - - with patch( - "homeassistant.components.ssdp.async_register_callback", - side_effect=register_callback, - ), patch( - "homeassistant.components.upnp.ssdp.async_get_discovery_info_by_st", - return_value=[incomplete_discovery], - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "incomplete_discovery" - - -@pytest.mark.usefixtures("ssdp_instant_discovery", "mock_get_source_ip") -async def test_flow_import_already_configured(hass: HomeAssistant): - """Test config flow: configured through configuration.yaml, but existing config entry.""" - # Existing entry. - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_USN, - data={ - CONFIG_ENTRY_ST: TEST_ST, - CONFIG_ENTRY_UDN: TEST_UDN, - CONFIG_ENTRY_ORIGINAL_UDN: TEST_UDN, - CONFIG_ENTRY_LOCATION: TEST_LOCATION, - CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS, - }, - state=config_entries.ConfigEntryState.LOADED, - ) - entry.add_to_hass(hass) - - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -@pytest.mark.usefixtures("ssdp_no_discovery", "mock_get_source_ip") -async def test_flow_import_no_devices_found(hass: HomeAssistant): - """Test config flow: no devices found, configured through configuration.yaml.""" - # Discovered via step import. - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "no_devices_found" From d5fc7e3174332e0d605865b1847a973fb5298637 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Mon, 30 May 2022 18:31:57 +0200 Subject: [PATCH 096/947] Upgrade frontier_silicon library to AFSAPI 0.2.4 (#69371) --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/frontier_silicon/const.py | 6 + .../components/frontier_silicon/manifest.json | 6 +- .../frontier_silicon/media_player.py | 138 ++++++++++-------- requirements_all.txt | 2 +- 6 files changed, 92 insertions(+), 62 deletions(-) create mode 100644 homeassistant/components/frontier_silicon/const.py diff --git a/.coveragerc b/.coveragerc index aced69a4714..6f96cccaf5f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -407,6 +407,7 @@ omit = homeassistant/components/fritzbox_callmonitor/const.py homeassistant/components/fritzbox_callmonitor/base.py homeassistant/components/fritzbox_callmonitor/sensor.py + homeassistant/components/frontier_silicon/const.py homeassistant/components/frontier_silicon/media_player.py homeassistant/components/futurenow/light.py homeassistant/components/garadget/cover.py diff --git a/CODEOWNERS b/CODEOWNERS index 259fbc77aab..3772a7f2dfa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -364,6 +364,7 @@ build.json @home-assistant/supervisor /tests/components/fronius/ @nielstron @farmio /homeassistant/components/frontend/ @home-assistant/frontend /tests/components/frontend/ @home-assistant/frontend +/homeassistant/components/frontier_silicon/ @wlcrs /homeassistant/components/garages_amsterdam/ @klaasnicolaas /tests/components/garages_amsterdam/ @klaasnicolaas /homeassistant/components/gdacs/ @exxamalte diff --git a/homeassistant/components/frontier_silicon/const.py b/homeassistant/components/frontier_silicon/const.py new file mode 100644 index 00000000000..4638e63c2f2 --- /dev/null +++ b/homeassistant/components/frontier_silicon/const.py @@ -0,0 +1,6 @@ +"""Constants for the Frontier Silicon Media Player integration.""" + +DOMAIN = "frontier_silicon" + +DEFAULT_PIN = "1234" +DEFAULT_PORT = 80 diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json index 3eb982e8118..20092b941a9 100644 --- a/homeassistant/components/frontier_silicon/manifest.json +++ b/homeassistant/components/frontier_silicon/manifest.json @@ -2,7 +2,7 @@ "domain": "frontier_silicon", "name": "Frontier Silicon", "documentation": "https://www.home-assistant.io/integrations/frontier_silicon", - "requirements": ["afsapi==0.0.4"], - "codeowners": [], - "iot_class": "local_push" + "requirements": ["afsapi==0.2.4"], + "codeowners": ["@wlcrs"], + "iot_class": "local_polling" } diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 034762a09ac..66d1f304b1e 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -3,8 +3,7 @@ from __future__ import annotations import logging -from afsapi import AFSAPI -import requests +from afsapi import AFSAPI, ConnectionError as FSConnectionError, PlayState import voluptuous as vol from homeassistant.components.media_player import ( @@ -20,25 +19,27 @@ from homeassistant.const import ( CONF_PORT, STATE_IDLE, STATE_OFF, + STATE_OPENING, STATE_PAUSED, STATE_PLAYING, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) +from .const import DEFAULT_PIN, DEFAULT_PORT, DOMAIN -DEFAULT_PORT = 80 -DEFAULT_PASSWORD = "1234" +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PIN): cv.string, vol.Optional(CONF_NAME): cv.string, } ) @@ -52,8 +53,14 @@ async def async_setup_platform( ) -> None: """Set up the Frontier Silicon platform.""" if discovery_info is not None: + webfsapi_url = await AFSAPI.get_webfsapi_endpoint( + discovery_info["ssdp_description"] + ) + afsapi = AFSAPI(webfsapi_url, DEFAULT_PIN) + + name = await afsapi.get_friendly_name() async_add_entities( - [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD, None)], + [AFSAPIDevice(name, afsapi)], True, ) return @@ -64,11 +71,12 @@ async def async_setup_platform( name = config.get(CONF_NAME) try: - async_add_entities( - [AFSAPIDevice(f"http://{host}:{port}/device", password, name)], True + webfsapi_url = await AFSAPI.get_webfsapi_endpoint( + f"http://{host}:{port}/device" ) - _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) - except requests.exceptions.RequestException: + afsapi = AFSAPI(webfsapi_url, password) + async_add_entities([AFSAPIDevice(name, afsapi)], True) + except FSConnectionError: _LOGGER.error( "Could not add the FSAPI device at %s:%s -> %s", host, port, password ) @@ -93,10 +101,15 @@ class AFSAPIDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, device_url, password, name): + def __init__(self, name: str | None, afsapi: AFSAPI) -> None: """Initialize the Frontier Silicon API device.""" - self._device_url = device_url - self._password = password + self.fs_device = afsapi + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, afsapi.webfsapi_endpoint)}, + name=name, + ) + self._state = None self._name = name @@ -110,17 +123,7 @@ class AFSAPIDevice(MediaPlayerEntity): self._max_volume = None self._volume_level = None - # Properties - @property - def fs_device(self): - """ - Create a fresh fsapi session. - - A new session is created for each request in case someone else - connected to the device in between the updates and invalidated the - existing session (i.e UNDOK). - """ - return AFSAPI(self._device_url, self._password) + self.__modes_by_label = None @property def name(self): @@ -175,43 +178,61 @@ class AFSAPIDevice(MediaPlayerEntity): async def async_update(self): """Get the latest date and update device state.""" - fs_device = self.fs_device - - if not self._name: - self._name = await fs_device.get_friendly_name() - - if not self._source_list: - self._source_list = await fs_device.get_mode_list() - - # The API seems to include 'zero' in the number of steps (e.g. if the range is - # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. - # If call to get_volume fails set to 0 and try again next time. - if not self._max_volume: - self._max_volume = int(await fs_device.get_volume_steps() or 1) - 1 - - if await fs_device.get_power(): - status = await fs_device.get_play_status() - self._state = { - "playing": STATE_PLAYING, - "paused": STATE_PAUSED, - "stopped": STATE_IDLE, - "unknown": STATE_UNKNOWN, - None: STATE_IDLE, - }.get(status, STATE_UNKNOWN) + afsapi = self.fs_device + try: + if await afsapi.get_power(): + status = await afsapi.get_play_status() + self._state = { + PlayState.PLAYING: STATE_PLAYING, + PlayState.PAUSED: STATE_PAUSED, + PlayState.STOPPED: STATE_IDLE, + PlayState.LOADING: STATE_OPENING, + None: STATE_IDLE, + }.get(status, STATE_UNKNOWN) + else: + self._state = STATE_OFF + except FSConnectionError: + if self._attr_available: + _LOGGER.warning( + "Could not connect to %s. Did it go offline?", + self._name or afsapi.webfsapi_endpoint, + ) + self._state = STATE_UNAVAILABLE + self._attr_available = False else: - self._state = STATE_OFF + if not self._attr_available: + _LOGGER.info( + "Reconnected to %s", + self._name or afsapi.webfsapi_endpoint, + ) - if self._state != STATE_OFF: - info_name = await fs_device.get_play_name() - info_text = await fs_device.get_play_text() + self._attr_available = True + if not self._name: + self._name = await afsapi.get_friendly_name() + + if not self._source_list: + self.__modes_by_label = { + mode.label: mode.key for mode in await afsapi.get_modes() + } + self._source_list = list(self.__modes_by_label.keys()) + + # The API seems to include 'zero' in the number of steps (e.g. if the range is + # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. + # If call to get_volume fails set to 0 and try again next time. + if not self._max_volume: + self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 + + if self._state not in [STATE_OFF, STATE_UNAVAILABLE]: + info_name = await afsapi.get_play_name() + info_text = await afsapi.get_play_text() self._title = " - ".join(filter(None, [info_name, info_text])) - self._artist = await fs_device.get_play_artist() - self._album_name = await fs_device.get_play_album() + self._artist = await afsapi.get_play_artist() + self._album_name = await afsapi.get_play_album() - self._source = await fs_device.get_mode() - self._mute = await fs_device.get_mute() - self._media_image_url = await fs_device.get_play_graphic() + self._source = (await afsapi.get_mode()).label + self._mute = await afsapi.get_mute() + self._media_image_url = await afsapi.get_play_graphic() volume = await self.fs_device.get_volume() @@ -296,4 +317,5 @@ class AFSAPIDevice(MediaPlayerEntity): async def async_select_source(self, source): """Select input source.""" - await self.fs_device.set_mode(source) + await self.fs_device.set_power(True) + await self.fs_device.set_mode(self.__modes_by_label.get(source)) diff --git a/requirements_all.txt b/requirements_all.txt index de4bcbef89e..16b059d385e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,7 +86,7 @@ adguardhome==0.5.1 advantage_air==0.3.1 # homeassistant.components.frontier_silicon -afsapi==0.0.4 +afsapi==0.2.4 # homeassistant.components.agent_dvr agent-py==0.0.23 From f06ceef43deac49a956e76aac3ba4a9cee2bdf19 Mon Sep 17 00:00:00 2001 From: Dimiter Geelen Date: Mon, 30 May 2022 18:36:01 +0200 Subject: [PATCH 097/947] Bump PyVLX to 0.2.20 (#72678) --- homeassistant/components/velux/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index 4a5ea07dc82..d86b607cdc6 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -2,7 +2,7 @@ "domain": "velux", "name": "Velux", "documentation": "https://www.home-assistant.io/integrations/velux", - "requirements": ["pyvlx==0.2.19"], + "requirements": ["pyvlx==0.2.20"], "codeowners": ["@Julius2342"], "iot_class": "local_polling", "loggers": ["pyvlx"] diff --git a/requirements_all.txt b/requirements_all.txt index 16b059d385e..f66db8e37f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2014,7 +2014,7 @@ pyvesync==2.0.3 pyvizio==0.1.57 # homeassistant.components.velux -pyvlx==0.2.19 +pyvlx==0.2.20 # homeassistant.components.volumio pyvolumio==0.1.5 From 3399be2dad7fc9115914b8b8c169c8e2fe3f8fdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 06:41:07 -1000 Subject: [PATCH 098/947] Retry bond setup when zeroconf discovery happens (#72687) --- homeassistant/components/bond/config_flow.py | 22 +++++------ tests/components/bond/test_config_flow.py | 39 ++++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 6eba9897468..9670782d2a6 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Bond integration.""" from __future__ import annotations +import contextlib from http import HTTPStatus import logging from typing import Any @@ -33,10 +34,9 @@ TOKEN_SCHEMA = vol.Schema({}) async def async_get_token(hass: HomeAssistant, host: str) -> str | None: """Try to fetch the token from the bond device.""" bond = Bond(host, "", session=async_get_clientsession(hass)) - try: - response: dict[str, str] = await bond.token() - except ClientConnectionError: - return None + response: dict[str, str] = {} + with contextlib.suppress(ClientConnectionError): + response = await bond.token() return response.get("token") @@ -101,6 +101,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host: str = discovery_info.host bond_id = name.partition(".")[0] await self.async_set_unique_id(bond_id) + hass = self.hass for entry in self._async_current_entries(): if entry.unique_id != bond_id: continue @@ -110,13 +111,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): updates[CONF_ACCESS_TOKEN] = token new_data = {**entry.data, **updates} - if new_data != dict(entry.data): - self.hass.config_entries.async_update_entry( - entry, data={**entry.data, **updates} - ) - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) + changed = new_data != dict(entry.data) + if changed: + hass.config_entries.async_update_entry(entry, data=new_data) + if changed or entry.state is ConfigEntryState.SETUP_RETRY: + entry_id = entry.entry_id + hass.async_create_task(hass.config_entries.async_reload(entry_id)) raise AbortFlow("already_configured") self._discovered = {CONF_HOST: host, CONF_NAME: bond_id} diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 5d3b357b9f7..67910af7b6c 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -381,6 +381,45 @@ async def test_zeroconf_already_configured(hass: core.HomeAssistant): assert len(mock_setup_entry.mock_calls) == 1 +async def test_zeroconf_in_setup_retry_state(hass: core.HomeAssistant): + """Test we retry right away on zeroconf discovery.""" + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="already-registered-bond-id", + data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "test-token"}, + ) + entry.add_to_hass(hass) + + with patch_bond_version(side_effect=OSError): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.SETUP_RETRY + + with _patch_async_setup_entry() as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="updated-host", + addresses=["updated-host"], + hostname="mock_hostname", + name="already-registered-bond-id.some-other-tail-info", + port=None, + properties={}, + type="mock_type", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data["host"] == "updated-host" + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.state is ConfigEntryState.LOADED + + async def test_zeroconf_already_configured_refresh_token(hass: core.HomeAssistant): """Test starting a flow from zeroconf when already configured and the token is out of date.""" entry2 = MockConfigEntry( From 45e4dd379b54847174b1f69ca138ba5fe73d24f9 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Tue, 31 May 2022 03:54:39 +1000 Subject: [PATCH 099/947] Add support for topdown shades to hunterdouglas_powerview (#62788) Co-authored-by: J. Nick Koston --- .coveragerc | 7 +- CODEOWNERS | 4 +- .../hunterdouglas_powerview/__init__.py | 43 +- .../hunterdouglas_powerview/const.py | 9 +- .../hunterdouglas_powerview/coordinator.py | 44 ++ .../hunterdouglas_powerview/cover.py | 572 +++++++++++------- .../hunterdouglas_powerview/entity.py | 42 +- .../hunterdouglas_powerview/manifest.json | 2 +- .../hunterdouglas_powerview/sensor.py | 12 +- .../hunterdouglas_powerview/shade_data.py | 113 ++++ .../hunterdouglas_powerview/util.py | 15 + 11 files changed, 602 insertions(+), 261 deletions(-) create mode 100644 homeassistant/components/hunterdouglas_powerview/coordinator.py create mode 100644 homeassistant/components/hunterdouglas_powerview/shade_data.py create mode 100644 homeassistant/components/hunterdouglas_powerview/util.py diff --git a/.coveragerc b/.coveragerc index 6f96cccaf5f..19f305dbf3b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -506,10 +506,13 @@ omit = homeassistant/components/huawei_lte/switch.py homeassistant/components/hue/light.py homeassistant/components/hunterdouglas_powerview/__init__.py - homeassistant/components/hunterdouglas_powerview/scene.py - homeassistant/components/hunterdouglas_powerview/sensor.py + homeassistant/components/hunterdouglas_powerview/coordinator.py homeassistant/components/hunterdouglas_powerview/cover.py homeassistant/components/hunterdouglas_powerview/entity.py + homeassistant/components/hunterdouglas_powerview/scene.py + homeassistant/components/hunterdouglas_powerview/sensor.py + homeassistant/components/hunterdouglas_powerview/shade_data.py + homeassistant/components/hunterdouglas_powerview/util.py homeassistant/components/hvv_departures/binary_sensor.py homeassistant/components/hvv_departures/sensor.py homeassistant/components/hvv_departures/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 3772a7f2dfa..545d5027ecb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -467,8 +467,8 @@ build.json @home-assistant/supervisor /tests/components/huisbaasje/ @dennisschroer /homeassistant/components/humidifier/ @home-assistant/core @Shulyaka /tests/components/humidifier/ @home-assistant/core @Shulyaka -/homeassistant/components/hunterdouglas_powerview/ @bdraco @trullock -/tests/components/hunterdouglas_powerview/ @bdraco @trullock +/homeassistant/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock +/tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion /homeassistant/components/hydrawise/ @ptcryan diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 17dd580f5cc..97f7f8de931 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -1,10 +1,8 @@ """The Hunter Douglas PowerView integration.""" -from datetime import timedelta import logging from aiopvapi.helpers.aiorequest import AioRequest from aiopvapi.helpers.api_base import ApiEntryPoint -from aiopvapi.helpers.constants import ATTR_ID from aiopvapi.helpers.tools import base64_to_unicode from aiopvapi.rooms import Rooms from aiopvapi.scenes import Scenes @@ -14,11 +12,10 @@ import async_timeout from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( API_PATH_FWVERSION, @@ -50,6 +47,9 @@ from .const import ( SHADE_DATA, USER_DATA, ) +from .coordinator import PowerviewShadeUpdateCoordinator +from .shade_data import PowerviewShadeData +from .util import async_map_data_by_id PARALLEL_UPDATES = 1 @@ -64,7 +64,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: config = entry.data - hub_address = config.get(CONF_HOST) + hub_address = config[CONF_HOST] websession = async_get_clientsession(hass) pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession) @@ -75,17 +75,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with async_timeout.timeout(10): rooms = Rooms(pv_request) - room_data = _async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) + room_data = async_map_data_by_id((await rooms.get_resources())[ROOM_DATA]) async with async_timeout.timeout(10): scenes = Scenes(pv_request) - scene_data = _async_map_data_by_id( + scene_data = async_map_data_by_id( (await scenes.get_resources())[SCENE_DATA] ) async with async_timeout.timeout(10): shades = Shades(pv_request) - shade_data = _async_map_data_by_id( + shade_data = async_map_data_by_id( (await shades.get_resources())[SHADE_DATA] ) except HUB_EXCEPTIONS as err: @@ -95,24 +95,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not device_info: raise ConfigEntryNotReady(f"Unable to initialize PowerView hub: {hub_address}") - async def async_update_data(): - """Fetch data from shade endpoint.""" - async with async_timeout.timeout(10): - shade_entries = await shades.get_resources() - if not shade_entries: - raise UpdateFailed("Failed to fetch new shade data.") - return _async_map_data_by_id(shade_entries[SHADE_DATA]) - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name="powerview hub", - update_method=async_update_data, - update_interval=timedelta(seconds=60), - ) - - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { + coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) + coordinator.async_set_updated_data(PowerviewShadeData()) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { PV_API: pv_request, PV_ROOM_DATA: room_data, PV_SCENE_DATA: scene_data, @@ -155,12 +140,6 @@ async def async_get_device_info(pv_request): } -@callback -def _async_map_data_by_id(data): - """Return a dict with the key being the id for a list of entries.""" - return {entry[ATTR_ID]: entry for entry in data} - - 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) diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index ea87150a9ca..1cc9f79df40 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -7,7 +7,6 @@ from aiopvapi.helpers.aiorequest import PvApiConnectionError, PvApiResponseStatu DOMAIN = "hunterdouglas_powerview" - MANUFACTURER = "Hunter Douglas" HUB_ADDRESS = "address" @@ -48,7 +47,6 @@ ROOM_NAME = "name" ROOM_NAME_UNICODE = "name_unicode" ROOM_ID = "id" -SHADE_RESPONSE = "shade" SHADE_BATTERY_LEVEL = "batteryStrength" SHADE_BATTERY_LEVEL_MAX = 200 @@ -81,5 +79,10 @@ DEFAULT_LEGACY_MAINPROCESSOR = { FIRMWARE_NAME: LEGACY_DEVICE_MODEL, } - API_PATH_FWVERSION = "api/fwversion" + +POS_KIND_NONE = 0 +POS_KIND_PRIMARY = 1 +POS_KIND_SECONDARY = 2 +POS_KIND_VANE = 3 +POS_KIND_ERROR = 4 diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py new file mode 100644 index 00000000000..bf3d6eb7a54 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -0,0 +1,44 @@ +"""Coordinate data for powerview devices.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from aiopvapi.shades import Shades +import async_timeout + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import SHADE_DATA +from .shade_data import PowerviewShadeData + +_LOGGER = logging.getLogger(__name__) + + +class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]): + """DataUpdateCoordinator to gather data from a powerview hub.""" + + def __init__( + self, + hass: HomeAssistant, + shades: Shades, + hub_address: str, + ) -> None: + """Initialize DataUpdateCoordinator to gather data for specific SmartPlug.""" + self.shades = shades + super().__init__( + hass, + _LOGGER, + name=f"powerview hub {hub_address}", + update_interval=timedelta(seconds=60), + ) + + async def _async_update_data(self) -> PowerviewShadeData: + """Fetch data from shade endpoint.""" + async with async_timeout.timeout(10): + shade_entries = await self.shades.get_resources() + if not shade_entries: + raise UpdateFailed("Failed to fetch new shade data") + self.data.store_group_data(shade_entries[SHADE_DATA]) + return self.data diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 493b5d53639..565bac6a5c8 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -1,14 +1,24 @@ """Support for hunter douglas shades.""" -from abc import abstractmethod +from __future__ import annotations + import asyncio +from collections.abc import Iterable from contextlib import suppress import logging +from typing import Any -from aiopvapi.helpers.constants import ATTR_POSITION1, ATTR_POSITION_DATA +from aiopvapi.helpers.constants import ( + ATTR_POSITION1, + ATTR_POSITION2, + ATTR_POSITION_DATA, +) from aiopvapi.resources.shade import ( ATTR_POSKIND1, + ATTR_POSKIND2, MAX_POSITION, MIN_POSITION, + BaseShade, + ShadeTdbu, Silhouette, factory as PvShade, ) @@ -22,7 +32,7 @@ from homeassistant.components.cover import ( CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -32,15 +42,19 @@ from .const import ( DEVICE_MODEL, DOMAIN, LEGACY_DEVICE_MODEL, + POS_KIND_PRIMARY, + POS_KIND_SECONDARY, + POS_KIND_VANE, PV_API, PV_ROOM_DATA, PV_SHADE_DATA, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE, - SHADE_RESPONSE, STATE_ATTRIBUTE_ROOM_NAME, ) +from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity +from .shade_data import PowerviewShadeMove _LOGGER = logging.getLogger(__name__) @@ -52,11 +66,13 @@ PARALLEL_UPDATES = 1 RESYNC_DELAY = 60 -POSKIND_NONE = 0 -POSKIND_PRIMARY = 1 -POSKIND_SECONDARY = 2 -POSKIND_VANE = 3 -POSKIND_ERROR = 4 +# this equates to 0.75/100 in terms of hass blind position +# some blinds in a closed position report less than 655.35 (1%) +# but larger than 0 even though they are clearly closed +# Find 1 percent of MAX_POSITION, then find 75% of that number +# The means currently 491.5125 or less is closed position +# implemented for top/down shades, but also works fine with normal shades +CLOSED_POSITION = (0.75 / 100) * (MAX_POSITION - MIN_POSITION) async def async_setup_entry( @@ -65,18 +81,17 @@ async def async_setup_entry( """Set up the hunter douglas shades.""" pv_data = hass.data[DOMAIN][entry.entry_id] - room_data = pv_data[PV_ROOM_DATA] + room_data: dict[str | int, Any] = pv_data[PV_ROOM_DATA] shade_data = pv_data[PV_SHADE_DATA] pv_request = pv_data[PV_API] - coordinator = pv_data[COORDINATOR] - device_info = pv_data[DEVICE_INFO] + coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR] + device_info: dict[str, Any] = pv_data[DEVICE_INFO] - entities = [] + entities: list[ShadeEntity] = [] for raw_shade in shade_data.values(): # The shade may be out of sync with the hub - # so we force a refresh when we add it if - # possible - shade = PvShade(raw_shade, pv_request) + # so we force a refresh when we add it if possible + shade: BaseShade = PvShade(raw_shade, pv_request) name_before_refresh = shade.name with suppress(asyncio.TimeoutError): async with async_timeout.timeout(1): @@ -88,9 +103,10 @@ async def async_setup_entry( name_before_refresh, ) continue + coordinator.data.update_shade_positions(shade.raw_data) room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") - entities.append( + entities.extend( create_powerview_shade_entity( coordinator, device_info, room_name, shade, name_before_refresh ) @@ -99,26 +115,36 @@ async def async_setup_entry( def create_powerview_shade_entity( - coordinator, device_info, room_name, shade, name_before_refresh -): + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name_before_refresh: str, +) -> Iterable[ShadeEntity]: """Create a PowerViewShade entity.""" - - if isinstance(shade, Silhouette): - return PowerViewShadeSilhouette( - coordinator, device_info, room_name, shade, name_before_refresh - ) - - return PowerViewShade( - coordinator, device_info, room_name, shade, name_before_refresh - ) + classes: list[BaseShade] = [] + # order here is important as both ShadeTDBU are listed in aiovapi as can_tilt + # and both require their own class here to work + if isinstance(shade, ShadeTdbu): + classes.extend([PowerViewShadeTDBUTop, PowerViewShadeTDBUBottom]) + elif isinstance(shade, Silhouette): + classes.append(PowerViewShadeSilhouette) + elif shade.can_tilt: + classes.append(PowerViewShadeWithTilt) + else: + classes.append(PowerViewShade) + return [ + cls(coordinator, device_info, room_name, shade, name_before_refresh) + for cls in classes + ] -def hd_position_to_hass(hd_position, max_val): +def hd_position_to_hass(hd_position: int, max_val: int = MAX_POSITION) -> int: """Convert hunter douglas position to hass position.""" return round((hd_position / max_val) * 100) -def hass_position_to_hd(hass_position, max_val): +def hass_position_to_hd(hass_position: int, max_val: int = MAX_POSITION) -> int: """Convert hass position to hunter douglas position.""" return int(hass_position / 100 * max_val) @@ -128,130 +154,126 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): # The hub frequently reports stale states _attr_assumed_state = True + _attr_device_class = CoverDeviceClass.SHADE + _attr_supported_features = 0 - def __init__(self, coordinator, device_info, room_name, shade, name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) - self._shade = shade - self._is_opening = False - self._is_closing = False - self._last_action_timestamp = 0 - self._scheduled_transition_update = None - self._current_hd_cover_position = MIN_POSITION + self._shade: BaseShade = shade + self._attr_name = self._shade_name + self._scheduled_transition_update: CALLBACK_TYPE | None = None if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync = None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} @property def is_closed(self): """Return if the cover is closed.""" - return self._current_hd_cover_position == MIN_POSITION + return self.positions.primary <= CLOSED_POSITION @property - def is_opening(self): - """Return if the cover is opening.""" - return self._is_opening - - @property - def is_closing(self): - """Return if the cover is closing.""" - return self._is_closing - - @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of cover.""" - return hd_position_to_hass(self._current_hd_cover_position, MAX_POSITION) + return hd_position_to_hass(self.positions.primary, MAX_POSITION) @property - def device_class(self): - """Return device class.""" - return CoverDeviceClass.SHADE + def transition_steps(self) -> int: + """Return the steps to make a move.""" + return hd_position_to_hass(self.positions.primary, MAX_POSITION) @property - def name(self): - """Return the name of the shade.""" - return self._shade_name + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove(self._shade.open_position, {}) - async def async_close_cover(self, **kwargs): + @property + def close_position(self) -> PowerviewShadeMove: + """Return the close position and required additional positions.""" + return PowerviewShadeMove(self._shade.close_position, {}) + + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - await self._async_move(0) + self._async_schedule_update_for_transition(self.transition_steps) + await self._async_execute_move(self.close_position) + self._attr_is_opening = False + self._attr_is_closing = True + self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - await self._async_move(100) + self._async_schedule_update_for_transition(100 - self.transition_steps) + await self._async_execute_move(self.open_position) + self._attr_is_opening = True + self._attr_is_closing = False + self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - # Cancel any previous updates self._async_cancel_scheduled_transition_update() - self._async_update_from_command(await self._shade.stop()) + self.data.update_from_response(await self._shade.stop()) await self._async_force_refresh_state() - async def async_set_cover_position(self, **kwargs): - """Move the shade to a specific position.""" - if ATTR_POSITION not in kwargs: - return - await self._async_move(kwargs[ATTR_POSITION]) + @callback + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + # no override required in base + return target_hass_position - async def _async_move(self, target_hass_position): + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the shade to a specific position.""" + await self._async_set_cover_position(kwargs[ATTR_POSITION]) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_one = hass_position_to_hd(target_hass_position) + return PowerviewShadeMove( + {ATTR_POSITION1: position_one, ATTR_POSKIND1: POS_KIND_PRIMARY}, {} + ) + + async def _async_execute_move(self, move: PowerviewShadeMove) -> None: + """Execute a move that can affect multiple positions.""" + response = await self._shade.move(move.request) + # Process any positions we know will update as result + # of the request since the hub won't return them + for kind, position in move.new_positions.items(): + self.data.update_shade_position(self._shade.id, position, kind) + # Finally process the response + self.data.update_from_response(response) + + async def _async_set_cover_position(self, target_hass_position: int) -> None: """Move the shade to a position.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION + target_hass_position = self._clamp_cover_limit(target_hass_position) + current_hass_position = self.current_cover_position + self._async_schedule_update_for_transition( + abs(current_hass_position - target_hass_position) ) - steps_to_move = abs(current_hass_position - target_hass_position) - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command( - await self._shade.move( - { - ATTR_POSITION1: hass_position_to_hd( - target_hass_position, MAX_POSITION - ), - ATTR_POSKIND1: POSKIND_PRIMARY, - } - ) - ) - self._is_opening = False - self._is_closing = False - if target_hass_position > current_hass_position: - self._is_opening = True - elif target_hass_position < current_hass_position: - self._is_closing = True + await self._async_execute_move(self._get_shade_move(target_hass_position)) + self._attr_is_opening = target_hass_position > current_hass_position + self._attr_is_closing = target_hass_position < current_hass_position self.async_write_ha_state() @callback - def _async_update_from_command(self, raw_data): - """Update the shade state after a command.""" - if not raw_data or SHADE_RESPONSE not in raw_data: - return - self._async_process_new_shade_data(raw_data[SHADE_RESPONSE]) - - @callback - def _async_process_new_shade_data(self, data): - """Process new data from an update.""" - self._shade.raw_data = data - self._async_update_current_cover_position() - - @callback - def _async_update_current_cover_position(self): + def _async_update_shade_data(self, shade_data: dict[str | int, Any]) -> None: """Update the current cover position from the data.""" - _LOGGER.debug("Raw data update: %s", self._shade.raw_data) - position_data = self._shade.raw_data.get(ATTR_POSITION_DATA, {}) - self._async_process_updated_position_data(position_data) - self._is_opening = False - self._is_closing = False + self.data.update_shade_positions(shade_data) + self._attr_is_opening = False + self._attr_is_closing = False @callback - @abstractmethod - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - - @callback - def _async_cancel_scheduled_transition_update(self): + def _async_cancel_scheduled_transition_update(self) -> None: """Cancel any previous updates.""" if self._scheduled_transition_update: self._scheduled_transition_update() @@ -261,9 +283,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._forced_resync = None @callback - def _async_schedule_update_for_transition(self, steps): - self.async_write_ha_state() - + def _async_schedule_update_for_transition(self, steps: int) -> None: # Cancel any previous updates self._async_cancel_scheduled_transition_update() @@ -278,7 +298,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): est_time_to_complete_transition, ) - # Schedule an update for when we expect the transition + # Schedule an forced update for when we expect the transition # to be completed. self._scheduled_transition_update = async_call_later( self.hass, @@ -295,139 +315,281 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self.hass, RESYNC_DELAY, self._async_force_resync ) - async def _async_force_resync(self, *_): + async def _async_force_resync(self, *_: Any) -> None: """Force a resync after an update since the hub may have stale state.""" self._forced_resync = None + _LOGGER.debug("Force resync of shade %s", self.name) await self._async_force_refresh_state() - async def _async_force_refresh_state(self): + async def _async_force_refresh_state(self) -> None: """Refresh the cover state and force the device cache to be bypassed.""" await self._shade.refresh() - self._async_update_current_cover_position() + self._async_update_shade_data(self._shade.raw_data) self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """When entity is added to hass.""" - self._async_update_current_cover_position() self.async_on_remove( self.coordinator.async_add_listener(self._async_update_shade_from_group) ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Cancel any pending refreshes.""" self._async_cancel_scheduled_transition_update() @callback - def _async_update_shade_from_group(self): + def _async_update_shade_from_group(self) -> None: """Update with new data from the coordinator.""" if self._scheduled_transition_update or self._forced_resync: - # If a transition in in progress - # the data will be wrong + # If a transition is in progress the data will be wrong return - self._async_process_new_shade_data(self.coordinator.data[self._shade.id]) + self.data.update_from_group_data(self._shade.id) self.async_write_ha_state() class PowerViewShade(PowerViewShadeBase): """Represent a standard shade.""" - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_supported_features |= ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + + +class PowerViewShadeTDBU(PowerViewShade): + """Representation of a PowerView shade with top/down bottom/up capabilities.""" + + @property + def transition_steps(self) -> int: + """Return the steps to make a move.""" + return hd_position_to_hass( + self.positions.primary, MAX_POSITION + ) + hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + +class PowerViewShadeTDBUBottom(PowerViewShadeTDBU): + """Representation of a top down bottom up powerview shade.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._shade.id}_bottom" + self._attr_name = f"{self._shade_name} Bottom" @callback - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - if ATTR_POSITION1 in position_data: - self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + cover_top = hd_position_to_hass(self.positions.secondary, MAX_POSITION) + return min(target_hass_position, (100 - cover_top)) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_bottom = hass_position_to_hd(target_hass_position) + position_top = self.positions.secondary + return PowerviewShadeMove( + { + ATTR_POSITION1: position_bottom, + ATTR_POSITION2: position_top, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + }, + {}, + ) + + +class PowerViewShadeTDBUTop(PowerViewShadeTDBU): + """Representation of a top down bottom up powerview shade.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + ) -> None: + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._shade.id}_top" + self._attr_name = f"{self._shade_name} Top" + # these shades share a class in parent API + # override open position for top shade + self._shade.open_position = { + ATTR_POSITION1: MIN_POSITION, + ATTR_POSITION2: MAX_POSITION, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + } + + @property + def is_closed(self): + """Return if the cover is closed.""" + # top shade needs to check other motor + return self.positions.secondary <= CLOSED_POSITION + + @property + def current_cover_position(self) -> int: + """Return the current position of cover.""" + # these need to be inverted to report state correctly in HA + return hd_position_to_hass(self.positions.secondary, MAX_POSITION) + + @callback + def _clamp_cover_limit(self, target_hass_position: int) -> int: + """Dont allow a cover to go into an impossbile position.""" + cover_bottom = hd_position_to_hass(self.positions.primary, MAX_POSITION) + return min(target_hass_position, (100 - cover_bottom)) + + @callback + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + position_bottom = self.positions.primary + position_top = hass_position_to_hd(target_hass_position, MAX_POSITION) + return PowerviewShadeMove( + { + ATTR_POSITION1: position_bottom, + ATTR_POSITION2: position_top, + ATTR_POSKIND1: POS_KIND_PRIMARY, + ATTR_POSKIND2: POS_KIND_SECONDARY, + }, + {}, + ) class PowerViewShadeWithTilt(PowerViewShade): """Representation of a PowerView shade with tilt capabilities.""" - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - | CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.STOP_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) - _max_tilt = MAX_POSITION - _tilt_steps = 10 - def __init__(self, coordinator, device_info, room_name, shade, name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade, name) - self._attr_current_cover_tilt_position = 0 - - async def async_open_cover_tilt(self, **kwargs): - """Open the cover tilt.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION + self._attr_supported_features |= ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION ) - steps_to_move = current_hass_position + self._tilt_steps - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command(await self._shade.tilt_open()) + if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: + self._attr_supported_features |= CoverEntityFeature.STOP_TILT - async def async_close_cover_tilt(self, **kwargs): + @property + def current_cover_tilt_position(self) -> int: + """Return the current cover tile position.""" + return hd_position_to_hass(self.positions.vane, self._max_tilt) + + @property + def transition_steps(self): + """Return the steps to make a move.""" + return hd_position_to_hass( + self.positions.primary, MAX_POSITION + ) + hd_position_to_hass(self.positions.vane, self._max_tilt) + + @property + def open_position(self) -> PowerviewShadeMove: + """Return the open position and required additional positions.""" + return PowerviewShadeMove( + self._shade.open_position, {POS_KIND_VANE: MIN_POSITION} + ) + + @property + def close_position(self) -> PowerviewShadeMove: + """Return the close position and required additional positions.""" + return PowerviewShadeMove( + self._shade.close_position, {POS_KIND_VANE: MIN_POSITION} + ) + + @property + def open_tilt_position(self) -> PowerviewShadeMove: + """Return the open tilt position and required additional positions.""" + # next upstream api release to include self._shade.open_tilt_position + return PowerviewShadeMove( + {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: self._max_tilt}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + @property + def close_tilt_position(self) -> PowerviewShadeMove: + """Return the close tilt position and required additional positions.""" + # next upstream api release to include self._shade.close_tilt_position + return PowerviewShadeMove( + {ATTR_POSKIND1: POS_KIND_VANE, ATTR_POSITION1: MIN_POSITION}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION - ) - steps_to_move = current_hass_position + self._tilt_steps - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command(await self._shade.tilt_close()) + self._async_schedule_update_for_transition(self.transition_steps) + await self._async_execute_move(self.close_tilt_position) + self.async_write_ha_state() - async def async_set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - target_hass_tilt_position = kwargs[ATTR_TILT_POSITION] - current_hass_position = hd_position_to_hass( - self._current_hd_cover_position, MAX_POSITION - ) - steps_to_move = current_hass_position + self._tilt_steps + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + self._async_schedule_update_for_transition(100 - self.transition_steps) + await self._async_execute_move(self.open_tilt_position) + self.async_write_ha_state() - self._async_schedule_update_for_transition(steps_to_move) - self._async_update_from_command( - await self._shade.move( - { - ATTR_POSITION1: hass_position_to_hd( - target_hass_tilt_position, self._max_tilt - ), - ATTR_POSKIND1: POSKIND_VANE, - } - ) - ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the vane to a specific position.""" + await self._async_set_cover_tilt_position(kwargs[ATTR_TILT_POSITION]) - async def async_stop_cover_tilt(self, **kwargs): - """Stop the cover tilting.""" - # Cancel any previous updates - await self.async_stop_cover() + async def _async_set_cover_tilt_position( + self, target_hass_tilt_position: int + ) -> None: + """Move the vane to a specific position.""" + final_position = self.current_cover_position + target_hass_tilt_position + self._async_schedule_update_for_transition( + abs(self.transition_steps - final_position) + ) + await self._async_execute_move(self._get_shade_tilt(target_hass_tilt_position)) + self.async_write_ha_state() @callback - def _async_process_updated_position_data(self, position_data): - """Process position data.""" - if ATTR_POSKIND1 not in position_data: - return - if int(position_data[ATTR_POSKIND1]) == POSKIND_PRIMARY: - self._current_hd_cover_position = int(position_data[ATTR_POSITION1]) - self._attr_current_cover_tilt_position = 0 - if int(position_data[ATTR_POSKIND1]) == POSKIND_VANE: - self._current_hd_cover_position = MIN_POSITION - self._attr_current_cover_tilt_position = hd_position_to_hass( - int(position_data[ATTR_POSITION1]), self._max_tilt - ) + def _get_shade_move(self, target_hass_position: int) -> PowerviewShadeMove: + """Return a PowerviewShadeMove.""" + position_shade = hass_position_to_hd(target_hass_position) + return PowerviewShadeMove( + {ATTR_POSITION1: position_shade, ATTR_POSKIND1: POS_KIND_PRIMARY}, + {POS_KIND_VANE: MIN_POSITION}, + ) + + @callback + def _get_shade_tilt(self, target_hass_tilt_position: int) -> PowerviewShadeMove: + """Return a PowerviewShadeMove.""" + position_vane = hass_position_to_hd(target_hass_tilt_position, self._max_tilt) + return PowerviewShadeMove( + {ATTR_POSITION1: position_vane, ATTR_POSKIND1: POS_KIND_VANE}, + {POS_KIND_PRIMARY: MIN_POSITION}, + ) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilting.""" + await self.async_stop_cover() class PowerViewShadeSilhouette(PowerViewShadeWithTilt): """Representation of a Silhouette PowerView shade.""" - def __init__(self, coordinator, device_info, room_name, shade, name): - """Initialize the shade.""" - super().__init__(coordinator, device_info, room_name, shade, name) - self._max_tilt = 32767 - self._tilt_steps = 4 + _max_tilt = 32767 diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 50894d59f8b..174e3def2d6 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -1,6 +1,8 @@ -"""The nexia integration base entity.""" +"""The powerview integration base entity.""" -from aiopvapi.resources.shade import ATTR_TYPE +from typing import Any + +from aiopvapi.resources.shade import ATTR_TYPE, BaseShade from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION import homeassistant.helpers.device_registry as dr @@ -20,22 +22,30 @@ from .const import ( FIRMWARE_SUB_REVISION, MANUFACTURER, ) +from .coordinator import PowerviewShadeUpdateCoordinator +from .shade_data import PowerviewShadeData, PowerviewShadePositions -class HDEntity(CoordinatorEntity): +class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): """Base class for hunter douglas entities.""" - def __init__(self, coordinator, device_info, room_name, unique_id): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + unique_id: str, + ) -> None: """Initialize the entity.""" super().__init__(coordinator) self._room_name = room_name - self._unique_id = unique_id + self._attr_unique_id = unique_id self._device_info = device_info @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id + def data(self) -> PowerviewShadeData: + """Return the PowerviewShadeData.""" + return self.coordinator.data @property def device_info(self) -> DeviceInfo: @@ -58,12 +68,24 @@ class HDEntity(CoordinatorEntity): class ShadeEntity(HDEntity): """Base class for hunter douglas shade entities.""" - def __init__(self, coordinator, device_info, room_name, shade, shade_name): + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + shade_name: str, + ) -> None: """Initialize the shade.""" super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade + @property + def positions(self) -> PowerviewShadePositions: + """Return the PowerviewShadeData.""" + return self.data.get_shade_positions(self._shade.id) + @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" @@ -78,7 +100,7 @@ class ShadeEntity(HDEntity): ) for shade in self._shade.shade_types: - if shade.shade_type == device_info[ATTR_MODEL]: + if str(shade.shade_type) == device_info[ATTR_MODEL]: device_info[ATTR_MODEL] = shade.description break diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index af6aea17de3..c571056be23 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -3,7 +3,7 @@ "name": "Hunter Douglas PowerView", "documentation": "https://www.home-assistant.io/integrations/hunterdouglas_powerview", "requirements": ["aiopvapi==1.6.19"], - "codeowners": ["@bdraco", "@trullock"], + "codeowners": ["@bdraco", "@kingy444", "@trullock"], "config_flow": true, "homekit": { "models": ["PowerView"] diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 43e438041f2..8fd492ddb1d 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -63,16 +63,16 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): _attr_device_class = SensorDeviceClass.BATTERY _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, coordinator, device_info, room_name, shade, name): + """Initialize the shade.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self._attr_unique_id = f"{self._attr_unique_id}_charge" + @property def name(self): """Name of the shade battery.""" return f"{self._shade_name} Battery" - @property - def unique_id(self): - """Shade battery Uniqueid.""" - return f"{self._unique_id}_charge" - @property def native_value(self): """Get the current value in percentage.""" @@ -89,5 +89,5 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): @callback def _async_update_shade_from_group(self): """Update with new data from the coordinator.""" - self._shade.raw_data = self.coordinator.data[self._shade.id] + self._shade.raw_data = self.data.get_raw_data(self._shade.id) self.async_write_ha_state() diff --git a/homeassistant/components/hunterdouglas_powerview/shade_data.py b/homeassistant/components/hunterdouglas_powerview/shade_data.py new file mode 100644 index 00000000000..4a7b7be0945 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -0,0 +1,113 @@ +"""Shade data for the Hunter Douglas PowerView integration.""" +from __future__ import annotations + +from collections.abc import Iterable +from dataclasses import dataclass +import logging +from typing import Any + +from aiopvapi.helpers.constants import ( + ATTR_ID, + ATTR_POSITION1, + ATTR_POSITION2, + ATTR_POSITION_DATA, + ATTR_POSKIND1, + ATTR_POSKIND2, + ATTR_SHADE, +) +from aiopvapi.resources.shade import MIN_POSITION + +from .const import POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE +from .util import async_map_data_by_id + +POSITIONS = ((ATTR_POSITION1, ATTR_POSKIND1), (ATTR_POSITION2, ATTR_POSKIND2)) + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class PowerviewShadeMove: + """Request to move a powerview shade.""" + + # The positions to request on the hub + request: dict[str, int] + + # The positions that will also change + # as a result of the request that the + # hub will not send back + new_positions: dict[int, int] + + +@dataclass +class PowerviewShadePositions: + """Positions for a powerview shade.""" + + primary: int = MIN_POSITION + secondary: int = MIN_POSITION + vane: int = MIN_POSITION + + +class PowerviewShadeData: + """Coordinate shade data between multiple api calls.""" + + def __init__(self): + """Init the shade data.""" + self._group_data_by_id: dict[int, dict[str | int, Any]] = {} + self.positions: dict[int, PowerviewShadePositions] = {} + + def get_raw_data(self, shade_id: int) -> dict[str | int, Any]: + """Get data for the shade.""" + return self._group_data_by_id[shade_id] + + def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions: + """Get positions for a shade.""" + if shade_id not in self.positions: + self.positions[shade_id] = PowerviewShadePositions() + return self.positions[shade_id] + + def update_from_group_data(self, shade_id: int) -> None: + """Process an update from the group data.""" + self.update_shade_positions(self._group_data_by_id[shade_id]) + + def store_group_data(self, shade_data: Iterable[dict[str | int, Any]]) -> None: + """Store data from the all shades endpoint. + + This does not update the shades or positions + as the data may be stale. update_from_group_data + with a shade_id will update a specific shade + from the group data. + """ + self._group_data_by_id = async_map_data_by_id(shade_data) + + def update_shade_position(self, shade_id: int, position: int, kind: int) -> None: + """Update a single shade position.""" + positions = self.get_shade_positions(shade_id) + if kind == POS_KIND_PRIMARY: + positions.primary = position + elif kind == POS_KIND_SECONDARY: + positions.secondary = position + elif kind == POS_KIND_VANE: + positions.vane = position + + def update_from_position_data( + self, shade_id: int, position_data: dict[str, Any] + ) -> None: + """Update the shade positions from the position data.""" + for position_key, kind_key in POSITIONS: + if position_key in position_data: + self.update_shade_position( + shade_id, position_data[position_key], position_data[kind_key] + ) + + def update_shade_positions(self, data: dict[int | str, Any]) -> None: + """Update a shades from data dict.""" + _LOGGER.debug("Raw data update: %s", data) + shade_id = data[ATTR_ID] + position_data = data[ATTR_POSITION_DATA] + self.update_from_position_data(shade_id, position_data) + + def update_from_response(self, response: dict[str, Any]) -> None: + """Update from the response to a command.""" + if response and ATTR_SHADE in response: + shade_data: dict[int | str, Any] = response[ATTR_SHADE] + self.update_shade_positions(shade_data) diff --git a/homeassistant/components/hunterdouglas_powerview/util.py b/homeassistant/components/hunterdouglas_powerview/util.py new file mode 100644 index 00000000000..15330f30bdb --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/util.py @@ -0,0 +1,15 @@ +"""Coordinate data for powerview devices.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from aiopvapi.helpers.constants import ATTR_ID + +from homeassistant.core import callback + + +@callback +def async_map_data_by_id(data: Iterable[dict[str | int, Any]]): + """Return a dict with the key being the id for a list of entries.""" + return {entry[ATTR_ID]: entry for entry in data} From 7ecb527648876f25f41a76dc2ab9a45839f760ba Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 30 May 2022 21:55:44 +0200 Subject: [PATCH 100/947] Remove unneeded token_request override in Geocaching (#72713) --- homeassistant/components/geocaching/oauth.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/geocaching/oauth.py b/homeassistant/components/geocaching/oauth.py index e0120344cdb..848c4fce66c 100644 --- a/homeassistant/components/geocaching/oauth.py +++ b/homeassistant/components/geocaching/oauth.py @@ -1,7 +1,7 @@ """oAuth2 functions and classes for Geocaching API integration.""" from __future__ import annotations -from typing import Any, cast +from typing import Any from homeassistant.components.application_credentials import ( AuthImplementation, @@ -9,7 +9,6 @@ from homeassistant.components.application_credentials import ( ClientCredential, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ENVIRONMENT, ENVIRONMENT_URLS @@ -65,13 +64,3 @@ class GeocachingOAuth2Implementation(AuthImplementation): new_token = await self._token_request(data) return {**token, **new_token} - - async def _token_request(self, data: dict) -> dict: - """Make a token request.""" - data["client_id"] = self.client_id - if self.client_secret is not None: - data["client_secret"] = self.client_secret - session = async_get_clientsession(self.hass) - resp = await session.post(ENVIRONMENT_URLS[ENVIRONMENT]["token_url"], data=data) - resp.raise_for_status() - return cast(dict, await resp.json()) From 8c16ac2e47ef73e644ed8ec16e767e880994dee4 Mon Sep 17 00:00:00 2001 From: Ethan Madden Date: Mon, 30 May 2022 13:13:53 -0700 Subject: [PATCH 101/947] Vesync air quality (#72658) --- homeassistant/components/vesync/common.py | 25 +-- homeassistant/components/vesync/const.py | 28 +++ homeassistant/components/vesync/fan.py | 50 +---- homeassistant/components/vesync/sensor.py | 218 ++++++++++++---------- homeassistant/components/vesync/switch.py | 12 +- 5 files changed, 169 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index 1104a84e6b6..acee8e20961 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -20,6 +20,8 @@ async def async_process_devices(hass, manager): if manager.fans: devices[VS_FANS].extend(manager.fans) + # Expose fan sensors separately + devices[VS_SENSORS].extend(manager.fans) _LOGGER.info("%d VeSync fans found", len(manager.fans)) if manager.bulbs: @@ -49,31 +51,25 @@ class VeSyncBaseEntity(Entity): def __init__(self, device): """Initialize the VeSync device.""" self.device = device + self._attr_unique_id = self.base_unique_id + self._attr_name = self.base_name @property def base_unique_id(self): """Return the ID of this device.""" + # The unique_id property may be overridden in subclasses, such as in + # sensors. Maintaining base_unique_id allows us to group related + # entities under a single device. if isinstance(self.device.sub_device_no, int): return f"{self.device.cid}{str(self.device.sub_device_no)}" return self.device.cid - @property - def unique_id(self): - """Return the ID of this device.""" - # The unique_id property may be overridden in subclasses, such as in sensors. Maintaining base_unique_id allows - # us to group related entities under a single device. - return self.base_unique_id - @property def base_name(self): """Return the name of the device.""" + # Same story here as `base_unique_id` above return self.device.device_name - @property - def name(self): - """Return the name of the entity (may be overridden).""" - return self.base_name - @property def available(self) -> bool: """Return True if device is available.""" @@ -98,6 +94,11 @@ class VeSyncBaseEntity(Entity): class VeSyncDevice(VeSyncBaseEntity, ToggleEntity): """Base class for VeSync Device Representations.""" + @property + def details(self): + """Provide access to the device details dictionary.""" + return self.device.details + @property def is_on(self): """Return True if device is on.""" diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index fceeff81ae4..b20a04b8a1c 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -9,3 +9,31 @@ VS_FANS = "fans" VS_LIGHTS = "lights" VS_SENSORS = "sensors" VS_MANAGER = "manager" + +DEV_TYPE_TO_HA = { + "wifi-switch-1.3": "outlet", + "ESW03-USA": "outlet", + "ESW01-EU": "outlet", + "ESW15-USA": "outlet", + "ESWL01": "switch", + "ESWL03": "switch", + "ESO15-TB": "outlet", +} + +SKU_TO_BASE_DEVICE = { + "LV-PUR131S": "LV-PUR131S", + "LV-RH131S": "LV-PUR131S", # Alt ID Model LV-PUR131S + "Core200S": "Core200S", + "LAP-C201S-AUSR": "Core200S", # Alt ID Model Core200S + "LAP-C202S-WUSR": "Core200S", # Alt ID Model Core200S + "Core300S": "Core300S", + "LAP-C301S-WJP": "Core300S", # Alt ID Model Core300S + "Core400S": "Core400S", + "LAP-C401S-WJP": "Core400S", # Alt ID Model Core400S + "LAP-C401S-WUSR": "Core400S", # Alt ID Model Core400S + "LAP-C401S-WAAA": "Core400S", # Alt ID Model Core400S + "Core600S": "Core600S", + "LAP-C601S-WUS": "Core600S", # Alt ID Model Core600S + "LAP-C601S-WUSR": "Core600S", # Alt ID Model Core600S + "LAP-C601S-WEU": "Core600S", # Alt ID Model Core600S +} diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index f16a785ee1e..44e74209c30 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -14,26 +14,16 @@ from homeassistant.util.percentage import ( ) from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_FANS +from .const import DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_FANS _LOGGER = logging.getLogger(__name__) DEV_TYPE_TO_HA = { "LV-PUR131S": "fan", - "LV-RH131S": "fan", # Alt ID Model LV-PUR131S "Core200S": "fan", - "LAP-C201S-AUSR": "fan", # Alt ID Model Core200S - "LAP-C202S-WUSR": "fan", # Alt ID Model Core200S "Core300S": "fan", - "LAP-C301S-WJP": "fan", # Alt ID Model Core300S "Core400S": "fan", - "LAP-C401S-WJP": "fan", # Alt ID Model Core400S - "LAP-C401S-WUSR": "fan", # Alt ID Model Core400S - "LAP-C401S-WAAA": "fan", # Alt ID Model Core400S "Core600S": "fan", - "LAP-C601S-WUS": "fan", # Alt ID Model Core600S - "LAP-C601S-WUSR": "fan", # Alt ID Model Core600S - "LAP-C601S-WEU": "fan", # Alt ID Model Core600S } FAN_MODE_AUTO = "auto" @@ -41,37 +31,17 @@ FAN_MODE_SLEEP = "sleep" PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LV-RH131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model LV-PUR131S "Core200S": [FAN_MODE_SLEEP], - "LAP-C201S-AUSR": [FAN_MODE_SLEEP], # Alt ID Model Core200S - "LAP-C202S-WUSR": [FAN_MODE_SLEEP], # Alt ID Model Core200S "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C301S-WJP": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core300S "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C401S-WJP": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S - "LAP-C401S-WUSR": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S - "LAP-C401S-WAAA": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core400S "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], - "LAP-C601S-WUS": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S - "LAP-C601S-WUSR": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S - "LAP-C601S-WEU": [FAN_MODE_AUTO, FAN_MODE_SLEEP], # Alt ID Model Core600S } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), - "LV-RH131S": (1, 3), # ALt ID Model LV-PUR131S "Core200S": (1, 3), - "LAP-C201S-AUSR": (1, 3), # ALt ID Model Core200S - "LAP-C202S-WUSR": (1, 3), # ALt ID Model Core200S "Core300S": (1, 3), - "LAP-C301S-WJP": (1, 3), # ALt ID Model Core300S "Core400S": (1, 4), - "LAP-C401S-WJP": (1, 4), # ALt ID Model Core400S - "LAP-C401S-WUSR": (1, 4), # ALt ID Model Core400S - "LAP-C401S-WAAA": (1, 4), # ALt ID Model Core400S "Core600S": (1, 4), - "LAP-C601S-WUS": (1, 4), # ALt ID Model Core600S - "LAP-C601S-WUSR": (1, 4), # ALt ID Model Core600S - "LAP-C601S-WEU": (1, 4), # ALt ID Model Core600S } @@ -99,7 +69,7 @@ def _setup_entities(devices, async_add_entities): """Check if device is online and add entity.""" entities = [] for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) == "fan": + if DEV_TYPE_TO_HA.get(SKU_TO_BASE_DEVICE.get(dev.device_type)) == "fan": entities.append(VeSyncFanHA(dev)) else: _LOGGER.warning( @@ -128,19 +98,21 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): and (current_level := self.smartfan.fan_level) is not None ): return ranged_value_to_percentage( - SPEED_RANGE[self.device.device_type], current_level + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], current_level ) return None @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE[self.device.device_type]) + return int_states_in_range( + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]] + ) @property def preset_modes(self): """Get the list of available preset modes.""" - return PRESET_MODES[self.device.device_type] + return PRESET_MODES[SKU_TO_BASE_DEVICE.get(self.device.device_type)] @property def preset_mode(self): @@ -171,15 +143,9 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): if hasattr(self.smartfan, "night_light"): attr["night_light"] = self.smartfan.night_light - if self.smartfan.details.get("air_quality_value") is not None: - attr["air_quality"] = self.smartfan.details["air_quality_value"] - if hasattr(self.smartfan, "mode"): attr["mode"] = self.smartfan.mode - if hasattr(self.smartfan, "filter_life"): - attr["filter_life"] = self.smartfan.filter_life - return attr def set_percentage(self, percentage): @@ -195,7 +161,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): self.smartfan.change_fan_speed( math.ceil( percentage_to_ranged_value( - SPEED_RANGE[self.device.device_type], percentage + SPEED_RANGE[SKU_TO_BASE_DEVICE[self.device.device_type]], percentage ) ) ) diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index cc69bf36fa6..24ba6f2f0a0 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -1,25 +1,119 @@ """Support for power & energy sensors for VeSync outlets.""" +from collections.abc import Callable +from dataclasses import dataclass import logging from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + POWER_WATT, +) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from .common import VeSyncBaseEntity -from .const import DOMAIN, VS_DISCOVERY, VS_SENSORS -from .switch import DEV_TYPE_TO_HA +from .common import VeSyncBaseEntity, VeSyncDevice +from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS _LOGGER = logging.getLogger(__name__) +@dataclass +class VeSyncSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[VeSyncDevice], StateType] + + +@dataclass +class VeSyncSensorEntityDescription( + SensorEntityDescription, VeSyncSensorEntityDescriptionMixin +): + """Describe VeSync sensor entity.""" + + exists_fn: Callable[[VeSyncDevice], bool] = lambda _: True + update_fn: Callable[[VeSyncDevice], None] = lambda _: None + + +def update_energy(device): + """Update outlet details and energy usage.""" + device.update() + device.update_energy() + + +def sku_supported(device, supported): + """Get the base device of which a device is an instance.""" + return SKU_TO_BASE_DEVICE.get(device.device_type) in supported + + +def ha_dev_type(device): + """Get the homeassistant device_type for a given device.""" + return DEV_TYPE_TO_HA.get(device.device_type) + + +FILTER_LIFE_SUPPORTED = ["LV-PUR131S", "Core200S", "Core300S", "Core400S", "Core600S"] +AIR_QUALITY_SUPPORTED = ["LV-PUR131S", "Core400S", "Core600S"] +PM25_SUPPORTED = ["Core400S", "Core600S"] + +SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( + VeSyncSensorEntityDescription( + key="filter-life", + name="Filter Life", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.details["filter_life"], + exists_fn=lambda device: sku_supported(device, FILTER_LIFE_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="air-quality", + name="Air Quality", + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["air_quality"], + exists_fn=lambda device: sku_supported(device, AIR_QUALITY_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="pm25", + name="PM2.5", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["air_quality_value"], + exists_fn=lambda device: sku_supported(device, PM25_SUPPORTED), + ), + VeSyncSensorEntityDescription( + key="power", + name="current power", + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["power"], + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy", + name="energy use today", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.details["energy"], + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -44,107 +138,33 @@ def _setup_entities(devices, async_add_entities): """Check if device is online and add entity.""" entities = [] for dev in devices: - if DEV_TYPE_TO_HA.get(dev.device_type) != "outlet": - # Not an outlet that supports energy/power, so do not create sensor entities - continue - entities.append(VeSyncPowerSensor(dev)) - entities.append(VeSyncEnergySensor(dev)) - + for description in SENSORS: + if description.exists_fn(dev): + entities.append(VeSyncSensorEntity(dev, description)) async_add_entities(entities, update_before_add=True) class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): - """Representation of a sensor describing diagnostics of a VeSync outlet.""" + """Representation of a sensor describing a VeSync device.""" - def __init__(self, plug): + entity_description: VeSyncSensorEntityDescription + + def __init__( + self, + device: VeSyncDevice, + description: VeSyncSensorEntityDescription, + ) -> None: """Initialize the VeSync outlet device.""" - super().__init__(plug) - self.smartplug = plug + super().__init__(device) + self.entity_description = description + self._attr_name = f"{super().name} {description.name}" + self._attr_unique_id = f"{super().unique_id}-{description.key}" @property - def entity_category(self): - """Return the diagnostic entity category.""" - return EntityCategory.DIAGNOSTIC + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) - -class VeSyncPowerSensor(VeSyncSensorEntity): - """Representation of current power use for a VeSync outlet.""" - - @property - def unique_id(self): - """Return unique ID for power sensor on device.""" - return f"{super().unique_id}-power" - - @property - def name(self): - """Return sensor name.""" - return f"{super().name} current power" - - @property - def device_class(self): - """Return the power device class.""" - return SensorDeviceClass.POWER - - @property - def native_value(self): - """Return the current power usage in W.""" - return self.smartplug.power - - @property - def native_unit_of_measurement(self): - """Return the Watt unit of measurement.""" - return POWER_WATT - - @property - def state_class(self): - """Return the measurement state class.""" - return SensorStateClass.MEASUREMENT - - def update(self): - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() - - -class VeSyncEnergySensor(VeSyncSensorEntity): - """Representation of current day's energy use for a VeSync outlet.""" - - def __init__(self, plug): - """Initialize the VeSync outlet device.""" - super().__init__(plug) - self.smartplug = plug - - @property - def unique_id(self): - """Return unique ID for power sensor on device.""" - return f"{super().unique_id}-energy" - - @property - def name(self): - """Return sensor name.""" - return f"{super().name} energy use today" - - @property - def device_class(self): - """Return the energy device class.""" - return SensorDeviceClass.ENERGY - - @property - def native_value(self): - """Return the today total energy usage in kWh.""" - return self.smartplug.energy_today - - @property - def native_unit_of_measurement(self): - """Return the kWh unit of measurement.""" - return ENERGY_KILO_WATT_HOUR - - @property - def state_class(self): - """Return the total_increasing state class.""" - return SensorStateClass.TOTAL_INCREASING - - def update(self): - """Update outlet details and energy usage.""" - self.smartplug.update() - self.smartplug.update_energy() + def update(self) -> None: + """Run the update function defined for the sensor.""" + return self.entity_description.update_fn(self.device) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index 282f8d99817..e5fd4c829fe 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -8,20 +8,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .common import VeSyncDevice -from .const import DOMAIN, VS_DISCOVERY, VS_SWITCHES +from .const import DEV_TYPE_TO_HA, DOMAIN, VS_DISCOVERY, VS_SWITCHES _LOGGER = logging.getLogger(__name__) -DEV_TYPE_TO_HA = { - "wifi-switch-1.3": "outlet", - "ESW03-USA": "outlet", - "ESW01-EU": "outlet", - "ESW15-USA": "outlet", - "ESWL01": "switch", - "ESWL03": "switch", - "ESO15-TB": "outlet", -} - async def async_setup_entry( hass: HomeAssistant, From 8e75547ca461f66135909cfb016aa682121cd15a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 15:36:58 -0600 Subject: [PATCH 102/947] Guard against missing data in 1st generation RainMachine controllers (#72632) --- .../components/rainmachine/binary_sensor.py | 16 +++---- .../components/rainmachine/sensor.py | 2 +- .../components/rainmachine/switch.py | 43 +++++++++++-------- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index fb404adb199..730b51c142a 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -158,17 +158,17 @@ class CurrentRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): def update_from_latest_data(self) -> None: """Update the state.""" if self.entity_description.key == TYPE_FREEZE: - self._attr_is_on = self.coordinator.data["freeze"] + self._attr_is_on = self.coordinator.data.get("freeze") elif self.entity_description.key == TYPE_HOURLY: - self._attr_is_on = self.coordinator.data["hourly"] + self._attr_is_on = self.coordinator.data.get("hourly") elif self.entity_description.key == TYPE_MONTH: - self._attr_is_on = self.coordinator.data["month"] + self._attr_is_on = self.coordinator.data.get("month") elif self.entity_description.key == TYPE_RAINDELAY: - self._attr_is_on = self.coordinator.data["rainDelay"] + self._attr_is_on = self.coordinator.data.get("rainDelay") elif self.entity_description.key == TYPE_RAINSENSOR: - self._attr_is_on = self.coordinator.data["rainSensor"] + self._attr_is_on = self.coordinator.data.get("rainSensor") elif self.entity_description.key == TYPE_WEEKDAY: - self._attr_is_on = self.coordinator.data["weekDay"] + self._attr_is_on = self.coordinator.data.get("weekDay") class ProvisionSettingsBinarySensor(RainMachineEntity, BinarySensorEntity): @@ -188,6 +188,6 @@ class UniversalRestrictionsBinarySensor(RainMachineEntity, BinarySensorEntity): def update_from_latest_data(self) -> None: """Update the state.""" if self.entity_description.key == TYPE_FREEZE_PROTECTION: - self._attr_is_on = self.coordinator.data["freezeProtectEnabled"] + self._attr_is_on = self.coordinator.data.get("freezeProtectEnabled") elif self.entity_description.key == TYPE_HOT_DAYS: - self._attr_is_on = self.coordinator.data["hotDaysExtraWatering"] + self._attr_is_on = self.coordinator.data.get("hotDaysExtraWatering") diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index b825faca7e1..a2b0f7cd539 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -198,7 +198,7 @@ class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): def update_from_latest_data(self) -> None: """Update the state.""" if self.entity_description.key == TYPE_FREEZE_TEMP: - self._attr_native_value = self.coordinator.data["freezeProtectTemp"] + self._attr_native_value = self.coordinator.data.get("freezeProtectTemp") class ZoneTimeRemainingSensor(RainMachineEntity, SensorEntity): diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index 007aec97a3e..a220aafa2a5 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -389,23 +389,32 @@ class RainMachineZone(RainMachineActivitySwitch): self._attr_is_on = bool(data["state"]) - self._attr_extra_state_attributes.update( - { - ATTR_AREA: round(data["waterSense"]["area"], 2), - ATTR_CURRENT_CYCLE: data["cycle"], - ATTR_FIELD_CAPACITY: round(data["waterSense"]["fieldCapacity"], 2), - ATTR_ID: data["uid"], - ATTR_NO_CYCLES: data["noOfCycles"], - ATTR_PRECIP_RATE: round(data["waterSense"]["precipitationRate"], 2), - ATTR_RESTRICTIONS: data["restriction"], - ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99), - ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99), - ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99), - ATTR_STATUS: RUN_STATE_MAP[data["state"]], - ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")), - ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data["type"], 99), - } - ) + attrs = { + ATTR_CURRENT_CYCLE: data["cycle"], + ATTR_ID: data["uid"], + ATTR_NO_CYCLES: data["noOfCycles"], + ATTR_RESTRICTIONS: data("restriction"), + ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99), + ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99), + ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99), + ATTR_STATUS: RUN_STATE_MAP[data["state"]], + ATTR_SUN_EXPOSURE: SUN_EXPOSURE_MAP.get(data.get("sun")), + ATTR_VEGETATION_TYPE: VEGETATION_MAP.get(data["type"], 99), + } + + if "waterSense" in data: + if "area" in data["waterSense"]: + attrs[ATTR_AREA] = round(data["waterSense"]["area"], 2) + if "fieldCapacity" in data["waterSense"]: + attrs[ATTR_FIELD_CAPACITY] = round( + data["waterSense"]["fieldCapacity"], 2 + ) + if "precipitationRate" in data["waterSense"]: + attrs[ATTR_PRECIP_RATE] = round( + data["waterSense"]["precipitationRate"], 2 + ) + + self._attr_extra_state_attributes.update(attrs) class RainMachineZoneEnabled(RainMachineEnabledSwitch): From 59f155b4825d0b94d8a2841cf0109e009ccb81ca Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 30 May 2022 23:37:28 +0200 Subject: [PATCH 103/947] Fix homewizard diagnostics and add tests (#72611) --- .coveragerc | 1 - .../components/homewizard/diagnostics.py | 9 ++-- tests/components/homewizard/conftest.py | 49 ++++++++++++++++++- .../components/homewizard/fixtures/data.json | 16 ++++++ .../homewizard/fixtures/device.json | 7 +++ .../components/homewizard/fixtures/state.json | 5 ++ .../components/homewizard/test_diagnostics.py | 47 ++++++++++++++++++ 7 files changed, 127 insertions(+), 7 deletions(-) create mode 100644 tests/components/homewizard/fixtures/data.json create mode 100644 tests/components/homewizard/fixtures/device.json create mode 100644 tests/components/homewizard/fixtures/state.json create mode 100644 tests/components/homewizard/test_diagnostics.py diff --git a/.coveragerc b/.coveragerc index 19f305dbf3b..4d05e704065 100644 --- a/.coveragerc +++ b/.coveragerc @@ -492,7 +492,6 @@ omit = homeassistant/components/homematic/* homeassistant/components/home_plus_control/api.py homeassistant/components/home_plus_control/switch.py - homeassistant/components/homewizard/diagnostics.py homeassistant/components/homeworks/* homeassistant/components/honeywell/__init__.py homeassistant/components/honeywell/climate.py diff --git a/homeassistant/components/homewizard/diagnostics.py b/homeassistant/components/homewizard/diagnostics.py index 3dd55933291..a97d2507098 100644 --- a/homeassistant/components/homewizard/diagnostics.py +++ b/homeassistant/components/homewizard/diagnostics.py @@ -1,6 +1,7 @@ """Diagnostics support for P1 Monitor.""" from __future__ import annotations +from dataclasses import asdict from typing import Any from homeassistant.components.diagnostics import async_redact_data @@ -21,10 +22,10 @@ async def async_get_config_entry_diagnostics( coordinator: HWEnergyDeviceUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] meter_data = { - "device": coordinator.api.device.todict(), - "data": coordinator.api.data.todict(), - "state": coordinator.api.state.todict() - if coordinator.api.state is not None + "device": asdict(coordinator.data["device"]), + "data": asdict(coordinator.data["data"]), + "state": asdict(coordinator.data["state"]) + if coordinator.data["state"] is not None else None, } diff --git a/tests/components/homewizard/conftest.py b/tests/components/homewizard/conftest.py index 15993aa35ed..1617db35458 100644 --- a/tests/components/homewizard/conftest.py +++ b/tests/components/homewizard/conftest.py @@ -1,10 +1,15 @@ """Fixtures for HomeWizard integration tests.""" +import json +from unittest.mock import AsyncMock, patch + +from homewizard_energy.models import Data, Device, State import pytest from homeassistant.components.homewizard.const import DOMAIN from homeassistant.const import CONF_IP_ADDRESS +from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture @pytest.fixture @@ -25,6 +30,46 @@ def mock_config_entry() -> MockConfigEntry: return MockConfigEntry( title="Product Name (aabbccddeeff)", domain=DOMAIN, - data={}, + data={CONF_IP_ADDRESS: "1.2.3.4"}, unique_id="aabbccddeeff", ) + + +@pytest.fixture +def mock_homewizardenergy(): + """Return a mocked P1 meter.""" + with patch( + "homeassistant.components.homewizard.coordinator.HomeWizardEnergy", + ) as device: + client = device.return_value + client.device = AsyncMock( + return_value=Device.from_dict( + json.loads(load_fixture("homewizard/device.json")) + ) + ) + client.data = AsyncMock( + return_value=Data.from_dict( + json.loads(load_fixture("homewizard/data.json")) + ) + ) + client.state = AsyncMock( + return_value=State.from_dict( + json.loads(load_fixture("homewizard/state.json")) + ) + ) + yield device + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: AsyncMock, +) -> MockConfigEntry: + """Set up the HomeWizard integration for testing.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry diff --git a/tests/components/homewizard/fixtures/data.json b/tests/components/homewizard/fixtures/data.json new file mode 100644 index 00000000000..b6eada38038 --- /dev/null +++ b/tests/components/homewizard/fixtures/data.json @@ -0,0 +1,16 @@ +{ + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "wifi_ssid": "My Wi-Fi", + "wifi_strength": 100, + "total_power_import_t1_kwh": 1234.111, + "total_power_import_t2_kwh": 5678.222, + "total_power_export_t1_kwh": 4321.333, + "total_power_export_t2_kwh": 8765.444, + "active_power_w": -123, + "active_power_l1_w": -123, + "active_power_l2_w": 456, + "active_power_l3_w": 123.456, + "total_gas_m3": 1122.333, + "gas_timestamp": 210314112233 +} diff --git a/tests/components/homewizard/fixtures/device.json b/tests/components/homewizard/fixtures/device.json new file mode 100644 index 00000000000..493daa12b94 --- /dev/null +++ b/tests/components/homewizard/fixtures/device.json @@ -0,0 +1,7 @@ +{ + "product_type": "HWE-P1", + "product_name": "P1 Meter", + "serial": "3c39e7aabbcc", + "firmware_version": "2.11", + "api_version": "v1" +} diff --git a/tests/components/homewizard/fixtures/state.json b/tests/components/homewizard/fixtures/state.json new file mode 100644 index 00000000000..bbc0242ed58 --- /dev/null +++ b/tests/components/homewizard/fixtures/state.json @@ -0,0 +1,5 @@ +{ + "power_on": true, + "switch_lock": false, + "brightness": 255 +} diff --git a/tests/components/homewizard/test_diagnostics.py b/tests/components/homewizard/test_diagnostics.py new file mode 100644 index 00000000000..e477c94d914 --- /dev/null +++ b/tests/components/homewizard/test_diagnostics.py @@ -0,0 +1,47 @@ +"""Tests for diagnostics data.""" +from aiohttp import ClientSession + +from homeassistant.components.diagnostics import REDACTED +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSession, + init_integration: MockConfigEntry, +): + """Test diagnostics.""" + assert await get_diagnostics_for_config_entry( + hass, hass_client, init_integration + ) == { + "entry": {"ip_address": REDACTED}, + "data": { + "device": { + "product_name": "P1 Meter", + "product_type": "HWE-P1", + "serial": REDACTED, + "api_version": "v1", + "firmware_version": "2.11", + }, + "data": { + "smr_version": 50, + "meter_model": "ISKRA 2M550T-101", + "wifi_ssid": REDACTED, + "wifi_strength": 100, + "total_power_import_t1_kwh": 1234.111, + "total_power_import_t2_kwh": 5678.222, + "total_power_export_t1_kwh": 4321.333, + "total_power_export_t2_kwh": 8765.444, + "active_power_w": -123, + "active_power_l1_w": -123, + "active_power_l2_w": 456, + "active_power_l3_w": 123.456, + "total_gas_m3": 1122.333, + "gas_timestamp": "2021-03-14T11:22:33", + }, + "state": {"power_on": True, "switch_lock": False, "brightness": 255}, + }, + } From 285a7251dff3058c1e6bc021b466e9247065f9f6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 May 2022 00:10:38 +0200 Subject: [PATCH 104/947] Adjust config-flow type hints in zwave_me (#72714) --- .../components/zwave_me/config_flow.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_me/config_flow.py b/homeassistant/components/zwave_me/config_flow.py index 4fee380ca48..9089e5514f5 100644 --- a/homeassistant/components/zwave_me/config_flow.py +++ b/homeassistant/components/zwave_me/config_flow.py @@ -1,4 +1,5 @@ """Config flow to configure ZWaveMe integration.""" +from __future__ import annotations import logging @@ -6,7 +7,9 @@ from url_normalize import url_normalize import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.zeroconf import ZeroconfServiceInfo from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.data_entry_flow import FlowResult from . import helpers from .const import DOMAIN @@ -17,13 +20,15 @@ _LOGGER = logging.getLogger(__name__) class ZWaveMeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """ZWaveMe integration config flow.""" - def __init__(self): + def __init__(self) -> None: """Initialize flow.""" - self.url = None - self.token = None - self.uuid = None + self.url: str | None = None + self.token: str | None = None + self.uuid: str | None = None - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle a flow initialized by the user or started with zeroconf.""" errors = {} placeholders = { @@ -55,6 +60,7 @@ class ZWaveMeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not self.url.startswith(("ws://", "wss://")): self.url = f"ws://{self.url}" self.url = url_normalize(self.url, default_scheme="ws") + assert self.url if self.uuid is None: self.uuid = await helpers.get_uuid(self.url, self.token) if self.uuid is not None: @@ -76,7 +82,9 @@ class ZWaveMeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_zeroconf(self, discovery_info): + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> FlowResult: """ Handle a discovered Z-Wave accessory - get url to pass into user step. From 6f8ba7ee2f7240c7b4a76e424f116005cd55595b Mon Sep 17 00:00:00 2001 From: eyager1 <44526531+eyager1@users.noreply.github.com> Date: Mon, 30 May 2022 18:32:52 -0400 Subject: [PATCH 105/947] Add empty string to list of invalid states (#72590) Add null state to list of invalid states --- homeassistant/components/statistics/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index ac62b63e8ca..3f33fa015b9 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -350,7 +350,7 @@ class StatisticsSensor(SensorEntity): if new_state.state == STATE_UNAVAILABLE: self.attributes[STAT_SOURCE_VALUE_VALID] = None return - if new_state.state in (STATE_UNKNOWN, None): + if new_state.state in (STATE_UNKNOWN, None, ""): self.attributes[STAT_SOURCE_VALUE_VALID] = False return From 565b60210d8f68991d3b9736e1e6179c932999d1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 30 May 2022 18:41:30 -0400 Subject: [PATCH 106/947] Add @lymanepp as codeowner to tomorrowio (#72725) --- CODEOWNERS | 4 ++-- homeassistant/components/tomorrowio/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 545d5027ecb..db9c0ff69d8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1063,8 +1063,8 @@ build.json @home-assistant/supervisor /tests/components/todoist/ @boralyl /homeassistant/components/tolo/ @MatthiasLohr /tests/components/tolo/ @MatthiasLohr -/homeassistant/components/tomorrowio/ @raman325 -/tests/components/tomorrowio/ @raman325 +/homeassistant/components/tomorrowio/ @raman325 @lymanepp +/tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek /homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco diff --git a/homeassistant/components/tomorrowio/manifest.json b/homeassistant/components/tomorrowio/manifest.json index a577ec517c1..5447b90d1ce 100644 --- a/homeassistant/components/tomorrowio/manifest.json +++ b/homeassistant/components/tomorrowio/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tomorrowio", "requirements": ["pytomorrowio==0.3.3"], - "codeowners": ["@raman325"], + "codeowners": ["@raman325", "@lymanepp"], "iot_class": "cloud_polling" } From 6b3f6e22d0e0807b633bd33311c300443fcf89bf Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 17:48:42 -0600 Subject: [PATCH 107/947] Fix invalid RainMachine syntax (#72732) --- homeassistant/components/rainmachine/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index a220aafa2a5..8d339682305 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -393,7 +393,7 @@ class RainMachineZone(RainMachineActivitySwitch): ATTR_CURRENT_CYCLE: data["cycle"], ATTR_ID: data["uid"], ATTR_NO_CYCLES: data["noOfCycles"], - ATTR_RESTRICTIONS: data("restriction"), + ATTR_RESTRICTIONS: data["restriction"], ATTR_SLOPE: SLOPE_TYPE_MAP.get(data["slope"], 99), ATTR_SOIL_TYPE: SOIL_TYPE_MAP.get(data["soil"], 99), ATTR_SPRINKLER_TYPE: SPRINKLER_TYPE_MAP.get(data["group_id"], 99), From 362f5720edfc2df4c5cd4ff8252efe57f54d99f4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 31 May 2022 00:23:11 +0000 Subject: [PATCH 108/947] [ci skip] Translation update --- .../components/aurora/translations/ja.json | 2 +- .../components/generic/translations/no.json | 2 ++ .../components/google/translations/bg.json | 9 +++++++ .../here_travel_time/translations/bg.json | 19 +++++++++++++++ .../components/ialarm_xr/translations/bg.json | 21 ++++++++++++++++ .../components/ialarm_xr/translations/de.json | 1 + .../components/ialarm_xr/translations/el.json | 1 + .../components/ialarm_xr/translations/fr.json | 1 + .../components/ialarm_xr/translations/hu.json | 1 + .../components/ialarm_xr/translations/id.json | 1 + .../components/ialarm_xr/translations/nl.json | 1 + .../components/ialarm_xr/translations/no.json | 22 +++++++++++++++++ .../components/ialarm_xr/translations/pl.json | 1 + .../ialarm_xr/translations/pt-BR.json | 1 + .../components/laundrify/translations/bg.json | 22 +++++++++++++++++ .../components/min_max/translations/ja.json | 4 ++-- .../components/plugwise/translations/bg.json | 4 ++++ .../components/plugwise/translations/de.json | 10 +++++--- .../components/plugwise/translations/el.json | 6 ++++- .../components/plugwise/translations/en.json | 24 ++++++++++++++++++- .../components/plugwise/translations/fr.json | 10 +++++--- .../components/plugwise/translations/hu.json | 6 ++++- .../components/plugwise/translations/id.json | 9 ++++--- .../components/plugwise/translations/nl.json | 6 ++++- .../components/plugwise/translations/no.json | 10 +++++--- .../plugwise/translations/pt-BR.json | 10 +++++--- .../components/plugwise/translations/tr.json | 4 +++- .../components/recorder/translations/bg.json | 7 ++++++ .../tankerkoenig/translations/fr.json | 3 ++- .../tankerkoenig/translations/ja.json | 3 ++- .../tankerkoenig/translations/nl.json | 3 ++- .../tankerkoenig/translations/no.json | 3 ++- .../tankerkoenig/translations/pl.json | 3 ++- .../components/threshold/translations/ja.json | 6 ++--- .../totalconnect/translations/no.json | 11 +++++++++ .../components/zwave_js/translations/ja.json | 2 +- 36 files changed, 217 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/here_travel_time/translations/bg.json create mode 100644 homeassistant/components/ialarm_xr/translations/bg.json create mode 100644 homeassistant/components/ialarm_xr/translations/no.json create mode 100644 homeassistant/components/laundrify/translations/bg.json create mode 100644 homeassistant/components/recorder/translations/bg.json diff --git a/homeassistant/components/aurora/translations/ja.json b/homeassistant/components/aurora/translations/ja.json index a4d9b830968..455ceceacac 100644 --- a/homeassistant/components/aurora/translations/ja.json +++ b/homeassistant/components/aurora/translations/ja.json @@ -17,7 +17,7 @@ "step": { "init": { "data": { - "threshold": "\u3057\u304d\u3044\u5024(%)" + "threshold": "\u95be\u5024(%)" } } } diff --git a/homeassistant/components/generic/translations/no.json b/homeassistant/components/generic/translations/no.json index 0c228e314d2..72355d002ed 100644 --- a/homeassistant/components/generic/translations/no.json +++ b/homeassistant/components/generic/translations/no.json @@ -15,6 +15,7 @@ "stream_no_video": "Stream har ingen video", "stream_not_permitted": "Operasjon er ikke tillatt mens du pr\u00f8ver \u00e5 koble til str\u00f8m. Feil RTSP-transportprotokoll?", "stream_unauthorised": "Autorisasjonen mislyktes under fors\u00f8k p\u00e5 \u00e5 koble til str\u00f8mmen", + "template_error": "Feil ved gjengivelse av mal. Se gjennom loggen for mer informasjon.", "timeout": "Tidsavbrudd under innlasting av URL", "unable_still_load": "Kan ikke laste inn gyldig bilde fra URL-adresse for stillbilde (f.eks. ugyldig verts-, URL- eller godkjenningsfeil). Se gjennom loggen hvis du vil ha mer informasjon.", "unknown": "Uventet feil" @@ -57,6 +58,7 @@ "stream_no_video": "Stream har ingen video", "stream_not_permitted": "Operasjon er ikke tillatt mens du pr\u00f8ver \u00e5 koble til str\u00f8m. Feil RTSP-transportprotokoll?", "stream_unauthorised": "Autorisasjonen mislyktes under fors\u00f8k p\u00e5 \u00e5 koble til str\u00f8mmen", + "template_error": "Feil ved gjengivelse av mal. Se gjennom loggen for mer informasjon.", "timeout": "Tidsavbrudd under innlasting av URL", "unable_still_load": "Kan ikke laste inn gyldig bilde fra URL-adresse for stillbilde (f.eks. ugyldig verts-, URL- eller godkjenningsfeil). Se gjennom loggen hvis du vil ha mer informasjon.", "unknown": "Uventet feil" diff --git a/homeassistant/components/google/translations/bg.json b/homeassistant/components/google/translations/bg.json index 38b08fc3616..0d82b088635 100644 --- a/homeassistant/components/google/translations/bg.json +++ b/homeassistant/components/google/translations/bg.json @@ -16,5 +16,14 @@ "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" } } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "\u0414\u043e\u0441\u0442\u044a\u043f \u043d\u0430 Home Assistant \u0434\u043e Google \u041a\u0430\u043b\u0435\u043d\u0434\u0430\u0440" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/here_travel_time/translations/bg.json b/homeassistant/components/here_travel_time/translations/bg.json new file mode 100644 index 00000000000..73cda8df2bb --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/bg.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447", + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/bg.json b/homeassistant/components/ialarm_xr/translations/bg.json new file mode 100644 index 00000000000..2189a9653ed --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "port": "\u041f\u043e\u0440\u0442", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/de.json b/homeassistant/components/ialarm_xr/translations/de.json index ccda778dcf6..32b35294072 100644 --- a/homeassistant/components/ialarm_xr/translations/de.json +++ b/homeassistant/components/ialarm_xr/translations/de.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", + "timeout": "Zeit\u00fcberschreitung beim Verbindungsaufbau", "unknown": "Unerwarteter Fehler" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/el.json b/homeassistant/components/ialarm_xr/translations/el.json index 067055d6654..acc75012a67 100644 --- a/homeassistant/components/ialarm_xr/translations/el.json +++ b/homeassistant/components/ialarm_xr/translations/el.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "timeout": "\u03a7\u03c1\u03bf\u03bd\u03b9\u03ba\u03cc \u03cc\u03c1\u03b9\u03bf \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/fr.json b/homeassistant/components/ialarm_xr/translations/fr.json index dec09505d3e..2ade23c9f4e 100644 --- a/homeassistant/components/ialarm_xr/translations/fr.json +++ b/homeassistant/components/ialarm_xr/translations/fr.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "\u00c9chec de connexion", + "timeout": "D\u00e9lai d'attente pour \u00e9tablir la connexion expir\u00e9", "unknown": "Erreur inattendue" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/hu.json b/homeassistant/components/ialarm_xr/translations/hu.json index 015de9b4bf6..6f72253aae6 100644 --- a/homeassistant/components/ialarm_xr/translations/hu.json +++ b/homeassistant/components/ialarm_xr/translations/hu.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a kapcsolat l\u00e9trehoz\u00e1sa sor\u00e1n", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/id.json b/homeassistant/components/ialarm_xr/translations/id.json index 558b7de6b24..e4688af2b37 100644 --- a/homeassistant/components/ialarm_xr/translations/id.json +++ b/homeassistant/components/ialarm_xr/translations/id.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Gagal terhubung", + "timeout": "Tenggang waktu membuat koneksi habis", "unknown": "Kesalahan yang tidak diharapkan" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/nl.json b/homeassistant/components/ialarm_xr/translations/nl.json index 3ec5fe68d61..a32ee7ccfbe 100644 --- a/homeassistant/components/ialarm_xr/translations/nl.json +++ b/homeassistant/components/ialarm_xr/translations/nl.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Kan geen verbinding maken", + "timeout": "Time-out bij het maken van verbinding", "unknown": "Onverwachte fout" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/no.json b/homeassistant/components/ialarm_xr/translations/no.json new file mode 100644 index 00000000000..ccadf2f9972 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "timeout": "Tidsavbrudd oppretter forbindelse", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "host": "Vert", + "password": "Passord", + "port": "Port", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/pl.json b/homeassistant/components/ialarm_xr/translations/pl.json index 137973010ca..3880b4a6e09 100644 --- a/homeassistant/components/ialarm_xr/translations/pl.json +++ b/homeassistant/components/ialarm_xr/translations/pl.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "timeout": "Limit czasu na nawi\u0105zanie po\u0142\u0105czenia", "unknown": "Nieoczekiwany b\u0142\u0105d" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/pt-BR.json b/homeassistant/components/ialarm_xr/translations/pt-BR.json index f18e4820eac..37dd49dc9d8 100644 --- a/homeassistant/components/ialarm_xr/translations/pt-BR.json +++ b/homeassistant/components/ialarm_xr/translations/pt-BR.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Falhou ao se conectar", + "timeout": "Tempo limite estabelecendo conex\u00e3o", "unknown": "Erro inesperado" }, "step": { diff --git a/homeassistant/components/laundrify/translations/bg.json b/homeassistant/components/laundrify/translations/bg.json new file mode 100644 index 00000000000..4721ecf584e --- /dev/null +++ b/homeassistant/components/laundrify/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "init": { + "data": { + "code": "\u041a\u043e\u0434 \u0437\u0430 \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 (xxx-xxx)" + } + }, + "reauth_confirm": { + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/min_max/translations/ja.json b/homeassistant/components/min_max/translations/ja.json index f9e767082d6..072e7a9b487 100644 --- a/homeassistant/components/min_max/translations/ja.json +++ b/homeassistant/components/min_max/translations/ja.json @@ -12,7 +12,7 @@ "round_digits": "\u7d71\u8a08\u7684\u7279\u6027\u304c\u5e73\u5747\u5024\u307e\u305f\u306f\u4e2d\u592e\u5024\u306e\u5834\u5408\u306b\u3001\u51fa\u529b\u306b\u542b\u307e\u308c\u308b\u5c0f\u6570\u70b9\u4ee5\u4e0b\u306e\u6841\u6570\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002" }, "description": "\u7d71\u8a08\u7684\u7279\u6027\u304c\u5e73\u5747\u307e\u305f\u306f\u4e2d\u592e\u5024\u306a\u5834\u5408\u306e\u7cbe\u5ea6\u3067\u3001\u5c0f\u6570\u70b9\u4ee5\u4e0b\u306e\u6841\u6570\u3092\u5236\u5fa1\u3057\u307e\u3059\u3002", - "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024 \u30bb\u30f3\u30b5\u30fc\u3092\u8ffd\u52a0" + "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024\u30bb\u30f3\u30b5\u30fc\u3092\u8ffd\u52a0" } } }, @@ -30,5 +30,5 @@ } } }, - "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024 \u30bb\u30f3\u30b5\u30fc" + "title": "\u6700\u5c0f/\u6700\u5927/\u5e73\u5747/\u4e2d\u592e\u5024\u30bb\u30f3\u30b5\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/bg.json b/homeassistant/components/plugwise/translations/bg.json index 0c1cf067319..18450edfce7 100644 --- a/homeassistant/components/plugwise/translations/bg.json +++ b/homeassistant/components/plugwise/translations/bg.json @@ -11,6 +11,10 @@ "flow_title": "{name}", "step": { "user": { + "data": { + "host": "IP \u0430\u0434\u0440\u0435\u0441", + "port": "\u041f\u043e\u0440\u0442" + }, "description": "\u041f\u0440\u043e\u0434\u0443\u043a\u0442:" }, "user_gateway": { diff --git a/homeassistant/components/plugwise/translations/de.json b/homeassistant/components/plugwise/translations/de.json index f6ce5fb1b2d..2b9d112977a 100644 --- a/homeassistant/components/plugwise/translations/de.json +++ b/homeassistant/components/plugwise/translations/de.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Verbindungstyp" + "flow_type": "Verbindungstyp", + "host": "IP-Adresse", + "password": "Smile ID", + "port": "Port", + "username": "Smile-Benutzername" }, - "description": "Produkt:", - "title": "Plugwise Typ" + "description": "Bitte eingeben", + "title": "Stelle eine Verbindung zu Smile her" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/el.json b/homeassistant/components/plugwise/translations/el.json index 8407cb38cb1..caee7bb5a88 100644 --- a/homeassistant/components/plugwise/translations/el.json +++ b/homeassistant/components/plugwise/translations/el.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" + "flow_type": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "host": "\u0394\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 IP", + "password": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc Smile", + "port": "\u0398\u03cd\u03c1\u03b1", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7 Smile" }, "description": "\u03a0\u03c1\u03bf\u03ca\u03cc\u03bd:", "title": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03b2\u03cd\u03c3\u03bc\u03b1\u03c4\u03bf\u03c2" diff --git a/homeassistant/components/plugwise/translations/en.json b/homeassistant/components/plugwise/translations/en.json index 48d3d2d0e46..3f365bfa25e 100644 --- a/homeassistant/components/plugwise/translations/en.json +++ b/homeassistant/components/plugwise/translations/en.json @@ -9,8 +9,20 @@ "invalid_setup": "Add your Adam instead of your Anna, see the Home Assistant Plugwise integration documentation for more information", "unknown": "Unexpected error" }, + "flow_title": "{name}", "step": { "user": { + "data": { + "flow_type": "Connection type", + "host": "IP Address", + "password": "Smile ID", + "port": "Port", + "username": "Smile Username" + }, + "description": "Please enter", + "title": "Connect to the Smile" + }, + "user_gateway": { "data": { "host": "IP Address", "password": "Smile ID", @@ -21,5 +33,15 @@ "title": "Connect to the Smile" } } + }, + "options": { + "step": { + "init": { + "data": { + "scan_interval": "Scan Interval (seconds)" + }, + "description": "Adjust Plugwise Options" + } + } } -} +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/fr.json b/homeassistant/components/plugwise/translations/fr.json index baa6074e68a..0306437b405 100644 --- a/homeassistant/components/plugwise/translations/fr.json +++ b/homeassistant/components/plugwise/translations/fr.json @@ -13,9 +13,13 @@ "step": { "user": { "data": { - "flow_type": "Type de connexion" + "flow_type": "Type de connexion", + "host": "Adresse IP", + "password": "ID Smile", + "port": "Port", + "username": "Nom d'utilisateur Smile" }, - "description": "Veuillez saisir :", + "description": "Veuillez saisir", "title": "Se connecter \u00e0 Smile" }, "user_gateway": { @@ -23,7 +27,7 @@ "host": "Adresse IP", "password": "ID Smile", "port": "Port", - "username": "Nom d'utilisateur de sourire" + "username": "Nom d'utilisateur Smile" }, "description": "Veuillez saisir :", "title": "Se connecter \u00e0 Smile" diff --git a/homeassistant/components/plugwise/translations/hu.json b/homeassistant/components/plugwise/translations/hu.json index 265a99186ba..8b9b619f728 100644 --- a/homeassistant/components/plugwise/translations/hu.json +++ b/homeassistant/components/plugwise/translations/hu.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "Kapcsolat t\u00edpusa" + "flow_type": "Kapcsolat t\u00edpusa", + "host": "IP c\u00edm", + "password": "Smile azonos\u00edt\u00f3", + "port": "Port", + "username": "Smile Felhaszn\u00e1l\u00f3n\u00e9v" }, "description": "Term\u00e9k:", "title": "Plugwise t\u00edpus" diff --git a/homeassistant/components/plugwise/translations/id.json b/homeassistant/components/plugwise/translations/id.json index 8878a4e775d..436514d1a82 100644 --- a/homeassistant/components/plugwise/translations/id.json +++ b/homeassistant/components/plugwise/translations/id.json @@ -13,10 +13,13 @@ "step": { "user": { "data": { - "flow_type": "Jenis koneksi" + "flow_type": "Jenis koneksi", + "host": "Alamat IP", + "password": "ID Smile", + "port": "Port" }, - "description": "Produk:", - "title": "Jenis Plugwise" + "description": "Masukkan", + "title": "Hubungkan ke Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/nl.json b/homeassistant/components/plugwise/translations/nl.json index 8109386013c..14d25d6716e 100644 --- a/homeassistant/components/plugwise/translations/nl.json +++ b/homeassistant/components/plugwise/translations/nl.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "Verbindingstype" + "flow_type": "Verbindingstype", + "host": "IP-adres", + "password": "Smile-ID", + "port": "Poort", + "username": "Smile-gebruikersnaam" }, "description": "Product:", "title": "Plugwise type" diff --git a/homeassistant/components/plugwise/translations/no.json b/homeassistant/components/plugwise/translations/no.json index d8ce2d8956a..d9fbb15b2e8 100644 --- a/homeassistant/components/plugwise/translations/no.json +++ b/homeassistant/components/plugwise/translations/no.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Tilkoblingstype" + "flow_type": "Tilkoblingstype", + "host": "IP adresse", + "password": "Smile ID", + "port": "Port", + "username": "Smile brukernavn" }, - "description": "Produkt:", - "title": "" + "description": "Vennligst skriv inn", + "title": "Koble til Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/pt-BR.json b/homeassistant/components/plugwise/translations/pt-BR.json index 35a08d93114..12f8070f074 100644 --- a/homeassistant/components/plugwise/translations/pt-BR.json +++ b/homeassistant/components/plugwise/translations/pt-BR.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Tipo de conex\u00e3o" + "flow_type": "Tipo de conex\u00e3o", + "host": "Endere\u00e7o IP", + "password": "ID do Smile", + "port": "Porta", + "username": "Nome de usu\u00e1rio do Smile" }, - "description": "Produto:", - "title": "Tipo Plugwise" + "description": "Por favor, insira", + "title": "Conecte-se ao Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/tr.json b/homeassistant/components/plugwise/translations/tr.json index ae756bf15d4..b4a07700d6f 100644 --- a/homeassistant/components/plugwise/translations/tr.json +++ b/homeassistant/components/plugwise/translations/tr.json @@ -13,7 +13,9 @@ "step": { "user": { "data": { - "flow_type": "Ba\u011flant\u0131 t\u00fcr\u00fc" + "flow_type": "Ba\u011flant\u0131 t\u00fcr\u00fc", + "host": "IP Adresi", + "port": "Port" }, "description": "\u00dcr\u00fcn:", "title": "Plugwise tipi" diff --git a/homeassistant/components/recorder/translations/bg.json b/homeassistant/components/recorder/translations/bg.json new file mode 100644 index 00000000000..2098a389361 --- /dev/null +++ b/homeassistant/components/recorder/translations/bg.json @@ -0,0 +1,7 @@ +{ + "system_health": { + "info": { + "database_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 \u0431\u0430\u0437\u0430\u0442\u0430 \u0434\u0430\u043d\u043d\u0438" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tankerkoenig/translations/fr.json b/homeassistant/components/tankerkoenig/translations/fr.json index 1a52eee5d39..865fdae66a1 100644 --- a/homeassistant/components/tankerkoenig/translations/fr.json +++ b/homeassistant/components/tankerkoenig/translations/fr.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Intervalle de mise \u00e0 jour", - "show_on_map": "Afficher les stations-services sur la carte" + "show_on_map": "Afficher les stations-services sur la carte", + "stations": "Stations-services" }, "title": "Options Tankerkoenig" } diff --git a/homeassistant/components/tankerkoenig/translations/ja.json b/homeassistant/components/tankerkoenig/translations/ja.json index 687e05322d5..8aa88b83f4a 100644 --- a/homeassistant/components/tankerkoenig/translations/ja.json +++ b/homeassistant/components/tankerkoenig/translations/ja.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "\u66f4\u65b0\u9593\u9694", - "show_on_map": "\u5730\u56f3\u4e0a\u306b\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u8868\u793a\u3059\u308b" + "show_on_map": "\u5730\u56f3\u4e0a\u306b\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u3092\u8868\u793a\u3059\u308b", + "stations": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3" }, "title": "Tankerkoenig\u30aa\u30d7\u30b7\u30e7\u30f3" } diff --git a/homeassistant/components/tankerkoenig/translations/nl.json b/homeassistant/components/tankerkoenig/translations/nl.json index 66d442a71f6..ea4aea3ff95 100644 --- a/homeassistant/components/tankerkoenig/translations/nl.json +++ b/homeassistant/components/tankerkoenig/translations/nl.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Update Interval", - "show_on_map": "Toon stations op kaart" + "show_on_map": "Toon stations op kaart", + "stations": "Stations" }, "title": "Tankerkoenig opties" } diff --git a/homeassistant/components/tankerkoenig/translations/no.json b/homeassistant/components/tankerkoenig/translations/no.json index 9d0b6ddab52..bbca71d933b 100644 --- a/homeassistant/components/tankerkoenig/translations/no.json +++ b/homeassistant/components/tankerkoenig/translations/no.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Oppdateringsintervall", - "show_on_map": "Vis stasjoner p\u00e5 kart" + "show_on_map": "Vis stasjoner p\u00e5 kart", + "stations": "Stasjoner" }, "title": "Tankerkoenig alternativer" } diff --git a/homeassistant/components/tankerkoenig/translations/pl.json b/homeassistant/components/tankerkoenig/translations/pl.json index e13ae2c4783..282c87cf3c8 100644 --- a/homeassistant/components/tankerkoenig/translations/pl.json +++ b/homeassistant/components/tankerkoenig/translations/pl.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji", - "show_on_map": "Poka\u017c stacje na mapie" + "show_on_map": "Poka\u017c stacje na mapie", + "stations": "Stacje" }, "title": "Opcje Tankerkoenig" } diff --git a/homeassistant/components/threshold/translations/ja.json b/homeassistant/components/threshold/translations/ja.json index 1978fa4f3c5..821de593499 100644 --- a/homeassistant/components/threshold/translations/ja.json +++ b/homeassistant/components/threshold/translations/ja.json @@ -12,7 +12,7 @@ "name": "\u540d\u524d", "upper": "\u4e0a\u9650\u5024" }, - "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002", + "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u5185\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002", "title": "\u65b0\u3057\u3044\u3057\u304d\u3044\u5024\u30bb\u30f3\u30b5\u30fc" } } @@ -30,9 +30,9 @@ "name": "\u540d\u524d", "upper": "\u4e0a\u9650\u5024" }, - "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002" + "description": "\u30bb\u30f3\u30b5\u30fc\u306e\u30aa\u30f3\u3068\u30aa\u30d5\u3092\u5207\u308a\u66ff\u3048\u308b\u30bf\u30a4\u30df\u30f3\u30b0\u3092\u8a2d\u5b9a\u3057\u307e\u3059\u3002\n\n\u4e0b\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0b\u9650\u5024\u3088\u308a\u5c0f\u3055\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0a\u9650\u306e\u307f\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c\u4e0a\u9650\u5024\u3088\u308a\u5927\u304d\u3044\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002\n\u4e0b\u9650\u3068\u4e0a\u9650\u306e\u4e21\u65b9\u3092\u8a2d\u5b9a - \u5165\u529b\u30bb\u30f3\u30b5\u30fc\u306e\u5024\u304c[\u4e0b\u9650..\u4e0a\u9650]\u306e\u7bc4\u56f2\u5185\u306b\u3042\u308b\u3068\u304d\u306b\u30aa\u30f3\u306b\u3057\u307e\u3059\u3002" } } }, - "title": "\u3057\u304d\u3044\u5024\u30bb\u30f3\u30b5\u30fc" + "title": "\u95be\u5024\u30bb\u30f3\u30b5\u30fc" } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/no.json b/homeassistant/components/totalconnect/translations/no.json index 86cfedf51d4..4ea6b791b23 100644 --- a/homeassistant/components/totalconnect/translations/no.json +++ b/homeassistant/components/totalconnect/translations/no.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Auto bypass lavt batteri" + }, + "description": "Omg\u00e5 automatisk soner i det \u00f8yeblikket de rapporterer lavt batteri.", + "title": "TotalConnect-alternativer" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/ja.json b/homeassistant/components/zwave_js/translations/ja.json index 902955c1b9e..f4c5a62b050 100644 --- a/homeassistant/components/zwave_js/translations/ja.json +++ b/homeassistant/components/zwave_js/translations/ja.json @@ -88,7 +88,7 @@ "event.value_notification.scene_activation": "{subtype} \u3067\u306e\u30b7\u30fc\u30f3\u306e\u30a2\u30af\u30c6\u30a3\u30d6\u5316", "state.node_status": "\u30ce\u30fc\u30c9\u30b9\u30c6\u30fc\u30bf\u30b9\u304c\u5909\u5316\u3057\u307e\u3057\u305f", "zwave_js.value_updated.config_parameter": "\u30b3\u30f3\u30d5\u30a3\u30b0\u30d1\u30e9\u30e1\u30fc\u30bf {subtype} \u306e\u5024\u306e\u5909\u66f4", - "zwave_js.value_updated.value": "Z-Wave JS\u5024\u306e\u5024\u306e\u5909\u66f4" + "zwave_js.value_updated.value": "Z-Wave JS\u5024\u306e\u5024\u3092\u5909\u66f4" } }, "options": { From 587fd05603b7e2a7d9adacc20075638e6a7e3715 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 14:34:32 -1000 Subject: [PATCH 109/947] Make logbook inherit the recorder filter (#72728) --- homeassistant/components/logbook/__init__.py | 16 +- homeassistant/components/recorder/filters.py | 46 ++++- .../components/logbook/test_websocket_api.py | 192 +++++++++++++++++- tests/components/recorder/test_filters.py | 114 +++++++++++ 4 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 tests/components/recorder/test_filters.py diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index f66f1d5e920..1abfcaba6ff 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -7,7 +7,10 @@ from typing import Any import voluptuous as vol from homeassistant.components import frontend +from homeassistant.components.recorder.const import DOMAIN as RECORDER_DOMAIN from homeassistant.components.recorder.filters import ( + extract_include_exclude_filter_conf, + merge_include_exclude_filters, sqlalchemy_filter_from_include_exclude_conf, ) from homeassistant.const import ( @@ -115,9 +118,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, "logbook", "logbook", "hass:format-list-bulleted-type" ) - if conf := config.get(DOMAIN, {}): - filters = sqlalchemy_filter_from_include_exclude_conf(conf) - entities_filter = convert_include_exclude_filter(conf) + recorder_conf = config.get(RECORDER_DOMAIN, {}) + logbook_conf = config.get(DOMAIN, {}) + recorder_filter = extract_include_exclude_filter_conf(recorder_conf) + logbook_filter = extract_include_exclude_filter_conf(logbook_conf) + merged_filter = merge_include_exclude_filters(recorder_filter, logbook_filter) + + possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) + if not possible_merged_entities_filter.empty_filter: + filters = sqlalchemy_filter_from_include_exclude_conf(merged_filter) + entities_filter = possible_merged_entities_filter else: filters = None entities_filter = None diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 5dd1e4b7884..0ceb013d8c5 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -25,6 +25,40 @@ GLOB_TO_SQL_CHARS = { ord("\\"): "\\\\", } +FILTER_TYPES = (CONF_EXCLUDE, CONF_INCLUDE) +FITLER_MATCHERS = (CONF_ENTITIES, CONF_DOMAINS, CONF_ENTITY_GLOBS) + + +def extract_include_exclude_filter_conf(conf: ConfigType) -> dict[str, Any]: + """Extract an include exclude filter from configuration. + + This makes a copy so we do not alter the original data. + """ + return { + filter_type: { + matcher: set(conf.get(filter_type, {}).get(matcher, [])) + for matcher in FITLER_MATCHERS + } + for filter_type in FILTER_TYPES + } + + +def merge_include_exclude_filters( + base_filter: dict[str, Any], add_filter: dict[str, Any] +) -> dict[str, Any]: + """Merge two filters. + + This makes a copy so we do not alter the original data. + """ + return { + filter_type: { + matcher: base_filter[filter_type][matcher] + | add_filter[filter_type][matcher] + for matcher in FITLER_MATCHERS + } + for filter_type in FILTER_TYPES + } + def sqlalchemy_filter_from_include_exclude_conf(conf: ConfigType) -> Filters | None: """Build a sql filter from config.""" @@ -46,13 +80,13 @@ class Filters: def __init__(self) -> None: """Initialise the include and exclude filters.""" - self.excluded_entities: list[str] = [] - self.excluded_domains: list[str] = [] - self.excluded_entity_globs: list[str] = [] + self.excluded_entities: Iterable[str] = [] + self.excluded_domains: Iterable[str] = [] + self.excluded_entity_globs: Iterable[str] = [] - self.included_entities: list[str] = [] - self.included_domains: list[str] = [] - self.included_entity_globs: list[str] = [] + self.included_entities: Iterable[str] = [] + self.included_domains: Iterable[str] = [] + self.included_entity_globs: Iterable[str] = [] @property def has_config(self) -> bool: diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 9d7146ec96c..1d35d6d897d 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -483,7 +483,7 @@ async def test_subscribe_unsubscribe_logbook_stream_excluded_entities( CONF_EXCLUDE: { CONF_ENTITIES: ["light.exc"], CONF_DOMAINS: ["switch"], - CONF_ENTITY_GLOBS: "*.excluded", + CONF_ENTITY_GLOBS: ["*.excluded"], } }, }, @@ -672,7 +672,7 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( CONF_INCLUDE: { CONF_ENTITIES: ["light.inc"], CONF_DOMAINS: ["switch"], - CONF_ENTITY_GLOBS: "*.included", + CONF_ENTITY_GLOBS: ["*.included"], } }, }, @@ -849,6 +849,194 @@ async def test_subscribe_unsubscribe_logbook_stream_included_entities( assert sum(hass.bus.async_listeners().values()) == init_count +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_logbook_stream_excluded_entities_inherits_filters_from_recorder( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream inherts filters from recorder.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "automation", "script") + ] + ) + await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: ["light.additional_excluded"], + } + }, + recorder.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: ["light.exc"], + CONF_DOMAINS: ["switch"], + CONF_ENTITY_GLOBS: ["*.excluded", "*.no_matches"], + } + }, + }, + ) + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + hass.states.async_set("light.additional_excluded", STATE_ON) + hass.states.async_set("light.additional_excluded", STATE_OFF) + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + state: State = hass.states.get("binary_sensor.is_light") + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": "off", + "when": state.last_updated.timestamp(), + } + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + hass.states.async_set("light.exc", STATE_ON) + hass.states.async_set("light.exc", STATE_OFF) + hass.states.async_set("switch.any", STATE_ON) + hass.states.async_set("switch.any", STATE_OFF) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + hass.states.async_set("light.additional_excluded", STATE_ON) + hass.states.async_set("light.additional_excluded", STATE_OFF) + hass.states.async_set("light.alpha", "on") + hass.states.async_set("light.alpha", "off") + alpha_off_state: State = hass.states.get("light.alpha") + hass.states.async_set("light.zulu", "on", {"color": "blue"}) + hass.states.async_set("light.zulu", "off", {"effect": "help"}) + zulu_off_state: State = hass.states.get("light.zulu") + hass.states.async_set( + "light.zulu", "on", {"effect": "help", "color": ["blue", "green"]} + ) + zulu_on_state: State = hass.states.get("light.zulu") + await hass.async_block_till_done() + + hass.states.async_remove("light.zulu") + await hass.async_block_till_done() + + hass.states.async_set("light.zulu", "on", {"effect": "help", "color": "blue"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "light.alpha", + "state": "off", + "when": alpha_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "off", + "when": zulu_off_state.last_updated.timestamp(), + }, + { + "entity_id": "light.zulu", + "state": "on", + "when": zulu_on_state.last_updated.timestamp(), + }, + ] + + await async_wait_recording_done(hass) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "cover.excluded"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + { + ATTR_NAME: "Mock automation switch matching entity", + ATTR_ENTITY_ID: "switch.match_domain", + }, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation switch matching domain", ATTR_DOMAIN: "switch"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation matches nothing"}, + ) + hass.bus.async_fire( + EVENT_AUTOMATION_TRIGGERED, + {ATTR_NAME: "Mock automation 3", ATTR_ENTITY_ID: "light.keep"}, + ) + hass.states.async_set("cover.excluded", STATE_ON) + hass.states.async_set("cover.excluded", STATE_OFF) + await hass.async_block_till_done() + msg = await websocket_client.receive_json() + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_id": ANY, + "domain": "automation", + "entity_id": None, + "message": "triggered", + "name": "Mock automation matches nothing", + "source": None, + "when": ANY, + }, + { + "context_id": ANY, + "domain": "automation", + "entity_id": "light.keep", + "message": "triggered", + "name": "Mock automation 3", + "source": None, + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) async def test_subscribe_unsubscribe_logbook_stream( hass, recorder_mock, hass_ws_client diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py new file mode 100644 index 00000000000..fa80df6e345 --- /dev/null +++ b/tests/components/recorder/test_filters.py @@ -0,0 +1,114 @@ +"""The tests for recorder filters.""" + +from homeassistant.components.recorder.filters import ( + extract_include_exclude_filter_conf, + merge_include_exclude_filters, +) +from homeassistant.helpers.entityfilter import ( + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_GLOBS, + CONF_EXCLUDE, + CONF_INCLUDE, +) + +SIMPLE_INCLUDE_FILTER = { + CONF_INCLUDE: { + CONF_DOMAINS: ["homeassistant"], + CONF_ENTITIES: ["sensor.one"], + CONF_ENTITY_GLOBS: ["climate.*"], + } +} +SIMPLE_INCLUDE_FILTER_DIFFERENT_ENTITIES = { + CONF_INCLUDE: { + CONF_DOMAINS: ["other"], + CONF_ENTITIES: ["not_sensor.one"], + CONF_ENTITY_GLOBS: ["not_climate.*"], + } +} +SIMPLE_EXCLUDE_FILTER = { + CONF_EXCLUDE: { + CONF_DOMAINS: ["homeassistant"], + CONF_ENTITIES: ["sensor.one"], + CONF_ENTITY_GLOBS: ["climate.*"], + } +} +SIMPLE_INCLUDE_EXCLUDE_FILTER = {**SIMPLE_INCLUDE_FILTER, **SIMPLE_EXCLUDE_FILTER} + + +def test_extract_include_exclude_filter_conf(): + """Test we can extract a filter from configuration without altering it.""" + include_filter = extract_include_exclude_filter_conf(SIMPLE_INCLUDE_FILTER) + assert include_filter == { + CONF_EXCLUDE: { + CONF_DOMAINS: set(), + CONF_ENTITIES: set(), + CONF_ENTITY_GLOBS: set(), + }, + CONF_INCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + } + + exclude_filter = extract_include_exclude_filter_conf(SIMPLE_EXCLUDE_FILTER) + assert exclude_filter == { + CONF_INCLUDE: { + CONF_DOMAINS: set(), + CONF_ENTITIES: set(), + CONF_ENTITY_GLOBS: set(), + }, + CONF_EXCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + } + + include_exclude_filter = extract_include_exclude_filter_conf( + SIMPLE_INCLUDE_EXCLUDE_FILTER + ) + assert include_exclude_filter == { + CONF_INCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + CONF_EXCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + } + + include_exclude_filter[CONF_EXCLUDE][CONF_ENTITIES] = {"cover.altered"} + # verify it really is a copy + assert SIMPLE_INCLUDE_EXCLUDE_FILTER[CONF_EXCLUDE][CONF_ENTITIES] != { + "cover.altered" + } + + +def test_merge_include_exclude_filters(): + """Test we can merge two filters together.""" + include_exclude_filter_base = extract_include_exclude_filter_conf( + SIMPLE_INCLUDE_EXCLUDE_FILTER + ) + include_filter_add = extract_include_exclude_filter_conf( + SIMPLE_INCLUDE_FILTER_DIFFERENT_ENTITIES + ) + merged_filter = merge_include_exclude_filters( + include_exclude_filter_base, include_filter_add + ) + assert merged_filter == { + CONF_EXCLUDE: { + CONF_DOMAINS: {"homeassistant"}, + CONF_ENTITIES: {"sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*"}, + }, + CONF_INCLUDE: { + CONF_DOMAINS: {"other", "homeassistant"}, + CONF_ENTITIES: {"not_sensor.one", "sensor.one"}, + CONF_ENTITY_GLOBS: {"climate.*", "not_climate.*"}, + }, + } From ec44a63a84b7b7e9c38a0e62753d34a0a7a759c3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 18:58:08 -0600 Subject: [PATCH 110/947] Bump regenmaschine to 2022.05.1 (#72735) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index bbe58e263b1..98dc9a6c877 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==2022.05.0"], + "requirements": ["regenmaschine==2022.05.1"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index f66db8e37f9..e0ccf63b91a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2065,7 +2065,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.05.0 +regenmaschine==2022.05.1 # homeassistant.components.renault renault-api==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c236a33697b..c04219f93da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1364,7 +1364,7 @@ rachiopy==1.0.3 radios==0.1.1 # homeassistant.components.rainmachine -regenmaschine==2022.05.0 +regenmaschine==2022.05.1 # homeassistant.components.renault renault-api==0.1.11 From f9bd384e6c85d80b232d7ba8fe46406655e2d442 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 17:24:34 -1000 Subject: [PATCH 111/947] Stop waiting for setup retry upon discovery (#72738) --- homeassistant/config_entries.py | 52 +++++++++++++--------- tests/test_config_entries.py | 76 ++++++++++++++++++++++++++++++++- 2 files changed, 106 insertions(+), 22 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 0ac02adb8d0..49b2059b2a2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -108,7 +108,7 @@ class ConfigEntryState(Enum): DEFAULT_DISCOVERY_UNIQUE_ID = "default_discovery_unique_id" DISCOVERY_NOTIFICATION_ID = "config_entry_discovery" -DISCOVERY_SOURCES = ( +DISCOVERY_SOURCES = { SOURCE_DHCP, SOURCE_DISCOVERY, SOURCE_HOMEKIT, @@ -119,7 +119,7 @@ DISCOVERY_SOURCES = ( SOURCE_UNIGNORE, SOURCE_USB, SOURCE_ZEROCONF, -) +} RECONFIGURE_NOTIFICATION_ID = "config_entry_reconfigure" @@ -1242,24 +1242,36 @@ class ConfigFlow(data_entry_flow.FlowHandler): return for entry in self._async_current_entries(include_ignore=True): - if entry.unique_id == self.unique_id: - if updates is not None: - changed = self.hass.config_entries.async_update_entry( - entry, data={**entry.data, **updates} - ) - if ( - changed - and reload_on_update - and entry.state - in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) - ): - self.hass.async_create_task( - self.hass.config_entries.async_reload(entry.entry_id) - ) - # Allow ignored entries to be configured on manual user step - if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: - continue - raise data_entry_flow.AbortFlow("already_configured") + if entry.unique_id != self.unique_id: + continue + should_reload = False + if ( + updates is not None + and self.hass.config_entries.async_update_entry( + entry, data={**entry.data, **updates} + ) + and reload_on_update + and entry.state + in (ConfigEntryState.LOADED, ConfigEntryState.SETUP_RETRY) + ): + # Existing config entry present, and the + # entry data just changed + should_reload = True + elif ( + self.source in DISCOVERY_SOURCES + and entry.state is ConfigEntryState.SETUP_RETRY + ): + # Existing config entry present in retry state, and we + # just discovered the unique id so we know its online + should_reload = True + # Allow ignored entries to be configured on manual user step + if entry.source == SOURCE_IGNORE and self.source == SOURCE_USER: + continue + if should_reload: + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + raise data_entry_flow.AbortFlow("already_configured") async def async_set_unique_id( self, unique_id: str | None = None, *, raise_on_progress: bool = True diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 2602887d1d5..09951b4f34e 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1,4 +1,6 @@ """Test the config manager.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -7,6 +9,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from homeassistant import config_entries, data_entry_flow, loader +from homeassistant.components import dhcp from homeassistant.components.hassio import HassioServiceInfo from homeassistant.const import ( EVENT_COMPONENT_LOADED, @@ -14,7 +17,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import CoreState, Event, callback -from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, BaseServiceInfo +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, BaseServiceInfo, FlowResult from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryNotReady, @@ -1644,7 +1647,7 @@ async def test_unique_id_update_existing_entry_without_reload(hass, manager): async def async_step_user(self, user_input=None): """Test user step.""" await self.async_set_unique_id("mock-unique-id") - await self._abort_if_unique_id_configured( + self._abort_if_unique_id_configured( updates={"host": "1.1.1.1"}, reload_on_update=False ) @@ -1725,6 +1728,75 @@ async def test_unique_id_update_existing_entry_with_reload(hass, manager): assert len(async_reload.mock_calls) == 0 +async def test_unique_id_from_discovery_in_setup_retry(hass, manager): + """Test that we reload when in a setup retry state from discovery.""" + hass.config.components.add("comp") + unique_id = "34:ea:34:b4:3b:5a" + host = "0.0.0.0" + entry = MockConfigEntry( + domain="comp", + data={"additional": "data", "host": host}, + unique_id=unique_id, + state=config_entries.ConfigEntryState.SETUP_RETRY, + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule("comp"), + ) + mock_entity_platform(hass, "config_flow.comp", None) + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_dhcp( + self, discovery_info: dhcp.DhcpServiceInfo + ) -> FlowResult: + """Test dhcp step.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + async def async_step_user(self, user_input: dict | None = None) -> FlowResult: + """Test user step.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + # Verify we do not reload from a user source + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload: + result = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert len(async_reload.mock_calls) == 0 + + # Verify do reload from a discovery source + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload: + discovery_result = await manager.flow.async_init( + "comp", + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="any", + ip=host, + macaddress=unique_id, + ), + ) + await hass.async_block_till_done() + + assert discovery_result["type"] == RESULT_TYPE_ABORT + assert discovery_result["reason"] == "already_configured" + assert len(async_reload.mock_calls) == 1 + + async def test_unique_id_not_update_existing_entry(hass, manager): """Test that we do not update an entry if existing entry has the data.""" hass.config.components.add("comp") From 99f3ca1f08e706abbc090abf8f68a7578d60ef81 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 30 May 2022 20:41:05 -0700 Subject: [PATCH 112/947] Add support for announce to play_media (#72566) --- .../components/media_player/__init__.py | 4 +++- .../components/media_player/const.py | 1 + .../components/media_player/services.yaml | 7 ++++++ homeassistant/components/tts/__init__.py | 2 ++ tests/components/media_player/test_init.py | 23 +++++++++++++++++++ tests/components/tts/test_init.py | 2 ++ 6 files changed, 38 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index f71f3fc2a1f..dc2f3624a0e 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -76,6 +76,7 @@ from .const import ( # noqa: F401 ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_ARTIST, ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, @@ -182,9 +183,10 @@ DEVICE_CLASS_RECEIVER = MediaPlayerDeviceClass.RECEIVER.value MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, - vol.Optional(ATTR_MEDIA_ENQUEUE): vol.Any( + vol.Exclusive(ATTR_MEDIA_ENQUEUE, "enqueue_announce"): vol.Any( cv.boolean, vol.Coerce(MediaPlayerEnqueue) ), + vol.Exclusive(ATTR_MEDIA_ANNOUNCE, "enqueue_announce"): cv.boolean, vol.Optional(ATTR_MEDIA_EXTRA, default={}): dict, } diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py index b12f0c4ae01..4d534467ad6 100644 --- a/homeassistant/components/media_player/const.py +++ b/homeassistant/components/media_player/const.py @@ -10,6 +10,7 @@ ATTR_ENTITY_PICTURE_LOCAL = "entity_picture_local" ATTR_GROUP_MEMBERS = "group_members" ATTR_INPUT_SOURCE = "source" ATTR_INPUT_SOURCE_LIST = "source_list" +ATTR_MEDIA_ANNOUNCE = "announce" ATTR_MEDIA_ALBUM_ARTIST = "media_album_artist" ATTR_MEDIA_ALBUM_NAME = "media_album_name" ATTR_MEDIA_ARTIST = "media_artist" diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index b2a8ac40262..b698b87aec6 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -166,6 +166,13 @@ play_media: value: "add" - label: "Play now and clear queue" value: "replace" + announce: + name: Announce + description: If the media should be played as an announcement. + required: false + example: "true" + selector: + boolean: select_source: name: Select source diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 4628ec8768a..706122c174c 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -21,6 +21,7 @@ import yarl from homeassistant.components.http import HomeAssistantView from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP, @@ -224,6 +225,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: str(yarl.URL.build(path=p_type, query=params)), ), ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_MEDIA_ANNOUNCE: True, }, blocking=True, context=service.context, diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index cb095cbcfe0..eceb7e9ec4f 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from unittest.mock import patch import pytest +import voluptuous as vol from homeassistant.components import media_player from homeassistant.components.media_player.browse_media import BrowseMedia @@ -291,3 +292,25 @@ async def test_enqueue_rewrite(hass, input, expected): assert len(mock_play_media.mock_calls) == 1 assert mock_play_media.mock_calls[0][2]["enqueue"] == expected + + +async def test_enqueue_alert_exclusive(hass): + """Test that alert and enqueue cannot be used together.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media_content_type": "music", + "media_content_id": "1234", + "enqueue": "play", + "announce": True, + }, + blocking=True, + ) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index 78fa49a8fc9..7b348489059 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components import media_source, tts from homeassistant.components.demo.tts import DemoProvider from homeassistant.components.media_player.const import ( + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP, @@ -91,6 +92,7 @@ async def test_setup_component_and_test_service(hass, empty_cache_dir): ) assert len(calls) == 1 + assert calls[0].data[ATTR_MEDIA_ANNOUNCE] is True assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC assert ( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) From 17abbd7f51ac89641e4dee14347ab519f6d68375 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 30 May 2022 22:56:59 -0500 Subject: [PATCH 113/947] Bump plexapi to 4.11.2 (#72729) --- homeassistant/components/plex/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 2e2db01de77..912732efe98 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/plex", "requirements": [ - "plexapi==4.11.1", + "plexapi==4.11.2", "plexauth==0.0.6", "plexwebsocket==0.0.13" ], diff --git a/requirements_all.txt b/requirements_all.txt index e0ccf63b91a..dc3efa7f441 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1236,7 +1236,7 @@ pillow==9.1.1 pizzapi==0.0.3 # homeassistant.components.plex -plexapi==4.11.1 +plexapi==4.11.2 # homeassistant.components.plex plexauth==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c04219f93da..4816a800f87 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -838,7 +838,7 @@ pilight==0.1.1 pillow==9.1.1 # homeassistant.components.plex -plexapi==4.11.1 +plexapi==4.11.2 # homeassistant.components.plex plexauth==0.0.6 From 969b7bd448a4645cedc411ca28cd9c58c38beb8a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 30 May 2022 23:04:53 -0600 Subject: [PATCH 114/947] Bump simplisafe-python to 2022.05.2 (#72740) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index cb1b02e37ae..f62da735f92 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==2022.05.1"], + "requirements": ["simplisafe-python==2022.05.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index dc3efa7f441..90ea8e4f432 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2168,7 +2168,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.05.1 +simplisafe-python==2022.05.2 # homeassistant.components.sisyphus sisyphus-control==3.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4816a800f87..df2d2fa73b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1425,7 +1425,7 @@ sharkiq==0.0.1 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==2022.05.1 +simplisafe-python==2022.05.2 # homeassistant.components.slack slackclient==2.5.0 From 5fdc6943259d19586cab54251bfd7fa6afcac1d1 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 31 May 2022 01:05:09 -0400 Subject: [PATCH 115/947] Bump zwave-js-server-python to 0.37.1 (#72731) Co-authored-by: Paulus Schoutsen --- homeassistant/components/zwave_js/api.py | 6 +++--- homeassistant/components/zwave_js/climate.py | 2 +- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index e922571f4b1..f9f1ad40f9a 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1232,7 +1232,7 @@ async def websocket_replace_failed_node( try: result = await controller.async_replace_failed_node( - node_id, + controller.nodes[node_id], INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value], force_security=force_security, provisioning=provisioning, @@ -1291,7 +1291,7 @@ async def websocket_remove_failed_node( connection.subscriptions[msg["id"]] = async_cleanup msg[DATA_UNSUBSCRIBE] = unsubs = [controller.on("node removed", node_removed)] - await controller.async_remove_failed_node(node.node_id) + await controller.async_remove_failed_node(node) connection.send_result(msg[ID]) @@ -1414,7 +1414,7 @@ async def websocket_heal_node( assert driver is not None # The node comes from the driver instance. controller = driver.controller - result = await controller.async_heal_node(node.node_id) + result = await controller.async_heal_node(node) connection.send_result( msg[ID], result, diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 7b07eb09619..d8037643488 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -66,7 +66,7 @@ ZW_HVAC_MODE_MAP: dict[int, HVACMode] = { ThermostatMode.AUTO: HVACMode.HEAT_COOL, ThermostatMode.AUXILIARY: HVACMode.HEAT, ThermostatMode.FAN: HVACMode.FAN_ONLY, - ThermostatMode.FURNANCE: HVACMode.HEAT, + ThermostatMode.FURNACE: HVACMode.HEAT, ThermostatMode.DRY: HVACMode.DRY, ThermostatMode.AUTO_CHANGE_OVER: HVACMode.HEAT_COOL, ThermostatMode.HEATING_ECON: HVACMode.HEAT, diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 555da5fe954..1c7eabb4e86 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.37.0"], + "requirements": ["zwave-js-server-python==0.37.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 90ea8e4f432..871980bffae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2528,7 +2528,7 @@ zigpy==0.45.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.0 +zwave-js-server-python==0.37.1 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index df2d2fa73b9..ba8a7cefd9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1665,7 +1665,7 @@ zigpy-znp==0.7.0 zigpy==0.45.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.0 +zwave-js-server-python==0.37.1 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 From b3682a5c81de837bba8f3c9f20877054831c2561 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 19:51:35 -1000 Subject: [PATCH 116/947] Revert bond reload on setup_retry discovery (#72744) --- homeassistant/components/bond/config_flow.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/bond/config_flow.py b/homeassistant/components/bond/config_flow.py index 9670782d2a6..09386c3587d 100644 --- a/homeassistant/components/bond/config_flow.py +++ b/homeassistant/components/bond/config_flow.py @@ -114,7 +114,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): changed = new_data != dict(entry.data) if changed: hass.config_entries.async_update_entry(entry, data=new_data) - if changed or entry.state is ConfigEntryState.SETUP_RETRY: entry_id = entry.entry_id hass.async_create_task(hass.config_entries.async_reload(entry_id)) raise AbortFlow("already_configured") From 4ae3929a006d0bb309f6e5d4cf85f94605687b08 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 19:52:05 -1000 Subject: [PATCH 117/947] Revert wiz reload on setup_retry discovery (#72743) --- homeassistant/components/wiz/config_flow.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index 6b5f5be027f..b2173ccda97 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -10,7 +10,7 @@ from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol from homeassistant.components import dhcp -from homeassistant.config_entries import ConfigEntryState, ConfigFlow +from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.util.network import is_ip_address @@ -58,15 +58,7 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): _LOGGER.debug("Discovered device: %s", device) ip_address = device.ip_address mac = device.mac_address - if current_entry := await self.async_set_unique_id(mac): - if ( - current_entry.state is ConfigEntryState.SETUP_RETRY - and current_entry.data[CONF_HOST] == ip_address - ): - self.hass.async_create_task( - self.hass.config_entries.async_reload(current_entry.entry_id) - ) - return self.async_abort(reason="already_configured") + await self.async_set_unique_id(mac) self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address}) await self._async_connect_discovered_or_abort() return await self.async_step_discovery_confirm() From 635d7085cf42dfaf8e60d1e262f096827d56e6e1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 May 2022 09:32:44 +0200 Subject: [PATCH 118/947] Move MQTT config schemas and client to separate modules (#71995) * Move MQTT config schemas and client to separate modules * Update integrations depending on MQTT --- .../manual_mqtt/alarm_control_panel.py | 2 +- homeassistant/components/mqtt/__init__.py | 958 +----------------- .../components/mqtt/alarm_control_panel.py | 14 +- .../components/mqtt/binary_sensor.py | 7 +- homeassistant/components/mqtt/button.py | 11 +- homeassistant/components/mqtt/camera.py | 7 +- homeassistant/components/mqtt/client.py | 659 ++++++++++++ homeassistant/components/mqtt/climate.py | 58 +- homeassistant/components/mqtt/config.py | 148 +++ homeassistant/components/mqtt/config_flow.py | 2 +- homeassistant/components/mqtt/const.py | 26 +- homeassistant/components/mqtt/cover.py | 20 +- .../components/mqtt/device_automation.py | 4 +- .../mqtt/device_tracker/schema_discovery.py | 7 +- .../mqtt/device_tracker/schema_yaml.py | 10 +- .../components/mqtt/device_trigger.py | 6 +- homeassistant/components/mqtt/fan.py | 24 +- homeassistant/components/mqtt/humidifier.py | 18 +- .../components/mqtt/light/schema_basic.py | 48 +- .../components/mqtt/light/schema_json.py | 11 +- .../components/mqtt/light/schema_template.py | 7 +- homeassistant/components/mqtt/lock.py | 7 +- homeassistant/components/mqtt/mixins.py | 14 +- homeassistant/components/mqtt/models.py | 126 ++- homeassistant/components/mqtt/number.py | 7 +- homeassistant/components/mqtt/scene.py | 10 +- homeassistant/components/mqtt/select.py | 7 +- homeassistant/components/mqtt/sensor.py | 10 +- homeassistant/components/mqtt/siren.py | 7 +- homeassistant/components/mqtt/switch.py | 7 +- homeassistant/components/mqtt/tag.py | 8 +- .../components/mqtt/vacuum/schema_legacy.py | 28 +- .../components/mqtt/vacuum/schema_state.py | 15 +- .../components/mqtt_json/device_tracker.py | 2 +- homeassistant/components/mqtt_room/sensor.py | 2 +- tests/components/mqtt/test_cover.py | 2 +- tests/components/mqtt/test_init.py | 18 +- tests/components/mqtt/test_legacy_vacuum.py | 2 +- tests/components/mqtt/test_state_vacuum.py | 2 +- 39 files changed, 1213 insertions(+), 1108 deletions(-) create mode 100644 homeassistant/components/mqtt/client.py create mode 100644 homeassistant/components/mqtt/config.py diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 5b74af49a91..67675a44e22 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -110,7 +110,7 @@ def _state_schema(state): PLATFORM_SCHEMA = vol.Schema( vol.All( - mqtt.MQTT_BASE_SCHEMA.extend( + mqtt.config.MQTT_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "manual_mqtt", vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string, diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 46eb7052f4f..e21885d2585 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1,23 +1,13 @@ """Support for MQTT message handling.""" from __future__ import annotations -from ast import literal_eval import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Callable from dataclasses import dataclass import datetime as dt -from functools import lru_cache, partial, wraps -import inspect -from itertools import groupby import logging -from operator import attrgetter -import ssl -import time -from typing import TYPE_CHECKING, Any, Union, cast -import uuid +from typing import Any, cast -import attr -import certifi import jinja2 import voluptuous as vol @@ -25,120 +15,73 @@ from homeassistant import config_entries from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_NAME, - CONF_CLIENT_ID, CONF_DISCOVERY, CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT, - CONF_PROTOCOL, CONF_USERNAME, - CONF_VALUE_TEMPLATE, - EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, - Platform, -) -from homeassistant.core import ( - CoreState, - Event, - HassJob, - HomeAssistant, - ServiceCall, - callback, ) +from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.exceptions import HomeAssistantError, TemplateError, Unauthorized +from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util -from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.logging import catch_log_exception +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from . import debug_info, discovery -from .const import ( +from .client import ( # noqa: F401 + MQTT, + async_publish, + async_subscribe, + publish, + subscribe, +) +from .config import CONFIG_SCHEMA_BASE, DEFAULT_VALUES, DEPRECATED_CONFIG_KEYS +from .const import ( # noqa: F401 ATTR_PAYLOAD, ATTR_QOS, ATTR_RETAIN, ATTR_TOPIC, CONF_BIRTH_MESSAGE, CONF_BROKER, - CONF_CERTIFICATE, - CONF_CLIENT_CERT, - CONF_CLIENT_KEY, CONF_COMMAND_TOPIC, - CONF_ENCODING, + CONF_DISCOVERY_PREFIX, CONF_QOS, - CONF_RETAIN, CONF_STATE_TOPIC, - CONF_TLS_INSECURE, CONF_TLS_VERSION, CONF_TOPIC, CONF_WILL_MESSAGE, CONFIG_ENTRY_IS_SETUP, DATA_CONFIG_ENTRY_LOCK, + DATA_MQTT, DATA_MQTT_CONFIG, DATA_MQTT_RELOAD_NEEDED, - DEFAULT_BIRTH, - DEFAULT_DISCOVERY, DEFAULT_ENCODING, - DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, - DEFAULT_WILL, DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, - PROTOCOL_31, - PROTOCOL_311, + PLATFORMS, ) -from .discovery import LAST_DISCOVERY -from .models import ( - AsyncMessageCallbackType, - MessageCallbackType, - PublishMessage, +from .models import ( # noqa: F401 + MqttCommandTemplate, + MqttValueTemplate, PublishPayloadType, ReceiveMessage, ReceivePayloadType, ) from .util import _VALID_QOS_SCHEMA, valid_publish_topic, valid_subscribe_topic -if TYPE_CHECKING: - # Only import for paho-mqtt type checking here, imports are done locally - # because integrations should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt - _LOGGER = logging.getLogger(__name__) -_SENTINEL = object() - -DATA_MQTT = "mqtt" - SERVICE_PUBLISH = "publish" SERVICE_DUMP = "dump" -CONF_DISCOVERY_PREFIX = "discovery_prefix" -CONF_KEEPALIVE = "keepalive" - -DEFAULT_PORT = 1883 -DEFAULT_KEEPALIVE = 60 -DEFAULT_PROTOCOL = PROTOCOL_311 -DEFAULT_TLS_PROTOCOL = "auto" - -DEFAULT_VALUES = { - CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, - CONF_DISCOVERY: DEFAULT_DISCOVERY, - CONF_PORT: DEFAULT_PORT, - CONF_TLS_VERSION: DEFAULT_TLS_PROTOCOL, - CONF_WILL_MESSAGE: DEFAULT_WILL, -} - MANDATORY_DEFAULT_VALUES = (CONF_PORT,) ATTR_TOPIC_TEMPLATE = "topic_template" @@ -150,93 +93,6 @@ CONNECTION_SUCCESS = "connection_success" CONNECTION_FAILED = "connection_failed" CONNECTION_FAILED_RECOVERABLE = "connection_failed_recoverable" -DISCOVERY_COOLDOWN = 2 -TIMEOUT_ACK = 10 - -PLATFORMS = [ - Platform.ALARM_CONTROL_PANEL, - Platform.BINARY_SENSOR, - Platform.BUTTON, - Platform.CAMERA, - Platform.CLIMATE, - Platform.DEVICE_TRACKER, - Platform.COVER, - Platform.FAN, - Platform.HUMIDIFIER, - Platform.LIGHT, - Platform.LOCK, - Platform.NUMBER, - Platform.SELECT, - Platform.SCENE, - Platform.SENSOR, - Platform.SIREN, - Platform.SWITCH, - Platform.VACUUM, -] - -CLIENT_KEY_AUTH_MSG = ( - "client_key and client_cert must both be present in " - "the MQTT broker configuration" -) - -MQTT_WILL_BIRTH_SCHEMA = vol.Schema( - { - vol.Inclusive(ATTR_TOPIC, "topic_payload"): valid_publish_topic, - vol.Inclusive(ATTR_PAYLOAD, "topic_payload"): cv.string, - vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, - vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - }, - required=True, -) - -PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( - {vol.Optional(platform.value): cv.ensure_list for platform in PLATFORMS} -) - -CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( - { - vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All( - vol.Coerce(int), vol.Range(min=15) - ), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile), - vol.Inclusive( - CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Inclusive( - CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), - vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All( - cv.string, vol.In([PROTOCOL_31, PROTOCOL_311]) - ), - vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, - vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, - vol.Optional(CONF_DISCOVERY): cv.boolean, - # discovery_prefix must be a valid publish topic because if no - # state topic is specified, it will be created with the given prefix. - vol.Optional( - CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX - ): valid_publish_topic, - } -) - -DEPRECATED_CONFIG_KEYS = [ - CONF_BIRTH_MESSAGE, - CONF_BROKER, - CONF_DISCOVERY, - CONF_PASSWORD, - CONF_PORT, - CONF_TLS_VERSION, - CONF_USERNAME, - CONF_WILL_MESSAGE, -] - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.All( @@ -254,29 +110,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SCHEMA_BASE = { - vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, - vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, -} - -MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE) - -# Sensor type platforms subscribe to MQTT events -MQTT_RO_SCHEMA = MQTT_BASE_SCHEMA.extend( - { - vol.Required(CONF_STATE_TOPIC): valid_subscribe_topic, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, - } -) - -# Switch type platforms publish to MQTT and may subscribe -MQTT_RW_SCHEMA = MQTT_BASE_SCHEMA.extend( - { - vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, - vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, - } -) # Service call validation schema MQTT_PUBLISH_SCHEMA = vol.All( @@ -295,124 +128,6 @@ MQTT_PUBLISH_SCHEMA = vol.All( ) -SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None - - -class MqttCommandTemplate: - """Class for rendering MQTT payload with command templates.""" - - def __init__( - self, - command_template: template.Template | None, - *, - hass: HomeAssistant | None = None, - entity: Entity | None = None, - ) -> None: - """Instantiate a command template.""" - self._attr_command_template = command_template - if command_template is None: - return - - self._entity = entity - - command_template.hass = hass - - if entity: - command_template.hass = entity.hass - - @callback - def async_render( - self, - value: PublishPayloadType = None, - variables: TemplateVarsType = None, - ) -> PublishPayloadType: - """Render or convert the command template with given value or variables.""" - - def _convert_outgoing_payload( - payload: PublishPayloadType, - ) -> PublishPayloadType: - """Ensure correct raw MQTT payload is passed as bytes for publishing.""" - if isinstance(payload, str): - try: - native_object = literal_eval(payload) - if isinstance(native_object, bytes): - return native_object - - except (ValueError, TypeError, SyntaxError, MemoryError): - pass - - return payload - - if self._attr_command_template is None: - return value - - values = {"value": value} - if self._entity: - values[ATTR_ENTITY_ID] = self._entity.entity_id - values[ATTR_NAME] = self._entity.name - if variables is not None: - values.update(variables) - return _convert_outgoing_payload( - self._attr_command_template.async_render(values, parse_result=False) - ) - - -class MqttValueTemplate: - """Class for rendering MQTT value template with possible json values.""" - - def __init__( - self, - value_template: template.Template | None, - *, - hass: HomeAssistant | None = None, - entity: Entity | None = None, - config_attributes: TemplateVarsType = None, - ) -> None: - """Instantiate a value template.""" - self._value_template = value_template - self._config_attributes = config_attributes - if value_template is None: - return - - value_template.hass = hass - self._entity = entity - - if entity: - value_template.hass = entity.hass - - @callback - def async_render_with_possible_json_value( - self, - payload: ReceivePayloadType, - default: ReceivePayloadType | object = _SENTINEL, - variables: TemplateVarsType = None, - ) -> ReceivePayloadType: - """Render with possible json value or pass-though a received MQTT value.""" - if self._value_template is None: - return payload - - values: dict[str, Any] = {} - - if variables is not None: - values.update(variables) - - if self._config_attributes is not None: - values.update(self._config_attributes) - - if self._entity: - values[ATTR_ENTITY_ID] = self._entity.entity_id - values[ATTR_NAME] = self._entity.name - - if default == _SENTINEL: - return self._value_template.async_render_with_possible_json_value( - payload, variables=values - ) - - return self._value_template.async_render_with_possible_json_value( - payload, default, variables=values - ) - - @dataclass class MqttServiceInfo(BaseServiceInfo): """Prepared info from mqtt entries.""" @@ -425,163 +140,6 @@ class MqttServiceInfo(BaseServiceInfo): timestamp: dt.datetime -def publish( - hass: HomeAssistant, - topic: str, - payload: PublishPayloadType, - qos: int | None = 0, - retain: bool | None = False, - encoding: str | None = DEFAULT_ENCODING, -) -> None: - """Publish message to a MQTT topic.""" - hass.add_job(async_publish, hass, topic, payload, qos, retain, encoding) - - -async def async_publish( - hass: HomeAssistant, - topic: str, - payload: PublishPayloadType, - qos: int | None = 0, - retain: bool | None = False, - encoding: str | None = DEFAULT_ENCODING, -) -> None: - """Publish message to a MQTT topic.""" - - outgoing_payload = payload - if not isinstance(payload, bytes): - if not encoding: - _LOGGER.error( - "Can't pass-through payload for publishing %s on %s with no encoding set, need 'bytes' got %s", - payload, - topic, - type(payload), - ) - return - outgoing_payload = str(payload) - if encoding != DEFAULT_ENCODING: - # a string is encoded as utf-8 by default, other encoding requires bytes as payload - try: - outgoing_payload = outgoing_payload.encode(encoding) - except (AttributeError, LookupError, UnicodeEncodeError): - _LOGGER.error( - "Can't encode payload for publishing %s on %s with encoding %s", - payload, - topic, - encoding, - ) - return - - await hass.data[DATA_MQTT].async_publish(topic, outgoing_payload, qos, retain) - - -AsyncDeprecatedMessageCallbackType = Callable[ - [str, ReceivePayloadType, int], Awaitable[None] -] -DeprecatedMessageCallbackType = Callable[[str, ReceivePayloadType, int], None] - - -def wrap_msg_callback( - msg_callback: AsyncDeprecatedMessageCallbackType | DeprecatedMessageCallbackType, -) -> AsyncMessageCallbackType | MessageCallbackType: - """Wrap an MQTT message callback to support deprecated signature.""" - # Check for partials to properly determine if coroutine function - check_func = msg_callback - while isinstance(check_func, partial): - check_func = check_func.func - - wrapper_func: AsyncMessageCallbackType | MessageCallbackType - if asyncio.iscoroutinefunction(check_func): - - @wraps(msg_callback) - async def async_wrapper(msg: ReceiveMessage) -> None: - """Call with deprecated signature.""" - await cast(AsyncDeprecatedMessageCallbackType, msg_callback)( - msg.topic, msg.payload, msg.qos - ) - - wrapper_func = async_wrapper - else: - - @wraps(msg_callback) - def wrapper(msg: ReceiveMessage) -> None: - """Call with deprecated signature.""" - msg_callback(msg.topic, msg.payload, msg.qos) - - wrapper_func = wrapper - return wrapper_func - - -@bind_hass -async def async_subscribe( - hass: HomeAssistant, - topic: str, - msg_callback: AsyncMessageCallbackType - | MessageCallbackType - | DeprecatedMessageCallbackType - | AsyncDeprecatedMessageCallbackType, - qos: int = DEFAULT_QOS, - encoding: str | None = "utf-8", -): - """Subscribe to an MQTT topic. - - Call the return value to unsubscribe. - """ - # Count callback parameters which don't have a default value - non_default = 0 - if msg_callback: - non_default = sum( - p.default == inspect.Parameter.empty - for _, p in inspect.signature(msg_callback).parameters.items() - ) - - wrapped_msg_callback = msg_callback - # If we have 3 parameters with no default value, wrap the callback - if non_default == 3: - module = inspect.getmodule(msg_callback) - _LOGGER.warning( - "Signature of MQTT msg_callback '%s.%s' is deprecated", - module.__name__ if module else "", - msg_callback.__name__, - ) - wrapped_msg_callback = wrap_msg_callback( - cast(DeprecatedMessageCallbackType, msg_callback) - ) - - async_remove = await hass.data[DATA_MQTT].async_subscribe( - topic, - catch_log_exception( - wrapped_msg_callback, - lambda msg: ( - f"Exception in {msg_callback.__name__} when handling msg on " - f"'{msg.topic}': '{msg.payload}'" - ), - ), - qos, - encoding, - ) - return async_remove - - -@bind_hass -def subscribe( - hass: HomeAssistant, - topic: str, - msg_callback: MessageCallbackType, - qos: int = DEFAULT_QOS, - encoding: str = "utf-8", -) -> Callable[[], None]: - """Subscribe to an MQTT topic.""" - async_remove = asyncio.run_coroutine_threadsafe( - async_subscribe(hass, topic, msg_callback, qos, encoding), hass.loop - ).result() - - def remove(): - """Remove listener convert.""" - run_callback_threadsafe(hass.loop, async_remove).result() - - return remove - - async def _async_setup_discovery( hass: HomeAssistant, conf: ConfigType, config_entry ) -> None: @@ -649,6 +207,26 @@ def _merge_extended_config(entry, conf): return {**conf, **entry.data} +async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle signals of config entry being updated. + + Causes for this is config entry options changing. + """ + mqtt_client = hass.data[DATA_MQTT] + + if (conf := hass.data.get(DATA_MQTT_CONFIG)) is None: + conf = CONFIG_SCHEMA_BASE(dict(entry.data)) + + mqtt_client.conf = _merge_extended_config(entry, conf) + await mqtt_client.async_disconnect() + mqtt_client.init_client() + await mqtt_client.async_connect() + + await discovery.async_stop(hass) + if mqtt_client.conf.get(CONF_DISCOVERY): + await _async_setup_discovery(hass, mqtt_client.conf, entry) + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" # Merge basic configuration, and add missing defaults for basic options @@ -685,6 +263,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, conf, ) + entry.add_update_listener(_async_config_entry_updated) await hass.data[DATA_MQTT].async_connect() @@ -813,459 +392,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -@attr.s(slots=True, frozen=True) -class Subscription: - """Class to hold data about an active subscription.""" - - topic: str = attr.ib() - matcher: Any = attr.ib() - job: HassJob = attr.ib() - qos: int = attr.ib(default=0) - encoding: str | None = attr.ib(default="utf-8") - - -class MqttClientSetup: - """Helper class to setup the paho mqtt client from config.""" - - def __init__(self, config: ConfigType) -> None: - """Initialize the MQTT client setup helper.""" - - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel - - if config[CONF_PROTOCOL] == PROTOCOL_31: - proto = mqtt.MQTTv31 - else: - proto = mqtt.MQTTv311 - - if (client_id := config.get(CONF_CLIENT_ID)) is None: - # PAHO MQTT relies on the MQTT server to generate random client IDs. - # However, that feature is not mandatory so we generate our own. - client_id = mqtt.base62(uuid.uuid4().int, padding=22) - self._client = mqtt.Client(client_id, protocol=proto) - - # Enable logging - self._client.enable_logger() - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - if username is not None: - self._client.username_pw_set(username, password) - - if (certificate := config.get(CONF_CERTIFICATE)) == "auto": - certificate = certifi.where() - - client_key = config.get(CONF_CLIENT_KEY) - client_cert = config.get(CONF_CLIENT_CERT) - tls_insecure = config.get(CONF_TLS_INSECURE) - if certificate is not None: - self._client.tls_set( - certificate, - certfile=client_cert, - keyfile=client_key, - tls_version=ssl.PROTOCOL_TLS, - ) - - if tls_insecure is not None: - self._client.tls_insecure_set(tls_insecure) - - @property - def client(self) -> mqtt.Client: - """Return the paho MQTT client.""" - return self._client - - -class MQTT: - """Home Assistant MQTT client.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry, - conf, - ) -> None: - """Initialize Home Assistant MQTT client.""" - # We don't import on the top because some integrations - # should be able to optionally rely on MQTT. - import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel - - self.hass = hass - self.config_entry = config_entry - self.conf = conf - self.subscriptions: list[Subscription] = [] - self.connected = False - self._ha_started = asyncio.Event() - self._last_subscribe = time.time() - self._mqttc: mqtt.Client = None - self._paho_lock = asyncio.Lock() - - self._pending_operations: dict[str, asyncio.Event] = {} - - if self.hass.state == CoreState.running: - self._ha_started.set() - else: - - @callback - def ha_started(_): - self._ha_started.set() - - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) - - self.init_client() - self.config_entry.add_update_listener(self.async_config_entry_updated) - - @staticmethod - async def async_config_entry_updated( - hass: HomeAssistant, entry: ConfigEntry - ) -> None: - """Handle signals of config entry being updated. - - This is a static method because a class method (bound method), can not be used with weak references. - Causes for this is config entry options changing. - """ - self = hass.data[DATA_MQTT] - - if (conf := hass.data.get(DATA_MQTT_CONFIG)) is None: - conf = CONFIG_SCHEMA_BASE(dict(entry.data)) - - self.conf = _merge_extended_config(entry, conf) - await self.async_disconnect() - self.init_client() - await self.async_connect() - - await discovery.async_stop(hass) - if self.conf.get(CONF_DISCOVERY): - await _async_setup_discovery(hass, self.conf, entry) - - def init_client(self): - """Initialize paho client.""" - self._mqttc = MqttClientSetup(self.conf).client - self._mqttc.on_connect = self._mqtt_on_connect - self._mqttc.on_disconnect = self._mqtt_on_disconnect - self._mqttc.on_message = self._mqtt_on_message - self._mqttc.on_publish = self._mqtt_on_callback - self._mqttc.on_subscribe = self._mqtt_on_callback - self._mqttc.on_unsubscribe = self._mqtt_on_callback - - if ( - CONF_WILL_MESSAGE in self.conf - and ATTR_TOPIC in self.conf[CONF_WILL_MESSAGE] - ): - will_message = PublishMessage(**self.conf[CONF_WILL_MESSAGE]) - else: - will_message = None - - if will_message is not None: - self._mqttc.will_set( - topic=will_message.topic, - payload=will_message.payload, - qos=will_message.qos, - retain=will_message.retain, - ) - - async def async_publish( - self, topic: str, payload: PublishPayloadType, qos: int, retain: bool - ) -> None: - """Publish a MQTT message.""" - async with self._paho_lock: - msg_info = await self.hass.async_add_executor_job( - self._mqttc.publish, topic, payload, qos, retain - ) - _LOGGER.debug( - "Transmitting message on %s: '%s', mid: %s", - topic, - payload, - msg_info.mid, - ) - _raise_on_error(msg_info.rc) - await self._wait_for_mid(msg_info.mid) - - async def async_connect(self) -> None: - """Connect to the host. Does not process messages yet.""" - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - - result: int | None = None - try: - result = await self.hass.async_add_executor_job( - self._mqttc.connect, - self.conf[CONF_BROKER], - self.conf[CONF_PORT], - self.conf[CONF_KEEPALIVE], - ) - except OSError as err: - _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) - - if result is not None and result != 0: - _LOGGER.error( - "Failed to connect to MQTT server: %s", mqtt.error_string(result) - ) - - self._mqttc.loop_start() - - async def async_disconnect(self): - """Stop the MQTT client.""" - - def stop(): - """Stop the MQTT client.""" - # Do not disconnect, we want the broker to always publish will - self._mqttc.loop_stop() - - await self.hass.async_add_executor_job(stop) - - async def async_subscribe( - self, - topic: str, - msg_callback: MessageCallbackType, - qos: int, - encoding: str | None = None, - ) -> Callable[[], None]: - """Set up a subscription to a topic with the provided qos. - - This method is a coroutine. - """ - if not isinstance(topic, str): - raise HomeAssistantError("Topic needs to be a string!") - - subscription = Subscription( - topic, _matcher_for_topic(topic), HassJob(msg_callback), qos, encoding - ) - self.subscriptions.append(subscription) - self._matching_subscriptions.cache_clear() - - # Only subscribe if currently connected. - if self.connected: - self._last_subscribe = time.time() - await self._async_perform_subscription(topic, qos) - - @callback - def async_remove() -> None: - """Remove subscription.""" - if subscription not in self.subscriptions: - raise HomeAssistantError("Can't remove subscription twice") - self.subscriptions.remove(subscription) - self._matching_subscriptions.cache_clear() - - # Only unsubscribe if currently connected. - if self.connected: - self.hass.async_create_task(self._async_unsubscribe(topic)) - - return async_remove - - async def _async_unsubscribe(self, topic: str) -> None: - """Unsubscribe from a topic. - - This method is a coroutine. - """ - if any(other.topic == topic for other in self.subscriptions): - # Other subscriptions on topic remaining - don't unsubscribe. - return - - async with self._paho_lock: - result: int | None = None - result, mid = await self.hass.async_add_executor_job( - self._mqttc.unsubscribe, topic - ) - _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) - _raise_on_error(result) - await self._wait_for_mid(mid) - - async def _async_perform_subscription(self, topic: str, qos: int) -> None: - """Perform a paho-mqtt subscription.""" - async with self._paho_lock: - result: int | None = None - result, mid = await self.hass.async_add_executor_job( - self._mqttc.subscribe, topic, qos - ) - _LOGGER.debug("Subscribing to %s, mid: %s", topic, mid) - _raise_on_error(result) - await self._wait_for_mid(mid) - - def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None: - """On connect callback. - - Resubscribe to all topics we were subscribed to and publish birth - message. - """ - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - - if result_code != mqtt.CONNACK_ACCEPTED: - _LOGGER.error( - "Unable to connect to the MQTT broker: %s", - mqtt.connack_string(result_code), - ) - return - - self.connected = True - dispatcher_send(self.hass, MQTT_CONNECTED) - _LOGGER.info( - "Connected to MQTT server %s:%s (%s)", - self.conf[CONF_BROKER], - self.conf[CONF_PORT], - result_code, - ) - - # Group subscriptions to only re-subscribe once for each topic. - keyfunc = attrgetter("topic") - for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), keyfunc): - # Re-subscribe with the highest requested qos - max_qos = max(subscription.qos for subscription in subs) - self.hass.add_job(self._async_perform_subscription, topic, max_qos) - - if ( - CONF_BIRTH_MESSAGE in self.conf - and ATTR_TOPIC in self.conf[CONF_BIRTH_MESSAGE] - ): - - async def publish_birth_message(birth_message): - await self._ha_started.wait() # Wait for Home Assistant to start - await self._discovery_cooldown() # Wait for MQTT discovery to cool down - await self.async_publish( - topic=birth_message.topic, - payload=birth_message.payload, - qos=birth_message.qos, - retain=birth_message.retain, - ) - - birth_message = PublishMessage(**self.conf[CONF_BIRTH_MESSAGE]) - asyncio.run_coroutine_threadsafe( - publish_birth_message(birth_message), self.hass.loop - ) - - def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None: - """Message received callback.""" - self.hass.add_job(self._mqtt_handle_message, msg) - - @lru_cache(2048) - def _matching_subscriptions(self, topic): - subscriptions = [] - for subscription in self.subscriptions: - if subscription.matcher(topic): - subscriptions.append(subscription) - return subscriptions - - @callback - def _mqtt_handle_message(self, msg) -> None: - _LOGGER.debug( - "Received message on %s%s: %s", - msg.topic, - " (retained)" if msg.retain else "", - msg.payload[0:8192], - ) - timestamp = dt_util.utcnow() - - subscriptions = self._matching_subscriptions(msg.topic) - - for subscription in subscriptions: - - payload: SubscribePayloadType = msg.payload - if subscription.encoding is not None: - try: - payload = msg.payload.decode(subscription.encoding) - except (AttributeError, UnicodeDecodeError): - _LOGGER.warning( - "Can't decode payload %s on %s with encoding %s (for %s)", - msg.payload[0:8192], - msg.topic, - subscription.encoding, - subscription.job, - ) - continue - - self.hass.async_run_hass_job( - subscription.job, - ReceiveMessage( - msg.topic, - payload, - msg.qos, - msg.retain, - subscription.topic, - timestamp, - ), - ) - - def _mqtt_on_callback(self, _mqttc, _userdata, mid, _granted_qos=None) -> None: - """Publish / Subscribe / Unsubscribe callback.""" - self.hass.add_job(self._mqtt_handle_mid, mid) - - @callback - def _mqtt_handle_mid(self, mid) -> None: - # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid - # may be executed first. - if mid not in self._pending_operations: - self._pending_operations[mid] = asyncio.Event() - self._pending_operations[mid].set() - - def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: - """Disconnected callback.""" - self.connected = False - dispatcher_send(self.hass, MQTT_DISCONNECTED) - _LOGGER.warning( - "Disconnected from MQTT server %s:%s (%s)", - self.conf[CONF_BROKER], - self.conf[CONF_PORT], - result_code, - ) - - async def _wait_for_mid(self, mid): - """Wait for ACK from broker.""" - # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid - # may be executed first. - if mid not in self._pending_operations: - self._pending_operations[mid] = asyncio.Event() - try: - await asyncio.wait_for(self._pending_operations[mid].wait(), TIMEOUT_ACK) - except asyncio.TimeoutError: - _LOGGER.warning( - "No ACK from MQTT server in %s seconds (mid: %s)", TIMEOUT_ACK, mid - ) - finally: - del self._pending_operations[mid] - - async def _discovery_cooldown(self): - now = time.time() - # Reset discovery and subscribe cooldowns - self.hass.data[LAST_DISCOVERY] = now - self._last_subscribe = now - - last_discovery = self.hass.data[LAST_DISCOVERY] - last_subscribe = self._last_subscribe - wait_until = max( - last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN - ) - while now < wait_until: - await asyncio.sleep(wait_until - now) - now = time.time() - last_discovery = self.hass.data[LAST_DISCOVERY] - last_subscribe = self._last_subscribe - wait_until = max( - last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN - ) - - -def _raise_on_error(result_code: int | None) -> None: - """Raise error if error result.""" - # pylint: disable-next=import-outside-toplevel - import paho.mqtt.client as mqtt - - if result_code is not None and result_code != 0: - raise HomeAssistantError( - f"Error talking to MQTT: {mqtt.error_string(result_code)}" - ) - - -def _matcher_for_topic(subscription: str) -> Any: - # pylint: disable-next=import-outside-toplevel - from paho.mqtt.matcher import MQTTMatcher - - matcher = MQTTMatcher() - matcher[subscription] = True - - return lambda topic: next(matcher.iter_match(topic), False) - - @websocket_api.websocket_command( {vol.Required("type"): "mqtt/device/debug_info", vol.Required("device_id"): str} ) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 06c013ec744..c20fbb7c657 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -31,8 +31,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, @@ -50,6 +50,8 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -85,7 +87,7 @@ DEFAULT_NAME = "MQTT Alarm" REMOTE_CODE = "REMOTE_CODE" REMOTE_CODE_TEXT = "REMOTE_CODE_TEXT" -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_BASE_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_CODE): cv.string, vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, @@ -94,7 +96,7 @@ PLATFORM_SCHEMA_MODERN = mqtt.MQTT_BASE_SCHEMA.extend( vol.Optional( CONF_COMMAND_TEMPLATE, default=DEFAULT_COMMAND_TEMPLATE ): cv.template, - vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string, vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string, @@ -107,8 +109,8 @@ PLATFORM_SCHEMA_MODERN = mqtt.MQTT_BASE_SCHEMA.extend( ): cv.string, vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, vol.Optional(CONF_PAYLOAD_TRIGGER, default=DEFAULT_TRIGGER): cv.string, - vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, - vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Required(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b9ab190cc9b..1cb90d6c903 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -34,8 +34,8 @@ from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from . import MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RO_SCHEMA from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, PAYLOAD_NONE from .debug_info import log_messages from .mixins import ( @@ -47,6 +47,7 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttValueTemplate _LOGGER = logging.getLogger(__name__) @@ -57,7 +58,7 @@ DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False CONF_EXPIRE_AFTER = "expire_after" -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RO_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 47e96ff3e1a..b50856d20c1 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -15,8 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate -from .. import mqtt +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, @@ -32,19 +31,21 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate +from .util import valid_publish_topic CONF_PAYLOAD_PRESS = "payload_press" DEFAULT_NAME = "MQTT Button" DEFAULT_PAYLOAD_PRESS = "PRESS" -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_BASE_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, - vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_DEVICE_CLASS): button.DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_PRESS, default=DEFAULT_PAYLOAD_PRESS): cv.string, - vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 2e5d95ebda4..ae38e07d17a 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription -from .. import mqtt +from .config import MQTT_BASE_SCHEMA from .const import CONF_ENCODING, CONF_QOS, CONF_TOPIC from .debug_info import log_messages from .mixins import ( @@ -28,6 +28,7 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .util import valid_subscribe_topic DEFAULT_NAME = "MQTT Camera" @@ -40,10 +41,10 @@ MQTT_CAMERA_ATTRIBUTES_BLOCKED = frozenset( } ) -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_BASE_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, + vol.Required(CONF_TOPIC): valid_subscribe_topic, } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py new file mode 100644 index 00000000000..66699372516 --- /dev/null +++ b/homeassistant/components/mqtt/client.py @@ -0,0 +1,659 @@ +"""Support for MQTT message handling.""" +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from functools import lru_cache, partial, wraps +import inspect +from itertools import groupby +import logging +from operator import attrgetter +import ssl +import time +from typing import TYPE_CHECKING, Any, Union, cast +import uuid + +import attr +import certifi + +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, + EVENT_HOMEASSISTANT_STARTED, +) +from homeassistant.core import CoreState, HassJob, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import bind_hass +from homeassistant.util import dt as dt_util +from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.logging import catch_log_exception + +from .const import ( + ATTR_TOPIC, + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_CERTIFICATE, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_KEEPALIVE, + CONF_TLS_INSECURE, + CONF_WILL_MESSAGE, + DATA_MQTT, + DEFAULT_ENCODING, + DEFAULT_QOS, + MQTT_CONNECTED, + MQTT_DISCONNECTED, + PROTOCOL_31, +) +from .discovery import LAST_DISCOVERY +from .models import ( + AsyncMessageCallbackType, + MessageCallbackType, + PublishMessage, + PublishPayloadType, + ReceiveMessage, + ReceivePayloadType, +) + +if TYPE_CHECKING: + # Only import for paho-mqtt type checking here, imports are done locally + # because integrations should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt + +_LOGGER = logging.getLogger(__name__) + +DISCOVERY_COOLDOWN = 2 +TIMEOUT_ACK = 10 + +SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None + + +def publish( + hass: HomeAssistant, + topic: str, + payload: PublishPayloadType, + qos: int | None = 0, + retain: bool | None = False, + encoding: str | None = DEFAULT_ENCODING, +) -> None: + """Publish message to a MQTT topic.""" + hass.add_job(async_publish, hass, topic, payload, qos, retain, encoding) + + +async def async_publish( + hass: HomeAssistant, + topic: str, + payload: PublishPayloadType, + qos: int | None = 0, + retain: bool | None = False, + encoding: str | None = DEFAULT_ENCODING, +) -> None: + """Publish message to a MQTT topic.""" + + outgoing_payload = payload + if not isinstance(payload, bytes): + if not encoding: + _LOGGER.error( + "Can't pass-through payload for publishing %s on %s with no encoding set, need 'bytes' got %s", + payload, + topic, + type(payload), + ) + return + outgoing_payload = str(payload) + if encoding != DEFAULT_ENCODING: + # a string is encoded as utf-8 by default, other encoding requires bytes as payload + try: + outgoing_payload = outgoing_payload.encode(encoding) + except (AttributeError, LookupError, UnicodeEncodeError): + _LOGGER.error( + "Can't encode payload for publishing %s on %s with encoding %s", + payload, + topic, + encoding, + ) + return + + await hass.data[DATA_MQTT].async_publish(topic, outgoing_payload, qos, retain) + + +AsyncDeprecatedMessageCallbackType = Callable[ + [str, ReceivePayloadType, int], Awaitable[None] +] +DeprecatedMessageCallbackType = Callable[[str, ReceivePayloadType, int], None] + + +def wrap_msg_callback( + msg_callback: AsyncDeprecatedMessageCallbackType | DeprecatedMessageCallbackType, +) -> AsyncMessageCallbackType | MessageCallbackType: + """Wrap an MQTT message callback to support deprecated signature.""" + # Check for partials to properly determine if coroutine function + check_func = msg_callback + while isinstance(check_func, partial): + check_func = check_func.func + + wrapper_func: AsyncMessageCallbackType | MessageCallbackType + if asyncio.iscoroutinefunction(check_func): + + @wraps(msg_callback) + async def async_wrapper(msg: ReceiveMessage) -> None: + """Call with deprecated signature.""" + await cast(AsyncDeprecatedMessageCallbackType, msg_callback)( + msg.topic, msg.payload, msg.qos + ) + + wrapper_func = async_wrapper + else: + + @wraps(msg_callback) + def wrapper(msg: ReceiveMessage) -> None: + """Call with deprecated signature.""" + msg_callback(msg.topic, msg.payload, msg.qos) + + wrapper_func = wrapper + return wrapper_func + + +@bind_hass +async def async_subscribe( + hass: HomeAssistant, + topic: str, + msg_callback: AsyncMessageCallbackType + | MessageCallbackType + | DeprecatedMessageCallbackType + | AsyncDeprecatedMessageCallbackType, + qos: int = DEFAULT_QOS, + encoding: str | None = "utf-8", +): + """Subscribe to an MQTT topic. + + Call the return value to unsubscribe. + """ + # Count callback parameters which don't have a default value + non_default = 0 + if msg_callback: + non_default = sum( + p.default == inspect.Parameter.empty + for _, p in inspect.signature(msg_callback).parameters.items() + ) + + wrapped_msg_callback = msg_callback + # If we have 3 parameters with no default value, wrap the callback + if non_default == 3: + module = inspect.getmodule(msg_callback) + _LOGGER.warning( + "Signature of MQTT msg_callback '%s.%s' is deprecated", + module.__name__ if module else "", + msg_callback.__name__, + ) + wrapped_msg_callback = wrap_msg_callback( + cast(DeprecatedMessageCallbackType, msg_callback) + ) + + async_remove = await hass.data[DATA_MQTT].async_subscribe( + topic, + catch_log_exception( + wrapped_msg_callback, + lambda msg: ( + f"Exception in {msg_callback.__name__} when handling msg on " + f"'{msg.topic}': '{msg.payload}'" + ), + ), + qos, + encoding, + ) + return async_remove + + +@bind_hass +def subscribe( + hass: HomeAssistant, + topic: str, + msg_callback: MessageCallbackType, + qos: int = DEFAULT_QOS, + encoding: str = "utf-8", +) -> Callable[[], None]: + """Subscribe to an MQTT topic.""" + async_remove = asyncio.run_coroutine_threadsafe( + async_subscribe(hass, topic, msg_callback, qos, encoding), hass.loop + ).result() + + def remove(): + """Remove listener convert.""" + run_callback_threadsafe(hass.loop, async_remove).result() + + return remove + + +@attr.s(slots=True, frozen=True) +class Subscription: + """Class to hold data about an active subscription.""" + + topic: str = attr.ib() + matcher: Any = attr.ib() + job: HassJob = attr.ib() + qos: int = attr.ib(default=0) + encoding: str | None = attr.ib(default="utf-8") + + +class MqttClientSetup: + """Helper class to setup the paho mqtt client from config.""" + + def __init__(self, config: ConfigType) -> None: + """Initialize the MQTT client setup helper.""" + + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + if config[CONF_PROTOCOL] == PROTOCOL_31: + proto = mqtt.MQTTv31 + else: + proto = mqtt.MQTTv311 + + if (client_id := config.get(CONF_CLIENT_ID)) is None: + # PAHO MQTT relies on the MQTT server to generate random client IDs. + # However, that feature is not mandatory so we generate our own. + client_id = mqtt.base62(uuid.uuid4().int, padding=22) + self._client = mqtt.Client(client_id, protocol=proto) + + # Enable logging + self._client.enable_logger() + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + if username is not None: + self._client.username_pw_set(username, password) + + if (certificate := config.get(CONF_CERTIFICATE)) == "auto": + certificate = certifi.where() + + client_key = config.get(CONF_CLIENT_KEY) + client_cert = config.get(CONF_CLIENT_CERT) + tls_insecure = config.get(CONF_TLS_INSECURE) + if certificate is not None: + self._client.tls_set( + certificate, + certfile=client_cert, + keyfile=client_key, + tls_version=ssl.PROTOCOL_TLS, + ) + + if tls_insecure is not None: + self._client.tls_insecure_set(tls_insecure) + + @property + def client(self) -> mqtt.Client: + """Return the paho MQTT client.""" + return self._client + + +class MQTT: + """Home Assistant MQTT client.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry, + conf, + ) -> None: + """Initialize Home Assistant MQTT client.""" + # We don't import on the top because some integrations + # should be able to optionally rely on MQTT. + import paho.mqtt.client as mqtt # pylint: disable=import-outside-toplevel + + self.hass = hass + self.config_entry = config_entry + self.conf = conf + self.subscriptions: list[Subscription] = [] + self.connected = False + self._ha_started = asyncio.Event() + self._last_subscribe = time.time() + self._mqttc: mqtt.Client = None + self._paho_lock = asyncio.Lock() + + self._pending_operations: dict[str, asyncio.Event] = {} + + if self.hass.state == CoreState.running: + self._ha_started.set() + else: + + @callback + def ha_started(_): + self._ha_started.set() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, ha_started) + + self.init_client() + + def init_client(self): + """Initialize paho client.""" + self._mqttc = MqttClientSetup(self.conf).client + self._mqttc.on_connect = self._mqtt_on_connect + self._mqttc.on_disconnect = self._mqtt_on_disconnect + self._mqttc.on_message = self._mqtt_on_message + self._mqttc.on_publish = self._mqtt_on_callback + self._mqttc.on_subscribe = self._mqtt_on_callback + self._mqttc.on_unsubscribe = self._mqtt_on_callback + + if ( + CONF_WILL_MESSAGE in self.conf + and ATTR_TOPIC in self.conf[CONF_WILL_MESSAGE] + ): + will_message = PublishMessage(**self.conf[CONF_WILL_MESSAGE]) + else: + will_message = None + + if will_message is not None: + self._mqttc.will_set( + topic=will_message.topic, + payload=will_message.payload, + qos=will_message.qos, + retain=will_message.retain, + ) + + async def async_publish( + self, topic: str, payload: PublishPayloadType, qos: int, retain: bool + ) -> None: + """Publish a MQTT message.""" + async with self._paho_lock: + msg_info = await self.hass.async_add_executor_job( + self._mqttc.publish, topic, payload, qos, retain + ) + _LOGGER.debug( + "Transmitting message on %s: '%s', mid: %s", + topic, + payload, + msg_info.mid, + ) + _raise_on_error(msg_info.rc) + await self._wait_for_mid(msg_info.mid) + + async def async_connect(self) -> None: + """Connect to the host. Does not process messages yet.""" + # pylint: disable-next=import-outside-toplevel + import paho.mqtt.client as mqtt + + result: int | None = None + try: + result = await self.hass.async_add_executor_job( + self._mqttc.connect, + self.conf[CONF_BROKER], + self.conf[CONF_PORT], + self.conf[CONF_KEEPALIVE], + ) + except OSError as err: + _LOGGER.error("Failed to connect to MQTT server due to exception: %s", err) + + if result is not None and result != 0: + _LOGGER.error( + "Failed to connect to MQTT server: %s", mqtt.error_string(result) + ) + + self._mqttc.loop_start() + + async def async_disconnect(self): + """Stop the MQTT client.""" + + def stop(): + """Stop the MQTT client.""" + # Do not disconnect, we want the broker to always publish will + self._mqttc.loop_stop() + + await self.hass.async_add_executor_job(stop) + + async def async_subscribe( + self, + topic: str, + msg_callback: MessageCallbackType, + qos: int, + encoding: str | None = None, + ) -> Callable[[], None]: + """Set up a subscription to a topic with the provided qos. + + This method is a coroutine. + """ + if not isinstance(topic, str): + raise HomeAssistantError("Topic needs to be a string!") + + subscription = Subscription( + topic, _matcher_for_topic(topic), HassJob(msg_callback), qos, encoding + ) + self.subscriptions.append(subscription) + self._matching_subscriptions.cache_clear() + + # Only subscribe if currently connected. + if self.connected: + self._last_subscribe = time.time() + await self._async_perform_subscription(topic, qos) + + @callback + def async_remove() -> None: + """Remove subscription.""" + if subscription not in self.subscriptions: + raise HomeAssistantError("Can't remove subscription twice") + self.subscriptions.remove(subscription) + self._matching_subscriptions.cache_clear() + + # Only unsubscribe if currently connected. + if self.connected: + self.hass.async_create_task(self._async_unsubscribe(topic)) + + return async_remove + + async def _async_unsubscribe(self, topic: str) -> None: + """Unsubscribe from a topic. + + This method is a coroutine. + """ + if any(other.topic == topic for other in self.subscriptions): + # Other subscriptions on topic remaining - don't unsubscribe. + return + + async with self._paho_lock: + result: int | None = None + result, mid = await self.hass.async_add_executor_job( + self._mqttc.unsubscribe, topic + ) + _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) + _raise_on_error(result) + await self._wait_for_mid(mid) + + async def _async_perform_subscription(self, topic: str, qos: int) -> None: + """Perform a paho-mqtt subscription.""" + async with self._paho_lock: + result: int | None = None + result, mid = await self.hass.async_add_executor_job( + self._mqttc.subscribe, topic, qos + ) + _LOGGER.debug("Subscribing to %s, mid: %s", topic, mid) + _raise_on_error(result) + await self._wait_for_mid(mid) + + def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None: + """On connect callback. + + Resubscribe to all topics we were subscribed to and publish birth + message. + """ + # pylint: disable-next=import-outside-toplevel + import paho.mqtt.client as mqtt + + if result_code != mqtt.CONNACK_ACCEPTED: + _LOGGER.error( + "Unable to connect to the MQTT broker: %s", + mqtt.connack_string(result_code), + ) + return + + self.connected = True + dispatcher_send(self.hass, MQTT_CONNECTED) + _LOGGER.info( + "Connected to MQTT server %s:%s (%s)", + self.conf[CONF_BROKER], + self.conf[CONF_PORT], + result_code, + ) + + # Group subscriptions to only re-subscribe once for each topic. + keyfunc = attrgetter("topic") + for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), keyfunc): + # Re-subscribe with the highest requested qos + max_qos = max(subscription.qos for subscription in subs) + self.hass.add_job(self._async_perform_subscription, topic, max_qos) + + if ( + CONF_BIRTH_MESSAGE in self.conf + and ATTR_TOPIC in self.conf[CONF_BIRTH_MESSAGE] + ): + + async def publish_birth_message(birth_message): + await self._ha_started.wait() # Wait for Home Assistant to start + await self._discovery_cooldown() # Wait for MQTT discovery to cool down + await self.async_publish( + topic=birth_message.topic, + payload=birth_message.payload, + qos=birth_message.qos, + retain=birth_message.retain, + ) + + birth_message = PublishMessage(**self.conf[CONF_BIRTH_MESSAGE]) + asyncio.run_coroutine_threadsafe( + publish_birth_message(birth_message), self.hass.loop + ) + + def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None: + """Message received callback.""" + self.hass.add_job(self._mqtt_handle_message, msg) + + @lru_cache(2048) + def _matching_subscriptions(self, topic): + subscriptions = [] + for subscription in self.subscriptions: + if subscription.matcher(topic): + subscriptions.append(subscription) + return subscriptions + + @callback + def _mqtt_handle_message(self, msg) -> None: + _LOGGER.debug( + "Received message on %s%s: %s", + msg.topic, + " (retained)" if msg.retain else "", + msg.payload[0:8192], + ) + timestamp = dt_util.utcnow() + + subscriptions = self._matching_subscriptions(msg.topic) + + for subscription in subscriptions: + + payload: SubscribePayloadType = msg.payload + if subscription.encoding is not None: + try: + payload = msg.payload.decode(subscription.encoding) + except (AttributeError, UnicodeDecodeError): + _LOGGER.warning( + "Can't decode payload %s on %s with encoding %s (for %s)", + msg.payload[0:8192], + msg.topic, + subscription.encoding, + subscription.job, + ) + continue + + self.hass.async_run_hass_job( + subscription.job, + ReceiveMessage( + msg.topic, + payload, + msg.qos, + msg.retain, + subscription.topic, + timestamp, + ), + ) + + def _mqtt_on_callback(self, _mqttc, _userdata, mid, _granted_qos=None) -> None: + """Publish / Subscribe / Unsubscribe callback.""" + self.hass.add_job(self._mqtt_handle_mid, mid) + + @callback + def _mqtt_handle_mid(self, mid) -> None: + # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid + # may be executed first. + if mid not in self._pending_operations: + self._pending_operations[mid] = asyncio.Event() + self._pending_operations[mid].set() + + def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: + """Disconnected callback.""" + self.connected = False + dispatcher_send(self.hass, MQTT_DISCONNECTED) + _LOGGER.warning( + "Disconnected from MQTT server %s:%s (%s)", + self.conf[CONF_BROKER], + self.conf[CONF_PORT], + result_code, + ) + + async def _wait_for_mid(self, mid): + """Wait for ACK from broker.""" + # Create the mid event if not created, either _mqtt_handle_mid or _wait_for_mid + # may be executed first. + if mid not in self._pending_operations: + self._pending_operations[mid] = asyncio.Event() + try: + await asyncio.wait_for(self._pending_operations[mid].wait(), TIMEOUT_ACK) + except asyncio.TimeoutError: + _LOGGER.warning( + "No ACK from MQTT server in %s seconds (mid: %s)", TIMEOUT_ACK, mid + ) + finally: + del self._pending_operations[mid] + + async def _discovery_cooldown(self): + now = time.time() + # Reset discovery and subscribe cooldowns + self.hass.data[LAST_DISCOVERY] = now + self._last_subscribe = now + + last_discovery = self.hass.data[LAST_DISCOVERY] + last_subscribe = self._last_subscribe + wait_until = max( + last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN + ) + while now < wait_until: + await asyncio.sleep(wait_until - now) + now = time.time() + last_discovery = self.hass.data[LAST_DISCOVERY] + last_subscribe = self._last_subscribe + wait_until = max( + last_discovery + DISCOVERY_COOLDOWN, last_subscribe + DISCOVERY_COOLDOWN + ) + + +def _raise_on_error(result_code: int | None) -> None: + """Raise error if error result.""" + # pylint: disable-next=import-outside-toplevel + import paho.mqtt.client as mqtt + + if result_code is not None and result_code != 0: + raise HomeAssistantError( + f"Error talking to MQTT: {mqtt.error_string(result_code)}" + ) + + +def _matcher_for_topic(subscription: str) -> Any: + # pylint: disable-next=import-outside-toplevel + from paho.mqtt.matcher import MQTTMatcher + + matcher = MQTTMatcher() + matcher[subscription] = True + + return lambda topic: next(matcher.iter_match(topic), False) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 52465bbba24..64b462359be 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -44,8 +44,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import CONF_ENCODING, CONF_QOS, CONF_RETAIN, PAYLOAD_NONE from .debug_info import log_messages from .mixins import ( @@ -56,6 +56,8 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -232,33 +234,33 @@ def valid_preset_mode_configuration(config): return config -_PLATFORM_SCHEMA_BASE = mqtt.MQTT_BASE_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { - vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AUX_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AUX_STATE_TOPIC): valid_subscribe_topic, # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 - vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_AWAY_MODE_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template, - vol.Optional(CONF_CURRENT_TEMP_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_CURRENT_TEMP_TOPIC): valid_subscribe_topic, vol.Optional(CONF_FAN_MODE_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( CONF_FAN_MODE_LIST, default=[FAN_AUTO, FAN_LOW, FAN_MEDIUM, FAN_HIGH], ): cv.ensure_list, vol.Optional(CONF_FAN_MODE_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_FAN_MODE_STATE_TOPIC): valid_subscribe_topic, # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 vol.Optional(CONF_HOLD_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_HOLD_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_HOLD_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_HOLD_LIST): cv.ensure_list, vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( CONF_MODE_LIST, default=[ @@ -271,54 +273,54 @@ _PLATFORM_SCHEMA_BASE = mqtt.MQTT_BASE_SCHEMA.extend( ], ): cv.ensure_list, vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, - vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_POWER_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_POWER_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_POWER_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_POWER_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRECISION): vol.In( [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE] ), - vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, # CONF_SEND_IF_OFF is deprecated, support will be removed with release 2022.9 vol.Optional(CONF_SEND_IF_OFF): cv.boolean, vol.Optional(CONF_ACTION_TEMPLATE): cv.template, - vol.Optional(CONF_ACTION_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_ACTION_TOPIC): valid_subscribe_topic, # CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together vol.Inclusive( CONF_PRESET_MODE_COMMAND_TOPIC, "preset_modes" - ): mqtt.valid_publish_topic, + ): valid_publish_topic, vol.Inclusive( CONF_PRESET_MODES_LIST, "preset_modes", default=[] ): cv.ensure_list, vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_SWING_MODE_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( CONF_SWING_MODE_LIST, default=[SWING_ON, SWING_OFF] ): cv.ensure_list, vol.Optional(CONF_SWING_MODE_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_SWING_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SWING_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TEMP_INITIAL, default=21): cv.positive_int, vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float), vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float), vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float), vol.Optional(CONF_TEMP_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_TEMP_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMP_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_TEMP_HIGH_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_TEMP_HIGH_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_TEMP_HIGH_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMP_HIGH_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_TEMP_HIGH_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TEMP_HIGH_STATE_TEMPLATE): cv.template, vol.Optional(CONF_TEMP_LOW_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_TEMP_LOW_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TEMP_LOW_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_TEMP_LOW_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_TEMP_LOW_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMP_LOW_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TEMP_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_TEMP_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TEMP_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, } diff --git a/homeassistant/components/mqtt/config.py b/homeassistant/components/mqtt/config.py new file mode 100644 index 00000000000..4f84d911418 --- /dev/null +++ b/homeassistant/components/mqtt/config.py @@ -0,0 +1,148 @@ +"""Support for MQTT message handling.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_DISCOVERY, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_PAYLOAD, + ATTR_QOS, + ATTR_RETAIN, + ATTR_TOPIC, + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_CERTIFICATE, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_COMMAND_TOPIC, + CONF_DISCOVERY_PREFIX, + CONF_ENCODING, + CONF_KEEPALIVE, + CONF_QOS, + CONF_RETAIN, + CONF_STATE_TOPIC, + CONF_TLS_INSECURE, + CONF_TLS_VERSION, + CONF_WILL_MESSAGE, + DEFAULT_BIRTH, + DEFAULT_DISCOVERY, + DEFAULT_ENCODING, + DEFAULT_PREFIX, + DEFAULT_QOS, + DEFAULT_RETAIN, + DEFAULT_WILL, + PLATFORMS, + PROTOCOL_31, + PROTOCOL_311, +) +from .util import _VALID_QOS_SCHEMA, valid_publish_topic, valid_subscribe_topic + +DEFAULT_PORT = 1883 +DEFAULT_KEEPALIVE = 60 +DEFAULT_PROTOCOL = PROTOCOL_311 +DEFAULT_TLS_PROTOCOL = "auto" + +DEFAULT_VALUES = { + CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, + CONF_DISCOVERY: DEFAULT_DISCOVERY, + CONF_PORT: DEFAULT_PORT, + CONF_TLS_VERSION: DEFAULT_TLS_PROTOCOL, + CONF_WILL_MESSAGE: DEFAULT_WILL, +} + +CLIENT_KEY_AUTH_MSG = ( + "client_key and client_cert must both be present in " + "the MQTT broker configuration" +) + +MQTT_WILL_BIRTH_SCHEMA = vol.Schema( + { + vol.Inclusive(ATTR_TOPIC, "topic_payload"): valid_publish_topic, + vol.Inclusive(ATTR_PAYLOAD, "topic_payload"): cv.string, + vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, + vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + }, + required=True, +) + +PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( + {vol.Optional(platform.value): cv.ensure_list for platform in PLATFORMS} +) + +CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( + { + vol.Optional(CONF_CLIENT_ID): cv.string, + vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All( + vol.Coerce(int), vol.Range(min=15) + ), + vol.Optional(CONF_BROKER): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile), + vol.Inclusive( + CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG + ): cv.isfile, + vol.Inclusive( + CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG + ): cv.isfile, + vol.Optional(CONF_TLS_INSECURE): cv.boolean, + vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), + vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All( + cv.string, vol.In([PROTOCOL_31, PROTOCOL_311]) + ), + vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, + vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, + vol.Optional(CONF_DISCOVERY): cv.boolean, + # discovery_prefix must be a valid publish topic because if no + # state topic is specified, it will be created with the given prefix. + vol.Optional( + CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX + ): valid_publish_topic, + } +) + +DEPRECATED_CONFIG_KEYS = [ + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_DISCOVERY, + CONF_PASSWORD, + CONF_PORT, + CONF_TLS_VERSION, + CONF_USERNAME, + CONF_WILL_MESSAGE, +] + +SCHEMA_BASE = { + vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, +} + +MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE) + +# Sensor type platforms subscribe to MQTT events +MQTT_RO_SCHEMA = MQTT_BASE_SCHEMA.extend( + { + vol.Required(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + } +) + +# Switch type platforms publish to MQTT and may subscribe +MQTT_RW_SCHEMA = MQTT_BASE_SCHEMA.extend( + { + vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + } +) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 0a763e850e5..822ae712573 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -17,7 +17,7 @@ from homeassistant.const import ( ) from homeassistant.data_entry_flow import FlowResult -from . import MqttClientSetup +from .client import MqttClientSetup from .const import ( ATTR_PAYLOAD, ATTR_QOS, diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 106d0310158..2f7e27e7252 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -1,5 +1,5 @@ """Constants used by multiple MQTT modules.""" -from homeassistant.const import CONF_PAYLOAD +from homeassistant.const import CONF_PAYLOAD, Platform ATTR_DISCOVERY_HASH = "discovery_hash" ATTR_DISCOVERY_PAYLOAD = "discovery_payload" @@ -14,7 +14,9 @@ CONF_BROKER = "broker" CONF_BIRTH_MESSAGE = "birth_message" CONF_COMMAND_TEMPLATE = "command_template" CONF_COMMAND_TOPIC = "command_topic" +CONF_DISCOVERY_PREFIX = "discovery_prefix" CONF_ENCODING = "encoding" +CONF_KEEPALIVE = "keepalive" CONF_QOS = ATTR_QOS CONF_RETAIN = ATTR_RETAIN CONF_STATE_TOPIC = "state_topic" @@ -30,6 +32,7 @@ CONF_TLS_VERSION = "tls_version" CONFIG_ENTRY_IS_SETUP = "mqtt_config_entry_is_setup" DATA_CONFIG_ENTRY_LOCK = "mqtt_config_entry_lock" +DATA_MQTT = "mqtt" DATA_MQTT_CONFIG = "mqtt_config" DATA_MQTT_RELOAD_NEEDED = "mqtt_reload_needed" @@ -66,3 +69,24 @@ PAYLOAD_NONE = "None" PROTOCOL_31 = "3.1" PROTOCOL_311 = "3.1.1" + +PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CAMERA, + Platform.CLIMATE, + Platform.DEVICE_TRACKER, + Platform.COVER, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SCENE, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.VACUUM, +] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 8e36329946a..5814f3e43f7 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -33,8 +33,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, @@ -51,6 +51,8 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate +from .util import valid_publish_topic, valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -152,11 +154,11 @@ def validate_options(value): return value -_PLATFORM_SCHEMA_BASE = mqtt.MQTT_BASE_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_BASE_SCHEMA.extend( { - vol.Optional(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_GET_POSITION_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_GET_POSITION_TOPIC): valid_subscribe_topic, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): vol.Any( @@ -172,24 +174,24 @@ _PLATFORM_SCHEMA_BASE = mqtt.MQTT_BASE_SCHEMA.extend( vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template, - vol.Optional(CONF_SET_POSITION_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SET_POSITION_TOPIC): valid_publish_topic, vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string, vol.Optional(CONF_STATE_CLOSING, default=STATE_CLOSING): cv.string, vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string, vol.Optional(CONF_STATE_OPENING, default=STATE_OPENING): cv.string, vol.Optional(CONF_STATE_STOPPED, default=DEFAULT_STATE_STOPPED): cv.string, - vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Optional( CONF_TILT_CLOSED_POSITION, default=DEFAULT_TILT_CLOSED_POSITION ): int, - vol.Optional(CONF_TILT_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_TILT_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_TILT_MAX, default=DEFAULT_TILT_MAX): int, vol.Optional(CONF_TILT_MIN, default=DEFAULT_TILT_MIN): int, vol.Optional(CONF_TILT_OPEN_POSITION, default=DEFAULT_TILT_OPEN_POSITION): int, vol.Optional( CONF_TILT_STATE_OPTIMISTIC, default=DEFAULT_TILT_OPTIMISTIC ): cv.boolean, - vol.Optional(CONF_TILT_STATUS_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TILT_STATUS_TOPIC): valid_subscribe_topic, vol.Optional(CONF_TILT_STATUS_TEMPLATE): cv.template, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_GET_POSITION_TEMPLATE): cv.template, diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index 002ae6e3991..0646a5bda0c 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -6,7 +6,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from . import device_trigger -from .. import mqtt +from .config import MQTT_BASE_SCHEMA from .mixins import async_setup_entry_helper AUTOMATION_TYPE_TRIGGER = "trigger" @@ -17,7 +17,7 @@ CONF_AUTOMATION_TYPE = "automation_type" PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( {vol.Required(CONF_AUTOMATION_TYPE): AUTOMATION_TYPES_SCHEMA}, extra=vol.ALLOW_EXTRA, -).extend(mqtt.MQTT_BASE_SCHEMA.schema) +).extend(MQTT_BASE_SCHEMA.schema) async def async_setup_entry(hass, config_entry): diff --git a/homeassistant/components/mqtt/device_tracker/schema_discovery.py b/homeassistant/components/mqtt/device_tracker/schema_discovery.py index aa7506bd5e3..1b48e15b80e 100644 --- a/homeassistant/components/mqtt/device_tracker/schema_discovery.py +++ b/homeassistant/components/mqtt/device_tracker/schema_discovery.py @@ -19,8 +19,8 @@ from homeassistant.const import ( from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from .. import MqttValueTemplate, subscription -from ... import mqtt +from .. import subscription +from ..config import MQTT_RO_SCHEMA from ..const import CONF_QOS, CONF_STATE_TOPIC from ..debug_info import log_messages from ..mixins import ( @@ -29,12 +29,13 @@ from ..mixins import ( async_get_platform_config_from_yaml, async_setup_entry_helper, ) +from ..models import MqttValueTemplate CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_SOURCE_TYPE = "source_type" -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RO_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, diff --git a/homeassistant/components/mqtt/device_tracker/schema_yaml.py b/homeassistant/components/mqtt/device_tracker/schema_yaml.py index f871ac89c2d..2dfa5b7134c 100644 --- a/homeassistant/components/mqtt/device_tracker/schema_yaml.py +++ b/homeassistant/components/mqtt/device_tracker/schema_yaml.py @@ -7,16 +7,18 @@ from homeassistant.const import CONF_DEVICES, STATE_HOME, STATE_NOT_HOME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from ... import mqtt +from ..client import async_subscribe +from ..config import SCHEMA_BASE from ..const import CONF_QOS +from ..util import valid_subscribe_topic CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" CONF_SOURCE_TYPE = "source_type" -PLATFORM_SCHEMA_YAML = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend( +PLATFORM_SCHEMA_YAML = PLATFORM_SCHEMA.extend(SCHEMA_BASE).extend( { - vol.Required(CONF_DEVICES): {cv.string: mqtt.valid_subscribe_topic}, + vol.Required(CONF_DEVICES): {cv.string: valid_subscribe_topic}, vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, vol.Optional(CONF_PAYLOAD_NOT_HOME, default=STATE_NOT_HOME): cv.string, vol.Optional(CONF_SOURCE_TYPE): vol.In(SOURCE_TYPES), @@ -50,6 +52,6 @@ async def async_setup_scanner_from_yaml(hass, config, async_see, discovery_info= hass.async_create_task(async_see(**see_args)) - await mqtt.async_subscribe(hass, topic, async_message_received, qos) + await async_subscribe(hass, topic, async_message_received, qos) return True diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 2c6c6ecc3ba..0b4bcbfcbc2 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -29,7 +29,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import ConfigType from . import debug_info, trigger as mqtt_trigger -from .. import mqtt +from .config import MQTT_BASE_SCHEMA from .const import ( ATTR_DISCOVERY_HASH, CONF_ENCODING, @@ -71,7 +71,7 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( } ) -TRIGGER_DISCOVERY_SCHEMA = mqtt.MQTT_BASE_SCHEMA.extend( +TRIGGER_DISCOVERY_SCHEMA = MQTT_BASE_SCHEMA.extend( { vol.Required(CONF_AUTOMATION_TYPE): str, vol.Required(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, @@ -101,7 +101,7 @@ class TriggerInstance: async def async_attach_trigger(self) -> None: """Attach MQTT trigger.""" mqtt_config = { - CONF_PLATFORM: mqtt.DOMAIN, + CONF_PLATFORM: DOMAIN, CONF_TOPIC: self.trigger.topic, CONF_ENCODING: DEFAULT_ENCODING, CONF_QOS: self.trigger.qos, diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index f2b738cd2bb..f72b0bdf689 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -34,8 +34,8 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, @@ -55,6 +55,8 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate +from .util import valid_publish_topic, valid_subscribe_topic CONF_PERCENTAGE_STATE_TOPIC = "percentage_state_topic" CONF_PERCENTAGE_COMMAND_TOPIC = "percentage_command_topic" @@ -125,28 +127,28 @@ def valid_preset_mode_configuration(config): return config -_PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_OSCILLATION_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_OSCILLATION_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_OSCILLATION_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_OSCILLATION_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_PERCENTAGE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_PERCENTAGE_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_PERCENTAGE_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_PERCENTAGE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PERCENTAGE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PERCENTAGE_VALUE_TEMPLATE): cv.template, # CONF_PRESET_MODE_COMMAND_TOPIC and CONF_PRESET_MODES_LIST must be used together vol.Inclusive( CONF_PRESET_MODE_COMMAND_TOPIC, "preset_modes" - ): mqtt.valid_publish_topic, + ): valid_publish_topic, vol.Inclusive( CONF_PRESET_MODES_LIST, "preset_modes", default=[] ): cv.ensure_list, vol.Optional(CONF_PRESET_MODE_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_PRESET_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_PRESET_MODE_VALUE_TEMPLATE): cv.template, vol.Optional( CONF_SPEED_RANGE_MIN, default=DEFAULT_SPEED_RANGE_MIN @@ -168,8 +170,8 @@ _PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_SCHEMA.extend( vol.Optional( CONF_PAYLOAD_OSCILLATION_ON, default=OSCILLATE_ON_PAYLOAD ): cv.string, - vol.Optional(CONF_SPEED_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_SPEED_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_SPEED_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_SPEED_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_SPEED_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, } diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index f6d4aa01dab..000a9b9700e 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -30,8 +30,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, @@ -51,6 +51,8 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate +from .util import valid_publish_topic, valid_subscribe_topic CONF_AVAILABLE_MODES_LIST = "modes" CONF_DEVICE_CLASS = "device_class" @@ -103,15 +105,13 @@ def valid_humidity_range_configuration(config): return config -_PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { # CONF_AVAIALABLE_MODES_LIST and CONF_MODE_COMMAND_TOPIC must be used together vol.Inclusive( CONF_AVAILABLE_MODES_LIST, "available_modes", default=[] ): cv.ensure_list, - vol.Inclusive( - CONF_MODE_COMMAND_TOPIC, "available_modes" - ): mqtt.valid_publish_topic, + vol.Inclusive(CONF_MODE_COMMAND_TOPIC, "available_modes"): valid_publish_topic, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Optional( CONF_DEVICE_CLASS, default=HumidifierDeviceClass.HUMIDIFIER @@ -119,14 +119,14 @@ _PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_SCHEMA.extend( [HumidifierDeviceClass.HUMIDIFIER, HumidifierDeviceClass.DEHUMIDIFIER] ), vol.Optional(CONF_MODE_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, - vol.Required(CONF_TARGET_HUMIDITY_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Required(CONF_TARGET_HUMIDITY_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_TARGET_HUMIDITY_COMMAND_TEMPLATE): cv.template, vol.Optional( CONF_TARGET_HUMIDITY_MAX, default=DEFAULT_MAX_HUMIDITY @@ -135,7 +135,7 @@ _PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_SCHEMA.extend( CONF_TARGET_HUMIDITY_MIN, default=DEFAULT_MIN_HUMIDITY ): cv.positive_int, vol.Optional(CONF_TARGET_HUMIDITY_STATE_TEMPLATE): cv.template, - vol.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_TARGET_HUMIDITY_STATE_TOPIC): valid_subscribe_topic, vol.Optional( CONF_PAYLOAD_RESET_HUMIDITY, default=DEFAULT_PAYLOAD_RESET ): cv.string, diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index eb4ec264981..1c94fa82f73 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -42,8 +42,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.color as color_util -from .. import MqttCommandTemplate, MqttValueTemplate, subscription -from ... import mqtt +from .. import subscription +from ..config import MQTT_RW_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, @@ -55,6 +55,8 @@ from ..const import ( ) from ..debug_info import log_messages from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..models import MqttCommandTemplate, MqttValueTemplate +from ..util import valid_publish_topic, valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -156,28 +158,28 @@ VALUE_TEMPLATE_KEYS = [ ] _PLATFORM_SCHEMA_BASE = ( - mqtt.MQTT_RW_SCHEMA.extend( + MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BRIGHTNESS_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): valid_publish_topic, vol.Optional( CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE ): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_BRIGHTNESS_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_BRIGHTNESS_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_BRIGHTNESS_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_COLOR_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_COLOR_MODE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_COLOR_MODE_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_COLOR_TEMP_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_EFFECT_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_EFFECT_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_EFFECT_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EFFECT_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_EFFECT_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_EFFECT_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_HS_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_HS_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_HS_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_HS_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_MAX_MIREDS): cv.positive_int, vol.Optional(CONF_MIN_MIREDS): cv.positive_int, @@ -189,30 +191,30 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_RGB_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_RGB_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RGB_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_RGBW_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_RGBW_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_RGBW_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_RGBW_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RGBW_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_RGBW_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_RGBWW_COMMAND_TEMPLATE): cv.template, - vol.Optional(CONF_RGBWW_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_RGBWW_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_RGBWW_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_RGBWW_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_RGBWW_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_WHITE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_WHITE_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_WHITE_SCALE, default=DEFAULT_WHITE_SCALE): vol.All( vol.Coerce(int), vol.Range(min=1) ), - vol.Optional(CONF_WHITE_VALUE_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_WHITE_VALUE_COMMAND_TOPIC): valid_publish_topic, vol.Optional( CONF_WHITE_VALUE_SCALE, default=DEFAULT_WHITE_VALUE_SCALE ): vol.All(vol.Coerce(int), vol.Range(min=1)), - vol.Optional(CONF_WHITE_VALUE_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_WHITE_VALUE_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_WHITE_VALUE_TEMPLATE): cv.template, - vol.Optional(CONF_XY_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_XY_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_XY_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_XY_STATE_TOPIC): valid_subscribe_topic, vol.Optional(CONF_XY_VALUE_TEMPLATE): cv.template, }, ) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 2049818ab31..be49f1ad2e3 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -51,7 +51,7 @@ from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util from .. import subscription -from ... import mqtt +from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, @@ -61,6 +61,7 @@ from ..const import ( ) from ..debug_info import log_messages from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..util import valid_subscribe_topic from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import CONF_BRIGHTNESS_SCALE, MQTT_LIGHT_ATTRIBUTES_BLOCKED @@ -103,7 +104,7 @@ def valid_color_configuration(config): _PLATFORM_SCHEMA_BASE = ( - mqtt.MQTT_RW_SCHEMA.extend( + MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean, vol.Optional( @@ -126,12 +127,12 @@ _PLATFORM_SCHEMA_BASE = ( vol.Optional(CONF_MIN_MIREDS): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS): vol.All( + vol.Optional(CONF_QOS, default=DEFAULT_QOS): vol.All( vol.Coerce(int), vol.In([0, 1, 2]) ), - vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, + vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_RGB, default=DEFAULT_RGB): cv.boolean, - vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, vol.Inclusive(CONF_SUPPORTED_COLOR_MODES, "color_mode"): vol.All( cv.ensure_list, [vol.In(VALID_COLOR_MODES)], diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 0165bfc8efa..779f2f17e24 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -31,8 +31,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.color as color_util -from .. import MqttValueTemplate, subscription -from ... import mqtt +from .. import subscription +from ..config import MQTT_RW_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, @@ -43,6 +43,7 @@ from ..const import ( ) from ..debug_info import log_messages from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity +from ..models import MqttValueTemplate from .schema import MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import MQTT_LIGHT_ATTRIBUTES_BLOCKED @@ -67,7 +68,7 @@ CONF_RED_TEMPLATE = "red_template" CONF_WHITE_VALUE_TEMPLATE = "white_value_template" _PLATFORM_SCHEMA_BASE = ( - mqtt.MQTT_RW_SCHEMA.extend( + MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_BLUE_TEMPLATE): cv.template, vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template, diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 5dc0a974d26..0cfd1d2b70f 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -15,8 +15,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, @@ -33,6 +33,7 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttValueTemplate CONF_PAYLOAD_LOCK = "payload_lock" CONF_PAYLOAD_UNLOCK = "payload_unlock" @@ -56,7 +57,7 @@ MQTT_LOCK_ATTRIBUTES_BLOCKED = frozenset( } ) -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RW_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index a46debeae54..694fae0b3c0 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -49,14 +49,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import ( - DATA_MQTT, - PLATFORMS, - MqttValueTemplate, - async_publish, - debug_info, - subscription, -) +from . import debug_info, subscription +from .client import async_publish from .const import ( ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_PAYLOAD, @@ -65,6 +59,7 @@ from .const import ( CONF_ENCODING, CONF_QOS, CONF_TOPIC, + DATA_MQTT, DATA_MQTT_CONFIG, DATA_MQTT_RELOAD_NEEDED, DEFAULT_ENCODING, @@ -73,6 +68,7 @@ from .const import ( DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, + PLATFORMS, ) from .debug_info import log_message, log_messages from .discovery import ( @@ -82,7 +78,7 @@ from .discovery import ( clear_discovery_hash, set_discovery_hash, ) -from .models import PublishPayloadType, ReceiveMessage +from .models import MqttValueTemplate, PublishPayloadType, ReceiveMessage from .subscription import ( async_prepare_subscribe_topics, async_subscribe_topics, diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index 9cec65d7254..9bce6baab8b 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -1,12 +1,21 @@ """Models used by multiple MQTT modules.""" from __future__ import annotations +from ast import literal_eval from collections.abc import Awaitable, Callable import datetime as dt -from typing import Union +from typing import Any, Union import attr +from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import template +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import TemplateVarsType + +_SENTINEL = object() + PublishPayloadType = Union[str, bytes, int, float, None] ReceivePayloadType = Union[str, bytes] @@ -35,3 +44,118 @@ class ReceiveMessage: AsyncMessageCallbackType = Callable[[ReceiveMessage], Awaitable[None]] MessageCallbackType = Callable[[ReceiveMessage], None] + + +class MqttCommandTemplate: + """Class for rendering MQTT payload with command templates.""" + + def __init__( + self, + command_template: template.Template | None, + *, + hass: HomeAssistant | None = None, + entity: Entity | None = None, + ) -> None: + """Instantiate a command template.""" + self._attr_command_template = command_template + if command_template is None: + return + + self._entity = entity + + command_template.hass = hass + + if entity: + command_template.hass = entity.hass + + @callback + def async_render( + self, + value: PublishPayloadType = None, + variables: TemplateVarsType = None, + ) -> PublishPayloadType: + """Render or convert the command template with given value or variables.""" + + def _convert_outgoing_payload( + payload: PublishPayloadType, + ) -> PublishPayloadType: + """Ensure correct raw MQTT payload is passed as bytes for publishing.""" + if isinstance(payload, str): + try: + native_object = literal_eval(payload) + if isinstance(native_object, bytes): + return native_object + + except (ValueError, TypeError, SyntaxError, MemoryError): + pass + + return payload + + if self._attr_command_template is None: + return value + + values = {"value": value} + if self._entity: + values[ATTR_ENTITY_ID] = self._entity.entity_id + values[ATTR_NAME] = self._entity.name + if variables is not None: + values.update(variables) + return _convert_outgoing_payload( + self._attr_command_template.async_render(values, parse_result=False) + ) + + +class MqttValueTemplate: + """Class for rendering MQTT value template with possible json values.""" + + def __init__( + self, + value_template: template.Template | None, + *, + hass: HomeAssistant | None = None, + entity: Entity | None = None, + config_attributes: TemplateVarsType = None, + ) -> None: + """Instantiate a value template.""" + self._value_template = value_template + self._config_attributes = config_attributes + if value_template is None: + return + + value_template.hass = hass + self._entity = entity + + if entity: + value_template.hass = entity.hass + + @callback + def async_render_with_possible_json_value( + self, + payload: ReceivePayloadType, + default: ReceivePayloadType | object = _SENTINEL, + variables: TemplateVarsType = None, + ) -> ReceivePayloadType: + """Render with possible json value or pass-though a received MQTT value.""" + if self._value_template is None: + return payload + + values: dict[str, Any] = {} + + if variables is not None: + values.update(variables) + + if self._config_attributes is not None: + values.update(self._config_attributes) + + if self._entity: + values[ATTR_ENTITY_ID] = self._entity.entity_id + values[ATTR_NAME] = self._entity.name + + if default == _SENTINEL: + return self._value_template.async_render_with_possible_json_value( + payload, variables=values + ) + + return self._value_template.async_render_with_possible_json_value( + payload, default, variables=values + ) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 001f9f4f668..6ea1f0959f6 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -27,8 +27,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, @@ -46,6 +46,7 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate _LOGGER = logging.getLogger(__name__) @@ -75,7 +76,7 @@ def validate_config(config): return config -_PLATFORM_SCHEMA_BASE = mqtt.MQTT_RW_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): vol.Coerce(float), diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 98c692ceaff..ce8f0b0a3e8 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -15,7 +15,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .. import mqtt +from .client import async_publish +from .config import MQTT_BASE_SCHEMA from .const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from .mixins import ( CONF_ENABLED_BY_DEFAULT, @@ -27,13 +28,14 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .util import valid_publish_topic DEFAULT_NAME = "MQTT Scene" DEFAULT_RETAIN = False -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_BASE_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( { - vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Required(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PAYLOAD_ON): cv.string, @@ -128,7 +130,7 @@ class MqttScene( This method is a coroutine. """ - await mqtt.async_publish( + await async_publish( self.hass, self._config[CONF_COMMAND_TOPIC], self._config[CONF_PAYLOAD_ON], diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 0765eb7f176..75e1b4e8efd 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -17,8 +17,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, @@ -36,6 +36,7 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate _LOGGER = logging.getLogger(__name__) @@ -51,7 +52,7 @@ MQTT_SELECT_ATTRIBUTES_BLOCKED = frozenset( ) -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RW_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index d865d90c4ee..4dd1ad4d95f 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -34,8 +34,8 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from . import MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RO_SCHEMA from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC from .debug_info import log_messages from .mixins import ( @@ -47,6 +47,8 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttValueTemplate +from .util import valid_subscribe_topic _LOGGER = logging.getLogger(__name__) @@ -89,12 +91,12 @@ def validate_options(conf): return conf -_PLATFORM_SCHEMA_BASE = mqtt.MQTT_RO_SCHEMA.extend( +_PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): mqtt.valid_subscribe_topic, + vol.Optional(CONF_LAST_RESET_TOPIC): valid_subscribe_topic, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index c3a41c3618e..1ecf2c37dbf 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -35,8 +35,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttCommandTemplate, MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, @@ -57,6 +57,7 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttCommandTemplate, MqttValueTemplate DEFAULT_NAME = "MQTT Siren" DEFAULT_PAYLOAD_ON = "ON" @@ -74,7 +75,7 @@ CONF_SUPPORT_VOLUME_SET = "support_volume_set" STATE = "state" -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RW_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_AVAILABLE_TONES): cv.ensure_list, vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index f5f8363eb33..c20ddfe5151 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -24,8 +24,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, @@ -43,6 +43,7 @@ from .mixins import ( async_setup_platform_helper, warn_for_legacy_schema, ) +from .models import MqttValueTemplate DEFAULT_NAME = "MQTT Switch" DEFAULT_PAYLOAD_ON = "ON" @@ -51,7 +52,7 @@ DEFAULT_OPTIMISTIC = False CONF_STATE_ON = "state_on" CONF_STATE_OFF = "state_off" -PLATFORM_SCHEMA_MODERN = mqtt.MQTT_RW_SCHEMA.extend( +PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, diff --git a/homeassistant/components/mqtt/tag.py b/homeassistant/components/mqtt/tag.py index 25e49524b8f..9452d5fc259 100644 --- a/homeassistant/components/mqtt/tag.py +++ b/homeassistant/components/mqtt/tag.py @@ -11,8 +11,8 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from . import MqttValueTemplate, subscription -from .. import mqtt +from . import subscription +from .config import MQTT_BASE_SCHEMA from .const import ATTR_DISCOVERY_HASH, CONF_QOS, CONF_TOPIC from .mixins import ( MQTT_ENTITY_DEVICE_INFO_SCHEMA, @@ -21,7 +21,7 @@ from .mixins import ( send_discovery_done, update_device, ) -from .models import ReceiveMessage +from .models import MqttValueTemplate, ReceiveMessage from .subscription import EntitySubscription from .util import valid_subscribe_topic @@ -30,7 +30,7 @@ LOG_NAME = "Tag" TAG = "tag" TAGS = "mqtt_tags" -PLATFORM_SCHEMA = mqtt.MQTT_BASE_SCHEMA.extend( +PLATFORM_SCHEMA = MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_DEVICE): MQTT_ENTITY_DEVICE_INFO_SCHEMA, vol.Optional(CONF_PLATFORM): "mqtt", diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index eb5e01b6251..f25131c43b7 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -15,11 +15,13 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.icon import icon_for_battery_level -from .. import MqttValueTemplate, subscription -from ... import mqtt +from .. import subscription +from ..config import MQTT_BASE_SCHEMA from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from ..debug_info import log_messages from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema +from ..models import MqttValueTemplate +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -96,25 +98,23 @@ MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED = MQTT_VACUUM_ATTRIBUTES_BLOCKED | frozens ) PLATFORM_SCHEMA_LEGACY_MODERN = ( - mqtt.MQTT_BASE_SCHEMA.extend( + MQTT_BASE_SCHEMA.extend( { vol.Inclusive(CONF_BATTERY_LEVEL_TEMPLATE, "battery"): cv.template, - vol.Inclusive( - CONF_BATTERY_LEVEL_TOPIC, "battery" - ): mqtt.valid_publish_topic, + vol.Inclusive(CONF_BATTERY_LEVEL_TOPIC, "battery"): valid_publish_topic, vol.Inclusive(CONF_CHARGING_TEMPLATE, "charging"): cv.template, - vol.Inclusive(CONF_CHARGING_TOPIC, "charging"): mqtt.valid_publish_topic, + vol.Inclusive(CONF_CHARGING_TOPIC, "charging"): valid_publish_topic, vol.Inclusive(CONF_CLEANING_TEMPLATE, "cleaning"): cv.template, - vol.Inclusive(CONF_CLEANING_TOPIC, "cleaning"): mqtt.valid_publish_topic, + vol.Inclusive(CONF_CLEANING_TOPIC, "cleaning"): valid_publish_topic, vol.Inclusive(CONF_DOCKED_TEMPLATE, "docked"): cv.template, - vol.Inclusive(CONF_DOCKED_TOPIC, "docked"): mqtt.valid_publish_topic, + vol.Inclusive(CONF_DOCKED_TOPIC, "docked"): valid_publish_topic, vol.Inclusive(CONF_ERROR_TEMPLATE, "error"): cv.template, - vol.Inclusive(CONF_ERROR_TOPIC, "error"): mqtt.valid_publish_topic, + vol.Inclusive(CONF_ERROR_TOPIC, "error"): valid_publish_topic, vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( cv.ensure_list, [cv.string] ), vol.Inclusive(CONF_FAN_SPEED_TEMPLATE, "fan_speed"): cv.template, - vol.Inclusive(CONF_FAN_SPEED_TOPIC, "fan_speed"): mqtt.valid_publish_topic, + vol.Inclusive(CONF_FAN_SPEED_TOPIC, "fan_speed"): valid_publish_topic, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional( CONF_PAYLOAD_CLEAN_SPOT, default=DEFAULT_PAYLOAD_CLEAN_SPOT @@ -135,12 +135,12 @@ PLATFORM_SCHEMA_LEGACY_MODERN = ( vol.Optional( CONF_PAYLOAD_TURN_ON, default=DEFAULT_PAYLOAD_TURN_ON ): cv.string, - vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, vol.Optional( CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS ): vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), - vol.Optional(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, } ) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 7aa7be07797..3d670780994 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -23,7 +23,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .. import subscription -from ... import mqtt +from ..config import MQTT_BASE_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, CONF_ENCODING, @@ -33,6 +33,7 @@ from ..const import ( ) from ..debug_info import log_messages from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema +from ..util import valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from .schema import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services @@ -105,7 +106,7 @@ DEFAULT_PAYLOAD_START = "start" DEFAULT_PAYLOAD_PAUSE = "pause" PLATFORM_SCHEMA_STATE_MODERN = ( - mqtt.MQTT_BASE_SCHEMA.extend( + MQTT_BASE_SCHEMA.extend( { vol.Optional(CONF_FAN_SPEED_LIST, default=[]): vol.All( cv.ensure_list, [cv.string] @@ -123,13 +124,13 @@ PLATFORM_SCHEMA_STATE_MODERN = ( vol.Optional(CONF_PAYLOAD_START, default=DEFAULT_PAYLOAD_START): cv.string, vol.Optional(CONF_PAYLOAD_PAUSE, default=DEFAULT_PAYLOAD_PAUSE): cv.string, vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string, - vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic, - vol.Optional(CONF_STATE_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_SEND_COMMAND_TOPIC): valid_publish_topic, + vol.Optional(CONF_SET_FAN_SPEED_TOPIC): valid_publish_topic, + vol.Optional(CONF_STATE_TOPIC): valid_publish_topic, vol.Optional( CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS ): vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]), - vol.Optional(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic, + vol.Optional(CONF_COMMAND_TOPIC): valid_publish_topic, vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean, } ) @@ -178,7 +179,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): supported_feature_strings, STRING_TO_SERVICE ) self._fan_speed_list = config[CONF_FAN_SPEED_LIST] - self._command_topic = config.get(mqtt.CONF_COMMAND_TOPIC) + self._command_topic = config.get(CONF_COMMAND_TOPIC) self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC) self._send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC) diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py index 505fa3bd809..1d99e6d7b6f 100644 --- a/homeassistant/components/mqtt_json/device_tracker.py +++ b/homeassistant/components/mqtt_json/device_tracker.py @@ -35,7 +35,7 @@ GPS_JSON_PAYLOAD_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend( +PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend(mqtt.config.SCHEMA_BASE).extend( {vol.Required(CONF_DEVICES): {cv.string: mqtt.valid_subscribe_topic}} ) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 54de561c11e..276695d8edd 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -43,7 +43,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_AWAY_TIMEOUT, default=DEFAULT_AWAY_TIMEOUT): cv.positive_int, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, } -).extend(mqtt.MQTT_RO_SCHEMA.schema) +).extend(mqtt.config.MQTT_RO_SCHEMA.schema) MQTT_PAYLOAD = vol.Schema( vol.All( diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 285af765ab4..e130b820c1b 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -12,7 +12,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, ) -from homeassistant.components.mqtt import CONF_STATE_TOPIC +from homeassistant.components.mqtt.const import CONF_STATE_TOPIC from homeassistant.components.mqtt.cover import ( CONF_GET_POSITION_TEMPLATE, CONF_GET_POSITION_TOPIC, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 07c39d70df0..aa0bfb82608 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1123,7 +1123,7 @@ async def test_restore_subscriptions_on_reconnect(hass, mqtt_client_mock, mqtt_m assert mqtt_client_mock.subscribe.call_count == 1 mqtt_client_mock.on_disconnect(None, None, 0) - with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0): + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0): mqtt_client_mock.on_connect(None, None, None, 0) await hass.async_block_till_done() assert mqtt_client_mock.subscribe.call_count == 2 @@ -1157,7 +1157,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( assert mqtt_client_mock.unsubscribe.call_count == 0 mqtt_client_mock.on_disconnect(None, None, 0) - with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0): + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0): mqtt_client_mock.on_connect(None, None, None, 0) await hass.async_block_till_done() @@ -1188,7 +1188,7 @@ async def test_logs_error_if_no_connect_broker( ) -@patch("homeassistant.components.mqtt.TIMEOUT_ACK", 0.3) +@patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.3) async def test_handle_mqtt_on_callback(hass, caplog, mqtt_mock, mqtt_client_mock): """Test receiving an ACK callback before waiting for it.""" # Simulate an ACK for mid == 1, this will call mqtt_mock._mqtt_handle_mid(mid) @@ -1331,7 +1331,7 @@ async def test_setup_mqtt_client_protocol(hass): """Test MQTT client protocol setup.""" entry = MockConfigEntry( domain=mqtt.DOMAIN, - data={mqtt.CONF_BROKER: "test-broker", mqtt.CONF_PROTOCOL: "3.1"}, + data={mqtt.CONF_BROKER: "test-broker", mqtt.config.CONF_PROTOCOL: "3.1"}, ) with patch("paho.mqtt.client.Client") as mock_client: mock_client.on_connect(return_value=0) @@ -1341,7 +1341,7 @@ async def test_setup_mqtt_client_protocol(hass): assert mock_client.call_args[1]["protocol"] == 3 -@patch("homeassistant.components.mqtt.TIMEOUT_ACK", 0.2) +@patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2) async def test_handle_mqtt_timeout_on_callback(hass, caplog): """Test publish without receiving an ACK callback.""" mid = 0 @@ -1486,7 +1486,7 @@ async def test_custom_birth_message(hass, mqtt_client_mock, mqtt_mock): """Handle birth message.""" birth.set() - with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1): + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.1): await mqtt.async_subscribe(hass, "birth", wait_birth) mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() @@ -1516,7 +1516,7 @@ async def test_default_birth_message(hass, mqtt_client_mock, mqtt_mock): """Handle birth message.""" birth.set() - with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1): + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.1): await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() @@ -1532,7 +1532,7 @@ async def test_default_birth_message(hass, mqtt_client_mock, mqtt_mock): ) async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock): """Test disabling birth message.""" - with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1): + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.1): mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() await asyncio.sleep(0.2) @@ -1580,7 +1580,7 @@ async def test_delayed_birth_message(hass, mqtt_client_mock, mqtt_config, mqtt_m """Handle birth message.""" birth.set() - with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1): + with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.1): await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth) mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index f451079e0f0..b7a3b5f2118 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components import vacuum -from homeassistant.components.mqtt import CONF_COMMAND_TOPIC +from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC from homeassistant.components.mqtt.vacuum import schema_legacy as mqttvacuum from homeassistant.components.mqtt.vacuum.schema import services_to_strings from homeassistant.components.mqtt.vacuum.schema_legacy import ( diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 3f752f1b528..c1017446eff 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.components import vacuum -from homeassistant.components.mqtt import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC +from homeassistant.components.mqtt.const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC from homeassistant.components.mqtt.vacuum import CONF_SCHEMA, schema_state as mqttvacuum from homeassistant.components.mqtt.vacuum.const import MQTT_VACUUM_ATTRIBUTES_BLOCKED from homeassistant.components.mqtt.vacuum.schema import services_to_strings From db9c586404d8fb0e520e731ccb0229d08ffd7161 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Tue, 31 May 2022 09:56:25 +0200 Subject: [PATCH 119/947] Address late comments for frontier silicon (#72745) Co-authored-by: Martin Hjelmare --- .../frontier_silicon/media_player.py | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 66d1f304b1e..e4a67fa5cc4 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -22,8 +22,6 @@ from homeassistant.const import ( STATE_OPENING, STATE_PAUSED, STATE_PLAYING, - STATE_UNAVAILABLE, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -74,12 +72,13 @@ async def async_setup_platform( webfsapi_url = await AFSAPI.get_webfsapi_endpoint( f"http://{host}:{port}/device" ) - afsapi = AFSAPI(webfsapi_url, password) - async_add_entities([AFSAPIDevice(name, afsapi)], True) except FSConnectionError: _LOGGER.error( "Could not add the FSAPI device at %s:%s -> %s", host, port, password ) + return + afsapi = AFSAPI(webfsapi_url, password) + async_add_entities([AFSAPIDevice(name, afsapi)], True) class AFSAPIDevice(MediaPlayerEntity): @@ -188,7 +187,7 @@ class AFSAPIDevice(MediaPlayerEntity): PlayState.STOPPED: STATE_IDLE, PlayState.LOADING: STATE_OPENING, None: STATE_IDLE, - }.get(status, STATE_UNKNOWN) + }.get(status) else: self._state = STATE_OFF except FSConnectionError: @@ -197,32 +196,32 @@ class AFSAPIDevice(MediaPlayerEntity): "Could not connect to %s. Did it go offline?", self._name or afsapi.webfsapi_endpoint, ) - self._state = STATE_UNAVAILABLE self._attr_available = False - else: - if not self._attr_available: - _LOGGER.info( - "Reconnected to %s", - self._name or afsapi.webfsapi_endpoint, - ) + return - self._attr_available = True - if not self._name: - self._name = await afsapi.get_friendly_name() + if not self._attr_available: + _LOGGER.info( + "Reconnected to %s", + self._name or afsapi.webfsapi_endpoint, + ) - if not self._source_list: - self.__modes_by_label = { - mode.label: mode.key for mode in await afsapi.get_modes() - } - self._source_list = list(self.__modes_by_label.keys()) + self._attr_available = True + if not self._name: + self._name = await afsapi.get_friendly_name() - # The API seems to include 'zero' in the number of steps (e.g. if the range is - # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. - # If call to get_volume fails set to 0 and try again next time. - if not self._max_volume: - self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 + if not self._source_list: + self.__modes_by_label = { + mode.label: mode.key for mode in await afsapi.get_modes() + } + self._source_list = list(self.__modes_by_label) - if self._state not in [STATE_OFF, STATE_UNAVAILABLE]: + # The API seems to include 'zero' in the number of steps (e.g. if the range is + # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. + # If call to get_volume fails set to 0 and try again next time. + if not self._max_volume: + self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 + + if self._state != STATE_OFF: info_name = await afsapi.get_play_name() info_text = await afsapi.get_play_text() @@ -269,7 +268,7 @@ class AFSAPIDevice(MediaPlayerEntity): async def async_media_play_pause(self): """Send play/pause command.""" - if "playing" in self._state: + if self._state == STATE_PLAYING: await self.fs_device.pause() else: await self.fs_device.play() From 627d6f7803728cbd188f7a7060d8b2c747a18555 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 May 2022 10:33:34 +0200 Subject: [PATCH 120/947] Ensure description_placeholders is always typed (#72716) --- homeassistant/auth/providers/__init__.py | 2 +- .../components/homewizard/config_flow.py | 8 +++--- .../components/tankerkoenig/config_flow.py | 2 +- homeassistant/config_entries.py | 7 ++++-- homeassistant/data_entry_flow.py | 25 ++++++++++++------- 5 files changed, 27 insertions(+), 17 deletions(-) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py index 63389059051..6feb4b26759 100644 --- a/homeassistant/auth/providers/__init__.py +++ b/homeassistant/auth/providers/__init__.py @@ -272,7 +272,7 @@ class LoginFlow(data_entry_flow.FlowHandler): if not errors: return await self.async_finish(self.credential) - description_placeholders: dict[str, str | None] = { + description_placeholders: dict[str, str] = { "mfa_module_name": auth_module.name, "mfa_module_id": auth_module.id, } diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index df883baf3b1..7d06f08ce74 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -2,7 +2,7 @@ from __future__ import annotations import logging -from typing import Any +from typing import Any, cast from homewizard_energy import HomeWizardEnergy from homewizard_energy.errors import DisabledError, UnsupportedError @@ -160,9 +160,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="discovery_confirm", description_placeholders={ - CONF_PRODUCT_TYPE: self.config[CONF_PRODUCT_TYPE], - CONF_SERIAL: self.config[CONF_SERIAL], - CONF_IP_ADDRESS: self.config[CONF_IP_ADDRESS], + CONF_PRODUCT_TYPE: cast(str, self.config[CONF_PRODUCT_TYPE]), + CONF_SERIAL: cast(str, self.config[CONF_SERIAL]), + CONF_IP_ADDRESS: cast(str, self.config[CONF_IP_ADDRESS]), }, ) diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 345b034b027..dd5893fe35f 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -133,7 +133,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not user_input: return self.async_show_form( step_id="select_station", - description_placeholders={"stations_count": len(self._stations)}, + description_placeholders={"stations_count": str(len(self._stations))}, data_schema=vol.Schema( {vol.Required(CONF_STATIONS): cv.multi_select(self._stations)} ), diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 49b2059b2a2..df633009138 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1403,7 +1403,10 @@ class ConfigFlow(data_entry_flow.FlowHandler): @callback def async_abort( - self, *, reason: str, description_placeholders: dict | None = None + self, + *, + reason: str, + description_placeholders: Mapping[str, str] | None = None, ) -> data_entry_flow.FlowResult: """Abort the config flow.""" # Remove reauth notification if no reauth flows are in progress @@ -1477,7 +1480,7 @@ class ConfigFlow(data_entry_flow.FlowHandler): title: str, data: Mapping[str, Any], description: str | None = None, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, options: Mapping[str, Any] | None = None, ) -> data_entry_flow.FlowResult: """Finish config flow and create a config entry.""" diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 628a89dd89b..714cea07044 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -52,7 +52,7 @@ class AbortFlow(FlowError): """Exception to indicate a flow needs to be aborted.""" def __init__( - self, reason: str, description_placeholders: dict | None = None + self, reason: str, description_placeholders: Mapping[str, str] | None = None ) -> None: """Initialize an abort flow exception.""" super().__init__(f"Flow aborted: {reason}") @@ -75,7 +75,7 @@ class FlowResult(TypedDict, total=False): required: bool errors: dict[str, str] | None description: str | None - description_placeholders: dict[str, Any] | None + description_placeholders: Mapping[str, str | None] | None progress_action: str url: str reason: str @@ -422,7 +422,7 @@ class FlowHandler: step_id: str, data_schema: vol.Schema | None = None, errors: dict[str, str] | None = None, - description_placeholders: dict[str, Any] | None = None, + description_placeholders: Mapping[str, str | None] | None = None, last_step: bool | None = None, ) -> FlowResult: """Return the definition of a form to gather user input.""" @@ -444,7 +444,7 @@ class FlowHandler: title: str, data: Mapping[str, Any], description: str | None = None, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Finish config flow and create a config entry.""" return { @@ -460,7 +460,10 @@ class FlowHandler: @callback def async_abort( - self, *, reason: str, description_placeholders: dict | None = None + self, + *, + reason: str, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Abort the config flow.""" return _create_abort_data( @@ -469,7 +472,11 @@ class FlowHandler: @callback def async_external_step( - self, *, step_id: str, url: str, description_placeholders: dict | None = None + self, + *, + step_id: str, + url: str, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Return the definition of an external step for the user to take.""" return { @@ -497,7 +504,7 @@ class FlowHandler: *, step_id: str, progress_action: str, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Show a progress message to the user, without user input allowed.""" return { @@ -525,7 +532,7 @@ class FlowHandler: *, step_id: str, menu_options: list[str] | dict[str, str], - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Show a navigation menu to the user. @@ -547,7 +554,7 @@ def _create_abort_data( flow_id: str, handler: str, reason: str, - description_placeholders: dict | None = None, + description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Return the definition of an external step for the user to take.""" return { From ca5f13b576b8424cf66c59a0c9481576d9be1a34 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 May 2022 10:40:08 +0200 Subject: [PATCH 121/947] Allow removing a onewire device (#72710) --- homeassistant/components/onewire/__init__.py | 11 ++++ tests/components/onewire/__init__.py | 7 ++- tests/components/onewire/test_init.py | 66 +++++++++++++++++++- 3 files changed, 80 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index c6f3d7dfa3f..e908a52a071 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -6,6 +6,7 @@ from pyownet import protocol from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from .const import DOMAIN, PLATFORMS from .onewirehub import CannotConnect, OneWireHub @@ -35,6 +36,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a config entry from a device.""" + onewirehub: OneWireHub = hass.data[DOMAIN][config_entry.entry_id] + return not device_entry.identifiers.intersection( + (DOMAIN, device.id) for device in onewirehub.devices or [] + ) + + async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload a config entry.""" unload_ok = await hass.config_entries.async_unload_platforms( diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index d189db8af1a..c916c777248 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( ATTR_NAME, ATTR_STATE, ATTR_VIA_DEVICE, + Platform, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry @@ -90,7 +91,7 @@ def check_entities( def setup_owproxy_mock_devices( - owproxy: MagicMock, platform: str, device_ids: list[str] + owproxy: MagicMock, platform: Platform, device_ids: list[str] ) -> None: """Set up mock for owproxy.""" main_dir_return_value = [] @@ -125,7 +126,7 @@ def _setup_owproxy_mock_device( main_read_side_effect: list, sub_read_side_effect: list, device_id: str, - platform: str, + platform: Platform, ) -> None: """Set up mock for owproxy.""" mock_device = MOCK_OWPROXY_DEVICES[device_id] @@ -167,7 +168,7 @@ def _setup_owproxy_mock_device_reads( sub_read_side_effect: list, mock_device: Any, device_id: str, - platform: str, + platform: Platform, ) -> None: """Set up mock for owproxy.""" # Setup device reads diff --git a/tests/components/onewire/test_init.py b/tests/components/onewire/test_init.py index fecade521a8..bc09432ea5c 100644 --- a/tests/components/onewire/test_init.py +++ b/tests/components/onewire/test_init.py @@ -1,12 +1,35 @@ """Tests for 1-Wire config flow.""" -from unittest.mock import MagicMock +from collections.abc import Awaitable, Callable +from unittest.mock import MagicMock, patch +import aiohttp from pyownet import protocol import pytest from homeassistant.components.onewire.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from . import setup_owproxy_mock_devices + + +async def remove_device( + ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str +) -> bool: + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 1, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] @pytest.mark.usefixtures("owproxy_with_connerror") @@ -48,3 +71,44 @@ async def test_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): assert config_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) + + +@patch("homeassistant.components.onewire.PLATFORMS", [Platform.SENSOR]) +async def test_registry_cleanup( + hass: HomeAssistant, + config_entry: ConfigEntry, + owproxy: MagicMock, + hass_ws_client: Callable[ + [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] + ], +): + """Test being able to remove a disconnected device.""" + assert await async_setup_component(hass, "config", {}) + + entry_id = config_entry.entry_id + device_registry = dr.async_get(hass) + live_id = "10.111111111111" + dead_id = "28.111111111111" + + # Initialise with two components + setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id, dead_id]) + await hass.config_entries.async_setup(entry_id) + await hass.async_block_till_done() + + # Reload with a device no longer on bus + setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [live_id]) + await hass.config_entries.async_reload(entry_id) + await hass.async_block_till_done() + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + + # Try to remove "10.111111111111" - fails as it is live + device = device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) + assert await remove_device(await hass_ws_client(hass), device.id, entry_id) is False + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 2 + assert device_registry.async_get_device(identifiers={(DOMAIN, live_id)}) is not None + + # Try to remove "28.111111111111" - succeeds as it is dead + device = device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) + assert await remove_device(await hass_ws_client(hass), device.id, entry_id) is True + assert len(dr.async_entries_for_config_entry(device_registry, entry_id)) == 1 + assert device_registry.async_get_device(identifiers={(DOMAIN, dead_id)}) is None From 5dc4c89acc32eecfb5be37b7e9a8b15eb50fe010 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 30 May 2022 22:41:33 -1000 Subject: [PATCH 122/947] Small performance improvement for matching logbook rows (#72750) --- homeassistant/components/logbook/processor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index ea6002cc62c..b3a43c2ca35 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -407,7 +407,8 @@ class ContextAugmenter: def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool: """Check of rows match by using the same method as Events __hash__.""" if ( - (state_id := row.state_id) is not None + row is other_row + or (state_id := row.state_id) is not None and state_id == other_row.state_id or (event_id := row.event_id) is not None and event_id == other_row.event_id From cf17169b0ebf4c7a069a8a93b62a2a67786898c5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 May 2022 11:20:31 +0200 Subject: [PATCH 123/947] Refactor type-hint pylint plugin (#72692) * Cleanup unused variable * Adjust tests * Refactor _METHOD_MATCH dict * Remove duplicate function * Early exit * Undo object hint * METHOD > FUNCTION * Add comment * Remove extend * Remove break * Extract __any_platform__ * Add tests * Cache _PLATFORMS * Adjust tests * Review comments * mypy * shorthand --- pylint/plugins/hass_enforce_type_hints.py | 749 +++++++++++----------- tests/pylint/test_enforce_type_hints.py | 57 +- 2 files changed, 405 insertions(+), 401 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index e230c27b4ee..24b32f2e238 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -13,18 +13,19 @@ from homeassistant.const import Platform UNDEFINED = object() +_PLATFORMS: set[str] = {platform.value for platform in Platform} + @dataclass class TypeHintMatch: """Class for pattern matching.""" - module_filter: re.Pattern function_name: str arg_types: dict[int, str] - return_type: list[str] | str | None + return_type: list[str] | str | None | object -_TYPE_HINT_MATCHERS: dict[str, re.Pattern] = { +_TYPE_HINT_MATCHERS: dict[str, re.Pattern[str]] = { # a_or_b matches items such as "DiscoveryInfoType | None" "a_or_b": re.compile(r"^(\w+) \| (\w+)$"), # x_of_y matches items such as "Awaitable[None]" @@ -35,373 +36,333 @@ _TYPE_HINT_MATCHERS: dict[str, re.Pattern] = { "x_of_y_of_z_comma_a": re.compile(r"^(\w+)\[(\w+)\[(.*?]*), (.*?]*)\]\]$"), } -_MODULE_FILTERS: dict[str, re.Pattern] = { - # init matches only in the package root (__init__.py) - "init": re.compile(r"^homeassistant\.components\.\w+$"), - # any_platform matches any platform in the package root ({platform}.py) - "any_platform": re.compile( - f"^homeassistant\\.components\\.\\w+\\.({'|'.join([platform.value for platform in Platform])})$" - ), - # application_credentials matches only in the package root (application_credentials.py) - "application_credentials": re.compile( - r"^homeassistant\.components\.\w+\.(application_credentials)$" - ), - # backup matches only in the package root (backup.py) - "backup": re.compile(r"^homeassistant\.components\.\w+\.(backup)$"), - # cast matches only in the package root (cast.py) - "cast": re.compile(r"^homeassistant\.components\.\w+\.(cast)$"), - # config_flow matches only in the package root (config_flow.py) - "config_flow": re.compile(r"^homeassistant\.components\.\w+\.(config_flow)$"), - # device_action matches only in the package root (device_action.py) - "device_action": re.compile(r"^homeassistant\.components\.\w+\.(device_action)$"), - # device_condition matches only in the package root (device_condition.py) - "device_condition": re.compile( - r"^homeassistant\.components\.\w+\.(device_condition)$" - ), - # device_tracker matches only in the package root (device_tracker.py) - "device_tracker": re.compile(r"^homeassistant\.components\.\w+\.(device_tracker)$"), - # device_trigger matches only in the package root (device_trigger.py) - "device_trigger": re.compile(r"^homeassistant\.components\.\w+\.(device_trigger)$"), - # diagnostics matches only in the package root (diagnostics.py) - "diagnostics": re.compile(r"^homeassistant\.components\.\w+\.(diagnostics)$"), +_MODULE_REGEX: re.Pattern[str] = re.compile(r"^homeassistant\.components\.\w+(\.\w+)?$") + +_FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { + "__init__": [ + TypeHintMatch( + function_name="setup", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_setup", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_setup_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_remove_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_unload_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_migrate_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type="bool", + ), + ], + "__any_platform__": [ + TypeHintMatch( + function_name="setup_platform", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "AddEntitiesCallback", + 3: "DiscoveryInfoType | None", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_setup_platform", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "AddEntitiesCallback", + 3: "DiscoveryInfoType | None", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_setup_entry", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + 2: "AddEntitiesCallback", + }, + return_type=None, + ), + ], + "application_credentials": [ + TypeHintMatch( + function_name="async_get_auth_implementation", + arg_types={ + 0: "HomeAssistant", + 1: "str", + 2: "ClientCredential", + }, + return_type="AbstractOAuth2Implementation", + ), + TypeHintMatch( + function_name="async_get_authorization_server", + arg_types={ + 0: "HomeAssistant", + }, + return_type="AuthorizationServer", + ), + ], + "backup": [ + TypeHintMatch( + function_name="async_pre_backup", + arg_types={ + 0: "HomeAssistant", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_post_backup", + arg_types={ + 0: "HomeAssistant", + }, + return_type=None, + ), + ], + "cast": [ + TypeHintMatch( + function_name="async_get_media_browser_root_object", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type="list[BrowseMedia]", + ), + TypeHintMatch( + function_name="async_browse_media", + arg_types={ + 0: "HomeAssistant", + 1: "str", + 2: "str", + 3: "str", + }, + return_type=["BrowseMedia", "BrowseMedia | None"], + ), + TypeHintMatch( + function_name="async_play_media", + arg_types={ + 0: "HomeAssistant", + 1: "str", + 2: "Chromecast", + 3: "str", + 4: "str", + }, + return_type="bool", + ), + ], + "config_flow": [ + TypeHintMatch( + function_name="_async_has_devices", + arg_types={ + 0: "HomeAssistant", + }, + return_type="bool", + ), + ], + "device_action": [ + TypeHintMatch( + function_name="async_validate_action_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConfigType", + ), + TypeHintMatch( + function_name="async_call_action_from_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "TemplateVarsType", + 3: "Context | None", + }, + return_type=None, + ), + TypeHintMatch( + function_name="async_get_action_capabilities", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="dict[str, Schema]", + ), + TypeHintMatch( + function_name="async_get_actions", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + ), + ], + "device_condition": [ + TypeHintMatch( + function_name="async_validate_condition_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConfigType", + ), + TypeHintMatch( + function_name="async_condition_from_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConditionCheckerType", + ), + TypeHintMatch( + function_name="async_get_condition_capabilities", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="dict[str, Schema]", + ), + TypeHintMatch( + function_name="async_get_conditions", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + ), + ], + "device_tracker": [ + TypeHintMatch( + function_name="setup_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "Callable[..., None]", + 3: "DiscoveryInfoType | None", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="async_setup_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "Callable[..., Awaitable[None]]", + 3: "DiscoveryInfoType | None", + }, + return_type="bool", + ), + TypeHintMatch( + function_name="get_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type=["DeviceScanner", "DeviceScanner | None"], + ), + TypeHintMatch( + function_name="async_get_scanner", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type=["DeviceScanner", "DeviceScanner | None"], + ), + ], + "device_trigger": [ + TypeHintMatch( + function_name="async_validate_condition_config", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="ConfigType", + ), + TypeHintMatch( + function_name="async_attach_trigger", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + 2: "AutomationActionType", + 3: "AutomationTriggerInfo", + }, + return_type="CALLBACK_TYPE", + ), + TypeHintMatch( + function_name="async_get_trigger_capabilities", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigType", + }, + return_type="dict[str, Schema]", + ), + TypeHintMatch( + function_name="async_get_triggers", + arg_types={ + 0: "HomeAssistant", + 1: "str", + }, + return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], + ), + ], + "diagnostics": [ + TypeHintMatch( + function_name="async_get_config_entry_diagnostics", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + }, + return_type=UNDEFINED, + ), + TypeHintMatch( + function_name="async_get_device_diagnostics", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + 2: "DeviceEntry", + }, + return_type=UNDEFINED, + ), + ], } -_METHOD_MATCH: list[TypeHintMatch] = [ - TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="setup", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_setup", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_setup_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_remove_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_unload_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["init"], - function_name="async_migrate_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["any_platform"], - function_name="setup_platform", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "AddEntitiesCallback", - 3: "DiscoveryInfoType | None", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["any_platform"], - function_name="async_setup_platform", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "AddEntitiesCallback", - 3: "DiscoveryInfoType | None", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["any_platform"], - function_name="async_setup_entry", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - 2: "AddEntitiesCallback", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["application_credentials"], - function_name="async_get_auth_implementation", - arg_types={ - 0: "HomeAssistant", - 1: "str", - 2: "ClientCredential", - }, - return_type="AbstractOAuth2Implementation", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["application_credentials"], - function_name="async_get_authorization_server", - arg_types={ - 0: "HomeAssistant", - }, - return_type="AuthorizationServer", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["backup"], - function_name="async_pre_backup", - arg_types={ - 0: "HomeAssistant", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["backup"], - function_name="async_post_backup", - arg_types={ - 0: "HomeAssistant", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["cast"], - function_name="async_get_media_browser_root_object", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type="list[BrowseMedia]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["cast"], - function_name="async_browse_media", - arg_types={ - 0: "HomeAssistant", - 1: "str", - 2: "str", - 3: "str", - }, - return_type=["BrowseMedia", "BrowseMedia | None"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["cast"], - function_name="async_play_media", - arg_types={ - 0: "HomeAssistant", - 1: "str", - 2: "Chromecast", - 3: "str", - 4: "str", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["config_flow"], - function_name="_async_has_devices", - arg_types={ - 0: "HomeAssistant", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_validate_action_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConfigType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_call_action_from_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "TemplateVarsType", - 3: "Context | None", - }, - return_type=None, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_get_action_capabilities", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="dict[str, Schema]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_action"], - function_name="async_get_actions", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_validate_condition_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConfigType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_condition_from_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConditionCheckerType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_get_condition_capabilities", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="dict[str, Schema]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_condition"], - function_name="async_get_conditions", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="setup_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "Callable[..., None]", - 3: "DiscoveryInfoType | None", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="async_setup_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "Callable[..., Awaitable[None]]", - 3: "DiscoveryInfoType | None", - }, - return_type="bool", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="get_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type=["DeviceScanner", "DeviceScanner | None"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_tracker"], - function_name="async_get_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type=["DeviceScanner", "DeviceScanner | None"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_validate_condition_config", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="ConfigType", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_attach_trigger", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "AutomationActionType", - 3: "AutomationTriggerInfo", - }, - return_type="CALLBACK_TYPE", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_get_trigger_capabilities", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="dict[str, Schema]", - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["device_trigger"], - function_name="async_get_triggers", - arg_types={ - 0: "HomeAssistant", - 1: "str", - }, - return_type=["list[dict[str, str]]", "list[dict[str, Any]]"], - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["diagnostics"], - function_name="async_get_config_entry_diagnostics", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - }, - return_type=UNDEFINED, - ), - TypeHintMatch( - module_filter=_MODULE_FILTERS["diagnostics"], - function_name="async_get_device_diagnostics", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigEntry", - 2: "DeviceEntry", - }, - return_type=UNDEFINED, - ), -] - -def _is_valid_type(expected_type: list[str] | str | None, node: astroid.NodeNG) -> bool: +def _is_valid_type( + expected_type: list[str] | str | None | object, node: astroid.NodeNG +) -> bool: """Check the argument node against the expected type.""" if expected_type is UNDEFINED: return True @@ -416,6 +377,8 @@ def _is_valid_type(expected_type: list[str] | str | None, node: astroid.NodeNG) if expected_type is None or expected_type == "None": return isinstance(node, astroid.Const) and node.value is None + assert isinstance(expected_type, str) + # Const occurs when the type is an Ellipsis if expected_type == "...": return isinstance(node, astroid.Const) and node.value == Ellipsis @@ -487,6 +450,17 @@ def _has_valid_annotations( return False +def _get_module_platform(module_name: str) -> str | None: + """Called when a Module node is visited.""" + if not (module_match := _MODULE_REGEX.match(module_name)): + # Ensure `homeassistant.components.` + # Or `homeassistant.components..` + return None + + platform = module_match.groups()[0] + return platform.lstrip(".") if platform else "__init__" + + class HassTypeHintChecker(BaseChecker): # type: ignore[misc] """Checker for setup type hints.""" @@ -510,38 +484,31 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] def __init__(self, linter: PyLinter | None = None) -> None: super().__init__(linter) - self.current_package: str | None = None - self.module: str | None = None + self._function_matchers: list[TypeHintMatch] = [] def visit_module(self, node: astroid.Module) -> None: """Called when a Module node is visited.""" - self.module = node.name - if node.package: - self.current_package = node.name - else: - # Strip name of the current module - self.current_package = node.name[: node.name.rfind(".")] + self._function_matchers = [] + + if (module_platform := _get_module_platform(node.name)) is None: + return + + if module_platform in _PLATFORMS: + self._function_matchers.extend(_FUNCTION_MATCH["__any_platform__"]) + + if matches := _FUNCTION_MATCH.get(module_platform): + self._function_matchers.extend(matches) def visit_functiondef(self, node: astroid.FunctionDef) -> None: """Called when a FunctionDef node is visited.""" - for match in _METHOD_MATCH: - self._visit_functiondef(node, match) + for match in self._function_matchers: + if node.name != match.function_name or node.is_method(): + continue + self._check_function(node, match) - def visit_asyncfunctiondef(self, node: astroid.AsyncFunctionDef) -> None: - """Called when an AsyncFunctionDef node is visited.""" - for match in _METHOD_MATCH: - self._visit_functiondef(node, match) - - def _visit_functiondef( - self, node: astroid.FunctionDef, match: TypeHintMatch - ) -> None: - if node.name != match.function_name: - return - if node.is_method(): - return - if not match.module_filter.match(self.module): - return + visit_asyncfunctiondef = visit_functiondef + def _check_function(self, node: astroid.FunctionDef, match: TypeHintMatch) -> None: # Check that at least one argument is annotated. annotations = _get_all_annotations(node) if node.returns is None and not _has_valid_annotations(annotations): diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index feb3b6b341c..0bd273985e3 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -1,5 +1,6 @@ """Tests for pylint hass_enforce_type_hints plugin.""" # pylint:disable=protected-access +from __future__ import annotations import re from types import ModuleType @@ -14,6 +15,30 @@ import pytest from . import assert_adds_messages, assert_no_messages +@pytest.mark.parametrize( + ("module_name", "expected_platform", "in_platforms"), + [ + ("homeassistant", None, False), + ("homeassistant.components", None, False), + ("homeassistant.components.pylint_test", "__init__", False), + ("homeassistant.components.pylint_test.config_flow", "config_flow", False), + ("homeassistant.components.pylint_test.light", "light", True), + ("homeassistant.components.pylint_test.light.v1", None, False), + ], +) +def test_regex_get_module_platform( + hass_enforce_type_hints: ModuleType, + module_name: str, + expected_platform: str | None, + in_platforms: bool, +) -> None: + """Test _get_module_platform regex.""" + platform = hass_enforce_type_hints._get_module_platform(module_name) + + assert platform == expected_platform + assert (platform in hass_enforce_type_hints._PLATFORMS) == in_platforms + + @pytest.mark.parametrize( ("string", "expected_x", "expected_y", "expected_z", "expected_a"), [ @@ -95,7 +120,11 @@ def test_ignore_not_annotations( hass_enforce_type_hints: ModuleType, type_hint_checker: BaseChecker, code: str ) -> None: """Ensure that _is_valid_type is not run if there are no annotations.""" - func_node = astroid.extract_node(code) + func_node = astroid.extract_node( + code, + "homeassistant.components.pylint_test", + ) + type_hint_checker.visit_module(func_node.parent) with patch.object( hass_enforce_type_hints, "_is_valid_type", return_value=True @@ -131,7 +160,11 @@ def test_dont_ignore_partial_annotations( hass_enforce_type_hints: ModuleType, type_hint_checker: BaseChecker, code: str ) -> None: """Ensure that _is_valid_type is run if there is at least one annotation.""" - func_node = astroid.extract_node(code) + func_node = astroid.extract_node( + code, + "homeassistant.components.pylint_test", + ) + type_hint_checker.visit_module(func_node.parent) with patch.object( hass_enforce_type_hints, "_is_valid_type", return_value=True @@ -144,7 +177,6 @@ def test_invalid_discovery_info( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure invalid hints are rejected for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_tracker" func_node, discovery_info_node = astroid.extract_node( """ async def async_setup_scanner( #@ @@ -154,8 +186,10 @@ def test_invalid_discovery_info( discovery_info: dict[str, Any] | None = None, #@ ) -> bool: pass - """ + """, + "homeassistant.components.pylint_test.device_tracker", ) + type_hint_checker.visit_module(func_node.parent) with assert_adds_messages( linter, @@ -176,7 +210,6 @@ def test_valid_discovery_info( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure valid hints are accepted for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_tracker" func_node = astroid.extract_node( """ async def async_setup_scanner( #@ @@ -186,8 +219,10 @@ def test_valid_discovery_info( discovery_info: DiscoveryInfoType | None = None, ) -> bool: pass - """ + """, + "homeassistant.components.pylint_test.device_tracker", ) + type_hint_checker.visit_module(func_node.parent) with assert_no_messages(linter): type_hint_checker.visit_asyncfunctiondef(func_node) @@ -197,7 +232,6 @@ def test_invalid_list_dict_str_any( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure invalid hints are rejected for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_trigger" func_node = astroid.extract_node( """ async def async_get_triggers( #@ @@ -205,8 +239,10 @@ def test_invalid_list_dict_str_any( device_id: str ) -> list: pass - """ + """, + "homeassistant.components.pylint_test.device_trigger", ) + type_hint_checker.visit_module(func_node.parent) with assert_adds_messages( linter, @@ -227,7 +263,6 @@ def test_valid_list_dict_str_any( linter: UnittestLinter, type_hint_checker: BaseChecker ) -> None: """Ensure valid hints are accepted for discovery_info.""" - type_hint_checker.module = "homeassistant.components.pylint_test.device_trigger" func_node = astroid.extract_node( """ async def async_get_triggers( #@ @@ -235,8 +270,10 @@ def test_valid_list_dict_str_any( device_id: str ) -> list[dict[str, Any]]: pass - """ + """, + "homeassistant.components.pylint_test.device_trigger", ) + type_hint_checker.visit_module(func_node.parent) with assert_no_messages(linter): type_hint_checker.visit_asyncfunctiondef(func_node) From 3ea304aaf10fb0d288aa9a56955c961442b5c388 Mon Sep 17 00:00:00 2001 From: Thijs W Date: Tue, 31 May 2022 11:56:44 +0200 Subject: [PATCH 124/947] Improve frontier_silicon style (#72752) --- .../frontier_silicon/media_player.py | 121 +++++------------- 1 file changed, 29 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index e4a67fa5cc4..555e0517d4c 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -84,6 +84,8 @@ async def async_setup_platform( class AFSAPIDevice(MediaPlayerEntity): """Representation of a Frontier Silicon device on the network.""" + _attr_media_content_type: str = MEDIA_TYPE_MUSIC + _attr_supported_features = ( MediaPlayerEntityFeature.PAUSE | MediaPlayerEntityFeature.VOLUME_SET @@ -108,80 +110,19 @@ class AFSAPIDevice(MediaPlayerEntity): identifiers={(DOMAIN, afsapi.webfsapi_endpoint)}, name=name, ) + self._attr_name = name - self._state = None - - self._name = name - self._title = None - self._artist = None - self._album_name = None - self._mute = None - self._source = None - self._source_list = None - self._media_image_url = None self._max_volume = None - self._volume_level = None self.__modes_by_label = None - @property - def name(self): - """Return the device name.""" - return self._name - - @property - def media_title(self): - """Title of current playing media.""" - return self._title - - @property - def media_artist(self): - """Artist of current playing media, music track only.""" - return self._artist - - @property - def media_album_name(self): - """Album name of current playing media, music track only.""" - return self._album_name - - @property - def media_content_type(self): - """Content type of current playing media.""" - return MEDIA_TYPE_MUSIC - - @property - def state(self): - """Return the state of the player.""" - return self._state - - # source - @property - def source_list(self): - """List of available input sources.""" - return self._source_list - - @property - def source(self): - """Name of the current input source.""" - return self._source - - @property - def media_image_url(self): - """Image url of current playing media.""" - return self._media_image_url - - @property - def volume_level(self): - """Volume level of the media player (0..1).""" - return self._volume_level - async def async_update(self): """Get the latest date and update device state.""" afsapi = self.fs_device try: if await afsapi.get_power(): status = await afsapi.get_play_status() - self._state = { + self._attr_state = { PlayState.PLAYING: STATE_PLAYING, PlayState.PAUSED: STATE_PAUSED, PlayState.STOPPED: STATE_IDLE, @@ -189,12 +130,12 @@ class AFSAPIDevice(MediaPlayerEntity): None: STATE_IDLE, }.get(status) else: - self._state = STATE_OFF + self._attr_state = STATE_OFF except FSConnectionError: if self._attr_available: _LOGGER.warning( "Could not connect to %s. Did it go offline?", - self._name or afsapi.webfsapi_endpoint, + self.name or afsapi.webfsapi_endpoint, ) self._attr_available = False return @@ -202,18 +143,18 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._attr_available: _LOGGER.info( "Reconnected to %s", - self._name or afsapi.webfsapi_endpoint, + self.name or afsapi.webfsapi_endpoint, ) self._attr_available = True - if not self._name: - self._name = await afsapi.get_friendly_name() + if not self._attr_name: + self._attr_name = await afsapi.get_friendly_name() - if not self._source_list: + if not self._attr_source_list: self.__modes_by_label = { mode.label: mode.key for mode in await afsapi.get_modes() } - self._source_list = list(self.__modes_by_label) + self._attr_source_list = list(self.__modes_by_label) # The API seems to include 'zero' in the number of steps (e.g. if the range is # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. @@ -221,32 +162,34 @@ class AFSAPIDevice(MediaPlayerEntity): if not self._max_volume: self._max_volume = int(await afsapi.get_volume_steps() or 1) - 1 - if self._state != STATE_OFF: + if self._attr_state != STATE_OFF: info_name = await afsapi.get_play_name() info_text = await afsapi.get_play_text() - self._title = " - ".join(filter(None, [info_name, info_text])) - self._artist = await afsapi.get_play_artist() - self._album_name = await afsapi.get_play_album() + self._attr_media_title = " - ".join(filter(None, [info_name, info_text])) + self._attr_media_artist = await afsapi.get_play_artist() + self._attr_media_album_name = await afsapi.get_play_album() - self._source = (await afsapi.get_mode()).label - self._mute = await afsapi.get_mute() - self._media_image_url = await afsapi.get_play_graphic() + self._attr_source = (await afsapi.get_mode()).label + + self._attr_is_volume_muted = await afsapi.get_mute() + self._attr_media_image_url = await afsapi.get_play_graphic() volume = await self.fs_device.get_volume() # Prevent division by zero if max_volume not known yet - self._volume_level = float(volume or 0) / (self._max_volume or 1) + self._attr_volume_level = float(volume or 0) / (self._max_volume or 1) else: - self._title = None - self._artist = None - self._album_name = None + self._attr_media_title = None + self._attr_media_artist = None + self._attr_media_album_name = None - self._source = None - self._mute = None - self._media_image_url = None + self._attr_source = None - self._volume_level = None + self._attr_is_volume_muted = None + self._attr_media_image_url = None + + self._attr_volume_level = None # Management actions # power control @@ -268,7 +211,7 @@ class AFSAPIDevice(MediaPlayerEntity): async def async_media_play_pause(self): """Send play/pause command.""" - if self._state == STATE_PLAYING: + if self._attr_state == STATE_PLAYING: await self.fs_device.pause() else: await self.fs_device.play() @@ -285,12 +228,6 @@ class AFSAPIDevice(MediaPlayerEntity): """Send next track command (results in fast-forward).""" await self.fs_device.forward() - # mute - @property - def is_volume_muted(self): - """Boolean if volume is currently muted.""" - return self._mute - async def async_mute_volume(self, mute): """Send mute command.""" await self.fs_device.set_mute(mute) From cf27b82d2fdce406fda3b1b9cd52d42d7f7d00d6 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 May 2022 12:26:15 +0200 Subject: [PATCH 125/947] Separate words with underscore in onewire (#72758) --- homeassistant/components/onewire/__init__.py | 10 +++++----- .../components/onewire/binary_sensor.py | 12 ++++++------ homeassistant/components/onewire/diagnostics.py | 6 +++--- homeassistant/components/onewire/sensor.py | 16 ++++++++-------- homeassistant/components/onewire/switch.py | 12 ++++++------ 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py index e908a52a071..b836d7e3298 100644 --- a/homeassistant/components/onewire/__init__.py +++ b/homeassistant/components/onewire/__init__.py @@ -18,16 +18,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a 1-Wire proxy for a config entry.""" hass.data.setdefault(DOMAIN, {}) - onewirehub = OneWireHub(hass) + onewire_hub = OneWireHub(hass) try: - await onewirehub.initialize(entry) + await onewire_hub.initialize(entry) except ( CannotConnect, # Failed to connect to the server protocol.OwnetError, # Connected to server, but failed to list the devices ) as exc: raise ConfigEntryNotReady() from exc - hass.data[DOMAIN][entry.entry_id] = onewirehub + hass.data[DOMAIN][entry.entry_id] = onewire_hub hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -40,9 +40,9 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove a config entry from a device.""" - onewirehub: OneWireHub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub: OneWireHub = hass.data[DOMAIN][config_entry.entry_id] return not device_entry.identifiers.intersection( - (DOMAIN, device.id) for device in onewirehub.devices or [] + (DOMAIN, device.id) for device in onewire_hub.devices or [] ) diff --git a/homeassistant/components/onewire/binary_sensor.py b/homeassistant/components/onewire/binary_sensor.py index 307f38ceea0..4d6baef353e 100644 --- a/homeassistant/components/onewire/binary_sensor.py +++ b/homeassistant/components/onewire/binary_sensor.py @@ -94,19 +94,19 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewirehub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub = hass.data[DOMAIN][config_entry.entry_id] - entities = await hass.async_add_executor_job(get_entities, onewirehub) + entities = await hass.async_add_executor_job(get_entities, onewire_hub) async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireBinarySensor]: +def get_entities(onewire_hub: OneWireHub) -> list[OneWireBinarySensor]: """Get a list of entities.""" - if not onewirehub.devices: + if not onewire_hub.devices: return [] entities: list[OneWireBinarySensor] = [] - for device in onewirehub.devices: + for device in onewire_hub.devices: family = device.family device_id = device.id device_type = device.type @@ -128,7 +128,7 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireBinarySensor]: device_file=device_file, device_info=device_info, name=name, - owproxy=onewirehub.owproxy, + owproxy=onewire_hub.owproxy, ) ) diff --git a/homeassistant/components/onewire/diagnostics.py b/homeassistant/components/onewire/diagnostics.py index a02ff2d8e47..36db7fd5360 100644 --- a/homeassistant/components/onewire/diagnostics.py +++ b/homeassistant/components/onewire/diagnostics.py @@ -19,7 +19,7 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - onewirehub: OneWireHub = hass.data[DOMAIN][entry.entry_id] + onewire_hub: OneWireHub = hass.data[DOMAIN][entry.entry_id] return { "entry": { @@ -27,7 +27,7 @@ async def async_get_config_entry_diagnostics( "data": async_redact_data(entry.data, TO_REDACT), "options": {**entry.options}, }, - "devices": [asdict(device_details) for device_details in onewirehub.devices] - if onewirehub.devices + "devices": [asdict(device_details) for device_details in onewire_hub.devices] + if onewire_hub.devices else [], } diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 9f376a6df7a..f6c88201dc7 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -367,23 +367,23 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewirehub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub = hass.data[DOMAIN][config_entry.entry_id] entities = await hass.async_add_executor_job( - get_entities, onewirehub, config_entry.options + get_entities, onewire_hub, config_entry.options ) async_add_entities(entities, True) def get_entities( - onewirehub: OneWireHub, options: MappingProxyType[str, Any] + onewire_hub: OneWireHub, options: MappingProxyType[str, Any] ) -> list[OneWireSensor]: """Get a list of entities.""" - if not onewirehub.devices: + if not onewire_hub.devices: return [] entities: list[OneWireSensor] = [] - assert onewirehub.owproxy - for device in onewirehub.devices: + assert onewire_hub.owproxy + for device in onewire_hub.devices: family = device.family device_type = device.type device_id = device.id @@ -403,7 +403,7 @@ def get_entities( if description.key.startswith("moisture/"): s_id = description.key.split(".")[1] is_leaf = int( - onewirehub.owproxy.read( + onewire_hub.owproxy.read( f"{device_path}moisture/is_leaf.{s_id}" ).decode() ) @@ -427,7 +427,7 @@ def get_entities( device_file=device_file, device_info=device_info, name=name, - owproxy=onewirehub.owproxy, + owproxy=onewire_hub.owproxy, ) ) return entities diff --git a/homeassistant/components/onewire/switch.py b/homeassistant/components/onewire/switch.py index 764cc403681..8a6e6ff3736 100644 --- a/homeassistant/components/onewire/switch.py +++ b/homeassistant/components/onewire/switch.py @@ -150,20 +150,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up 1-Wire platform.""" - onewirehub = hass.data[DOMAIN][config_entry.entry_id] + onewire_hub = hass.data[DOMAIN][config_entry.entry_id] - entities = await hass.async_add_executor_job(get_entities, onewirehub) + entities = await hass.async_add_executor_job(get_entities, onewire_hub) async_add_entities(entities, True) -def get_entities(onewirehub: OneWireHub) -> list[OneWireSwitch]: +def get_entities(onewire_hub: OneWireHub) -> list[OneWireSwitch]: """Get a list of entities.""" - if not onewirehub.devices: + if not onewire_hub.devices: return [] entities: list[OneWireSwitch] = [] - for device in onewirehub.devices: + for device in onewire_hub.devices: family = device.family device_type = device.type device_id = device.id @@ -185,7 +185,7 @@ def get_entities(onewirehub: OneWireHub) -> list[OneWireSwitch]: device_file=device_file, device_info=device_info, name=name, - owproxy=onewirehub.owproxy, + owproxy=onewire_hub.owproxy, ) ) From 8140ed724c6998164e0ff43197c25d97a123df64 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 31 May 2022 15:22:31 +0200 Subject: [PATCH 126/947] Remove mysensors yaml (#72761) --- .../components/mysensors/__init__.py | 150 +------- .../components/mysensors/config_flow.py | 46 +-- homeassistant/components/mysensors/const.py | 3 - tests/components/mysensors/conftest.py | 7 +- .../components/mysensors/test_config_flow.py | 189 ++++------ tests/components/mysensors/test_init.py | 343 +----------------- 6 files changed, 87 insertions(+), 651 deletions(-) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index d392624bbe4..3313a70808c 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -7,12 +7,9 @@ from functools import partial import logging from mysensors import BaseAsyncGateway -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.components.mqtt import valid_publish_topic, valid_subscribe_topic from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_OPTIMISTIC, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntry @@ -22,17 +19,6 @@ from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DEVICES, - CONF_BAUD_RATE, - CONF_DEVICE, - CONF_GATEWAYS, - CONF_NODES, - CONF_PERSISTENCE, - CONF_PERSISTENCE_FILE, - CONF_RETAIN, - CONF_TCP_PORT, - CONF_TOPIC_IN_PREFIX, - CONF_TOPIC_OUT_PREFIX, - CONF_VERSION, DOMAIN, MYSENSORS_DISCOVERY, MYSENSORS_GATEWAYS, @@ -48,147 +34,17 @@ from .helpers import on_unload _LOGGER = logging.getLogger(__name__) -CONF_DEBUG = "debug" -CONF_NODE_NAME = "name" - DATA_HASS_CONFIG = "hass_config" -DEFAULT_BAUD_RATE = 115200 -DEFAULT_TCP_PORT = 5003 -DEFAULT_VERSION = "1.4" - -def set_default_persistence_file(value: dict) -> dict: - """Set default persistence file.""" - for idx, gateway in enumerate(value): - if gateway.get(CONF_PERSISTENCE_FILE) is not None: - continue - new_name = f"mysensors{idx + 1}.pickle" - gateway[CONF_PERSISTENCE_FILE] = new_name - - return value - - -def has_all_unique_files(value: list[dict]) -> list[dict]: - """Validate that all persistence files are unique and set if any is set.""" - persistence_files = [gateway[CONF_PERSISTENCE_FILE] for gateway in value] - schema = vol.Schema(vol.Unique()) - schema(persistence_files) - return value - - -def is_persistence_file(value: str) -> str: - """Validate that persistence file path ends in either .pickle or .json.""" - if value.endswith((".json", ".pickle")): - return value - raise vol.Invalid(f"{value} does not end in either `.json` or `.pickle`") - - -def deprecated(key: str) -> Callable[[dict], dict]: - """Mark key as deprecated in configuration.""" - - def validator(config: dict) -> dict: - """Check if key is in config, log warning and remove key.""" - if key not in config: - return config - _LOGGER.warning( - "%s option for %s is deprecated. Please remove %s from your " - "configuration file", - key, - DOMAIN, - key, - ) - config.pop(key) - return config - - return validator - - -NODE_SCHEMA = vol.Schema({cv.positive_int: {vol.Required(CONF_NODE_NAME): cv.string}}) - -GATEWAY_SCHEMA = vol.Schema( - vol.All( - deprecated(CONF_NODES), - { - vol.Required(CONF_DEVICE): cv.string, - vol.Optional(CONF_PERSISTENCE_FILE): vol.All( - cv.string, is_persistence_file - ), - vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): cv.positive_int, - vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, - vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, - vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, - }, - ) -) - -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - vol.All( - deprecated(CONF_DEBUG), - deprecated(CONF_OPTIMISTIC), - deprecated(CONF_PERSISTENCE), - { - vol.Required(CONF_GATEWAYS): vol.All( - cv.ensure_list, - set_default_persistence_file, - has_all_unique_files, - [GATEWAY_SCHEMA], - ), - vol.Optional(CONF_RETAIN, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, - }, - ) - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MySensors component.""" + # This is needed to set up the notify platform via discovery. hass.data[DOMAIN] = {DATA_HASS_CONFIG: config} - if DOMAIN not in config or bool(hass.config_entries.async_entries(DOMAIN)): - return True - - config = config[DOMAIN] - user_inputs = [ - { - CONF_DEVICE: gw[CONF_DEVICE], - CONF_BAUD_RATE: gw[CONF_BAUD_RATE], - CONF_TCP_PORT: gw[CONF_TCP_PORT], - CONF_TOPIC_OUT_PREFIX: gw.get(CONF_TOPIC_OUT_PREFIX, ""), - CONF_TOPIC_IN_PREFIX: gw.get(CONF_TOPIC_IN_PREFIX, ""), - CONF_RETAIN: config[CONF_RETAIN], - CONF_VERSION: config[CONF_VERSION], - CONF_PERSISTENCE_FILE: gw[CONF_PERSISTENCE_FILE] - # nodes config ignored at this time. renaming nodes can now be done from the frontend. - } - for gw in config[CONF_GATEWAYS] - ] - user_inputs = [ - {k: v for k, v in userinput.items() if v is not None} - for userinput in user_inputs - ] - - # there is an actual configuration in configuration.yaml, so we have to process it - for user_input in user_inputs: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=user_input, - ) - ) - return True diff --git a/homeassistant/components/mysensors/config_flow.py b/homeassistant/components/mysensors/config_flow.py index 9d992e172b0..5409e3c9a85 100644 --- a/homeassistant/components/mysensors/config_flow.py +++ b/homeassistant/components/mysensors/config_flow.py @@ -22,7 +22,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import DEFAULT_BAUD_RATE, DEFAULT_TCP_PORT, DEFAULT_VERSION, is_persistence_file from .const import ( CONF_BAUD_RATE, CONF_DEVICE, @@ -42,6 +41,17 @@ from .const import ( ) from .gateway import MQTT_COMPONENT, is_serial_port, is_socket_address, try_connect +DEFAULT_BAUD_RATE = 115200 +DEFAULT_TCP_PORT = 5003 +DEFAULT_VERSION = "1.4" + + +def is_persistence_file(value: str) -> str: + """Validate that persistence file path ends in either .pickle or .json.""" + if value.endswith((".json", ".pickle")): + return value + raise vol.Invalid(f"{value} does not end in either `.json` or `.pickle`") + def _get_schema_common(user_input: dict[str, str]) -> dict: """Create a schema with options common to all gateway types.""" @@ -105,31 +115,6 @@ class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Set up config flow.""" self._gw_type: str | None = None - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Import a config entry. - - This method is called by async_setup and it has already - prepared the dict to be compatible with what a user would have - entered from the frontend. - Therefore we process it as though it came from the frontend. - """ - if user_input[CONF_DEVICE] == MQTT_COMPONENT: - user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_MQTT - else: - try: - await self.hass.async_add_executor_job( - is_serial_port, user_input[CONF_DEVICE] - ) - except vol.Invalid: - user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_TCP - else: - user_input[CONF_GATEWAY_TYPE] = CONF_GATEWAY_TYPE_SERIAL - - result: FlowResult = await self.async_step_user(user_input=user_input) - if errors := result.get("errors"): - return self.async_abort(reason=next(iter(errors.values()))) - return result - async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: @@ -335,10 +320,11 @@ class MySensorsConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors[CONF_PERSISTENCE_FILE] = "duplicate_persistence_file" break - for other_entry in self._async_current_entries(): - if _is_same_device(gw_type, user_input, other_entry): - errors["base"] = "already_configured" - break + if not errors: + for other_entry in self._async_current_entries(): + if _is_same_device(gw_type, user_input, other_entry): + errors["base"] = "already_configured" + break # if no errors so far, try to connect if not errors and not await try_connect(self.hass, gw_type, user_input): diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index 5f3cb6aed96..32e2110dd95 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -11,9 +11,6 @@ ATTR_GATEWAY_ID: Final = "gateway_id" CONF_BAUD_RATE: Final = "baud_rate" CONF_DEVICE: Final = "device" -CONF_GATEWAYS: Final = "gateways" -CONF_NODES: Final = "nodes" -CONF_PERSISTENCE: Final = "persistence" CONF_PERSISTENCE_FILE: Final = "persistence_file" CONF_RETAIN: Final = "retain" CONF_TCP_PORT: Final = "tcp_port" diff --git a/tests/components/mysensors/conftest.py b/tests/components/mysensors/conftest.py index 54ae88cdccb..7c73ce1a389 100644 --- a/tests/components/mysensors/conftest.py +++ b/tests/components/mysensors/conftest.py @@ -13,13 +13,13 @@ import pytest from homeassistant.components.device_tracker.legacy import Device from homeassistant.components.mqtt import DOMAIN as MQTT_DOMAIN -from homeassistant.components.mysensors import CONF_VERSION, DEFAULT_BAUD_RATE +from homeassistant.components.mysensors.config_flow import DEFAULT_BAUD_RATE from homeassistant.components.mysensors.const import ( CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAY_TYPE, CONF_GATEWAY_TYPE_SERIAL, - CONF_GATEWAYS, + CONF_VERSION, DOMAIN, ) from homeassistant.core import HomeAssistant @@ -141,8 +141,7 @@ async def integration_fixture( hass: HomeAssistant, transport: MagicMock, config_entry: MockConfigEntry ) -> AsyncGenerator[MockConfigEntry, None]: """Set up the mysensors integration with a config entry.""" - device = config_entry.data[CONF_DEVICE] - config: dict[str, Any] = {DOMAIN: {CONF_GATEWAYS: [{CONF_DEVICE: device}]}} + config: dict[str, Any] = {} config_entry.add_to_hass(hass) with patch("homeassistant.components.mysensors.device.UPDATE_DELAY", new=0): diff --git a/tests/components/mysensors/test_config_flow.py b/tests/components/mysensors/test_config_flow.py index ca13a6d9cef..e7808162043 100644 --- a/tests/components/mysensors/test_config_flow.py +++ b/tests/components/mysensors/test_config_flow.py @@ -14,7 +14,6 @@ from homeassistant.components.mysensors.const import ( CONF_GATEWAY_TYPE_MQTT, CONF_GATEWAY_TYPE_SERIAL, CONF_GATEWAY_TYPE_TCP, - CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, @@ -399,333 +398,268 @@ async def test_config_invalid( assert len(mock_setup_entry.mock_calls) == 0 -@pytest.mark.parametrize( - "user_input", - [ - { - CONF_DEVICE: "COM5", - CONF_BAUD_RATE: 57600, - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, - CONF_VERSION: "2.3", - CONF_PERSISTENCE_FILE: "bla.json", - }, - { - CONF_DEVICE: "COM5", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 57600, - CONF_TCP_PORT: 5003, - CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: True, - }, - { - CONF_DEVICE: "mqtt", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_TOPIC_IN_PREFIX: "intopic", - CONF_TOPIC_OUT_PREFIX: "outtopic", - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - }, - { - CONF_DEVICE: "127.0.0.1", - CONF_PERSISTENCE_FILE: "blub.pickle", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 343, - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - }, - ], -) -async def test_import(hass: HomeAssistant, mqtt: None, user_input: dict) -> None: - """Test importing a gateway.""" - - with patch("sys.platform", "win32"), patch( - "homeassistant.components.mysensors.config_flow.try_connect", return_value=True - ), patch( - "homeassistant.components.mysensors.async_setup_entry", - return_value=True, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, data=user_input, context={"source": config_entries.SOURCE_IMPORT} - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - - @pytest.mark.parametrize( "first_input, second_input, expected_result", [ ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "same2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "same2", }, - (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_IN_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "different1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "different3", CONF_TOPIC_OUT_PREFIX: "different4", }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different4", }, - (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_IN_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "different1", CONF_TOPIC_OUT_PREFIX: "same1", }, - (CONF_TOPIC_OUT_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_OUT_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different2", }, { - CONF_DEVICE: "mqtt", + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_VERSION: "2.3", CONF_TOPIC_IN_PREFIX: "same1", CONF_TOPIC_OUT_PREFIX: "different1", }, - (CONF_TOPIC_IN_PREFIX, "duplicate_topic"), + FlowResult(type="form", errors={CONF_TOPIC_IN_PREFIX: "duplicate_topic"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "127.0.0.1", CONF_PERSISTENCE_FILE: "same.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "same.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - ("persistence_file", "duplicate_persistence_file"), + FlowResult( + type="form", errors={"persistence_file": "duplicate_persistence_file"} + ), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "127.0.0.1", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "same.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "127.0.0.1", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different1.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different2.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - ("base", "already_configured"), + FlowResult(type="form", errors={"base": "already_configured"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different1.json", CONF_TCP_PORT: 343, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_PERSISTENCE_FILE: "different2.json", CONF_TCP_PORT: 5003, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.2", CONF_TCP_PORT: 5003, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, CONF_DEVICE: "192.168.1.3", CONF_TCP_PORT: 5003, CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different1.json", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different2.json", }, - ("base", "already_configured"), + FlowResult(type="form", errors={"base": "already_configured"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM6", CONF_BAUD_RATE: 57600, - CONF_RETAIN: True, CONF_VERSION: "2.3", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", - CONF_TCP_PORT: 5003, - CONF_RETAIN: True, CONF_VERSION: "2.3", }, - None, + FlowResult(type="create_entry"), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", CONF_BAUD_RATE: 115200, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different1.json", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", CONF_BAUD_RATE: 57600, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "different2.json", }, - ("base", "already_configured"), + FlowResult(type="form", errors={"base": "already_configured"}), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM5", CONF_BAUD_RATE: 115200, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "same.json", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM6", CONF_BAUD_RATE: 57600, - CONF_RETAIN: True, CONF_VERSION: "2.3", CONF_PERSISTENCE_FILE: "same.json", }, - ("persistence_file", "duplicate_persistence_file"), + FlowResult( + type="form", errors={"persistence_file": "duplicate_persistence_file"} + ), ), ( { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, CONF_DEVICE: "mqtt", CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, CONF_VERSION: "1.4", }, { + CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, CONF_DEVICE: "COM6", CONF_PERSISTENCE_FILE: "bla2.json", CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, CONF_VERSION: "1.4", }, - None, + FlowResult(type="create_entry"), ), ], ) @@ -734,7 +668,7 @@ async def test_duplicate( mqtt: None, first_input: dict, second_input: dict, - expected_result: tuple[str, str] | None, + expected_result: FlowResult, ) -> None: """Test duplicate detection.""" @@ -746,12 +680,17 @@ async def test_duplicate( ): MockConfigEntry(domain=DOMAIN, data=first_input).add_to_hass(hass) + second_gateway_type = second_input.pop(CONF_GATEWAY_TYPE) result = await hass.config_entries.flow.async_init( - DOMAIN, data=second_input, context={"source": config_entries.SOURCE_IMPORT} + DOMAIN, + data={CONF_GATEWAY_TYPE: second_gateway_type}, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + second_input, ) await hass.async_block_till_done() - if expected_result is None: - assert result["type"] == "create_entry" - else: - assert result["type"] == "abort" - assert result["reason"] == expected_result[1] + + for key, val in expected_result.items(): + assert result[key] == val # type: ignore[literal-required] diff --git a/tests/components/mysensors/test_init.py b/tests/components/mysensors/test_init.py index bb5d77dc7e3..5d44cdbdb3c 100644 --- a/tests/components/mysensors/test_init.py +++ b/tests/components/mysensors/test_init.py @@ -2,360 +2,19 @@ from __future__ import annotations from collections.abc import Awaitable, Callable -from typing import Any -from unittest.mock import patch from aiohttp import ClientWebSocketResponse from mysensors import BaseSyncGateway from mysensors.sensor import Sensor -import pytest -from homeassistant.components.mysensors import ( - CONF_BAUD_RATE, - CONF_DEVICE, - CONF_GATEWAYS, - CONF_PERSISTENCE, - CONF_PERSISTENCE_FILE, - CONF_RETAIN, - CONF_TCP_PORT, - CONF_VERSION, - DEFAULT_VERSION, - DOMAIN, -) -from homeassistant.components.mysensors.const import ( - CONF_GATEWAY_TYPE, - CONF_GATEWAY_TYPE_MQTT, - CONF_GATEWAY_TYPE_SERIAL, - CONF_GATEWAY_TYPE_TCP, - CONF_TOPIC_IN_PREFIX, - CONF_TOPIC_OUT_PREFIX, -) +from homeassistant.components.mysensors import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -@pytest.mark.parametrize( - "config, expected_calls, expected_to_succeed, expected_config_entry_data", - [ - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "COM5", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 57600, - CONF_TCP_PORT: 5003, - } - ], - CONF_VERSION: "2.3", - CONF_PERSISTENCE: False, - CONF_RETAIN: True, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - CONF_DEVICE: "COM5", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 57600, - CONF_VERSION: "2.3", - CONF_TCP_PORT: 5003, - CONF_TOPIC_IN_PREFIX: "", - CONF_TOPIC_OUT_PREFIX: "", - CONF_RETAIN: True, - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "127.0.0.1", - CONF_PERSISTENCE_FILE: "blub.pickle", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 343, - } - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, - CONF_DEVICE: "127.0.0.1", - CONF_PERSISTENCE_FILE: "blub.pickle", - CONF_TCP_PORT: 343, - CONF_VERSION: "2.4", - CONF_BAUD_RATE: 115200, - CONF_TOPIC_IN_PREFIX: "", - CONF_TOPIC_OUT_PREFIX: "", - CONF_RETAIN: False, - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "127.0.0.1", - } - ], - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_TCP, - CONF_DEVICE: "127.0.0.1", - CONF_TCP_PORT: 5003, - CONF_VERSION: DEFAULT_VERSION, - CONF_BAUD_RATE: 115200, - CONF_TOPIC_IN_PREFIX: "", - CONF_TOPIC_OUT_PREFIX: "", - CONF_RETAIN: False, - CONF_PERSISTENCE_FILE: "mysensors1.pickle", - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_TOPIC_IN_PREFIX: "intopic", - CONF_TOPIC_OUT_PREFIX: "outtopic", - } - ], - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 1, - True, - [ - { - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, - CONF_DEVICE: "mqtt", - CONF_VERSION: DEFAULT_VERSION, - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_TOPIC_OUT_PREFIX: "outtopic", - CONF_TOPIC_IN_PREFIX: "intopic", - CONF_RETAIN: False, - CONF_PERSISTENCE_FILE: "mysensors1.pickle", - } - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - } - ], - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 0, - True, - [{}], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_TOPIC_OUT_PREFIX: "out", - CONF_TOPIC_IN_PREFIX: "in", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - { - CONF_DEVICE: "COM6", - CONF_PERSISTENCE_FILE: "bla2.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 2, - True, - [ - { - CONF_DEVICE: "mqtt", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_TOPIC_OUT_PREFIX: "out", - CONF_TOPIC_IN_PREFIX: "in", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "2.4", - CONF_RETAIN: False, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_MQTT, - }, - { - CONF_DEVICE: "COM6", - CONF_PERSISTENCE_FILE: "bla2.json", - CONF_TOPIC_OUT_PREFIX: "", - CONF_TOPIC_IN_PREFIX: "", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "2.4", - CONF_RETAIN: False, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - }, - ], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "mqtt", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - { - CONF_DEVICE: "COM6", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 0, - False, - [{}], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "COMx", - CONF_PERSISTENCE_FILE: "bla.json", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - }, - ], - CONF_VERSION: "2.4", - CONF_PERSISTENCE: False, - CONF_RETAIN: False, - } - }, - 0, - True, - [{}], - ), - ( - { - DOMAIN: { - CONF_GATEWAYS: [ - { - CONF_DEVICE: "COM1", - }, - { - CONF_DEVICE: "COM2", - }, - ], - } - }, - 2, - True, - [ - { - CONF_DEVICE: "COM1", - CONF_PERSISTENCE_FILE: "mysensors1.pickle", - CONF_TOPIC_OUT_PREFIX: "", - CONF_TOPIC_IN_PREFIX: "", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "1.4", - CONF_RETAIN: True, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - }, - { - CONF_DEVICE: "COM2", - CONF_PERSISTENCE_FILE: "mysensors2.pickle", - CONF_TOPIC_OUT_PREFIX: "", - CONF_TOPIC_IN_PREFIX: "", - CONF_BAUD_RATE: 115200, - CONF_TCP_PORT: 5003, - CONF_VERSION: "1.4", - CONF_RETAIN: True, - CONF_GATEWAY_TYPE: CONF_GATEWAY_TYPE_SERIAL, - }, - ], - ), - ], -) -async def test_import( - hass: HomeAssistant, - mqtt: None, - config: ConfigType, - expected_calls: int, - expected_to_succeed: bool, - expected_config_entry_data: list[dict[str, Any]], -) -> None: - """Test importing a gateway.""" - - with patch("sys.platform", "win32"), patch( - "homeassistant.components.mysensors.config_flow.try_connect", return_value=True - ), patch( - "homeassistant.components.mysensors.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await async_setup_component(hass, DOMAIN, config) - assert result == expected_to_succeed - await hass.async_block_till_done() - - assert len(mock_setup_entry.mock_calls) == expected_calls - - for idx in range(expected_calls): - config_entry = mock_setup_entry.mock_calls[idx][1][1] - expected_persistence_file = expected_config_entry_data[idx].pop( - CONF_PERSISTENCE_FILE - ) - expected_persistence_path = hass.config.path(expected_persistence_file) - config_entry_data = dict(config_entry.data) - persistence_path = config_entry_data.pop(CONF_PERSISTENCE_FILE) - assert persistence_path == expected_persistence_path - assert config_entry_data == expected_config_entry_data[idx] - - async def test_remove_config_entry_device( hass: HomeAssistant, gps_sensor: Sensor, From 78cb0e24bc440a9e7a5835a43441a79bbf2dac59 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 May 2022 15:51:38 +0200 Subject: [PATCH 127/947] Improve integration sensor's time unit handling (#72759) --- .../components/integration/sensor.py | 17 +++++-- tests/components/integration/test_sensor.py | 46 ++++++++++++++++++- 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 5c982c6ec5e..5d0dde3e4de 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -154,17 +154,26 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._method = integration_method self._attr_name = name if name is not None else f"{source_entity} integral" - self._unit_template = ( - f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}" - ) + self._unit_template = f"{'' if unit_prefix is None else unit_prefix}{{}}" self._unit_of_measurement = None self._unit_prefix = UNIT_PREFIXES[unit_prefix] self._unit_time = UNIT_TIME[unit_time] + self._unit_time_str = unit_time self._attr_state_class = SensorStateClass.TOTAL self._attr_icon = "mdi:chart-histogram" self._attr_should_poll = False self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} + def _unit(self, source_unit: str) -> str: + """Derive unit from the source sensor, SI prefix and time unit.""" + unit_time = self._unit_time_str + if source_unit.endswith(f"/{unit_time}"): + integral_unit = source_unit[0 : (-(1 + len(unit_time)))] + else: + integral_unit = f"{source_unit}{unit_time}" + + return self._unit_template.format(integral_unit) + async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() @@ -203,7 +212,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): update_state = False unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if unit is not None: - new_unit_of_measurement = self._unit_template.format(unit) + new_unit_of_measurement = self._unit(unit) if self._unit_of_measurement != new_unit_of_measurement: self._unit_of_measurement = new_unit_of_measurement update_state = True diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index e4173d62eb4..8999c1f8d04 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -5,12 +5,15 @@ from unittest.mock import patch from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, + DATA_KILOBYTES, + DATA_RATE_BYTES_PER_SECOND, ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR, POWER_KILO_WATT, POWER_WATT, STATE_UNAVAILABLE, STATE_UNKNOWN, + TIME_HOURS, TIME_SECONDS, ) from homeassistant.core import HomeAssistant, State @@ -300,7 +303,9 @@ async def test_suffix(hass): assert await async_setup_component(hass, "sensor", config) entity_id = config["sensor"]["source"] - hass.states.async_set(entity_id, 1000, {ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT}) + hass.states.async_set( + entity_id, 1000, {ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_BYTES_PER_SECOND} + ) await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) @@ -308,7 +313,7 @@ async def test_suffix(hass): hass.states.async_set( entity_id, 1000, - {ATTR_UNIT_OF_MEASUREMENT: POWER_KILO_WATT}, + {ATTR_UNIT_OF_MEASUREMENT: DATA_RATE_BYTES_PER_SECOND}, force_update=True, ) await hass.async_block_till_done() @@ -318,6 +323,43 @@ async def test_suffix(hass): # Testing a network speed sensor at 1000 bytes/s over 10s = 10kbytes assert round(float(state.state)) == 10 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == DATA_KILOBYTES + + +async def test_suffix_2(hass): + """Test integration sensor state.""" + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.cubic_meters_per_hour", + "round": 2, + "unit_time": TIME_HOURS, + } + } + + assert await async_setup_component(hass, "sensor", config) + + entity_id = config["sensor"]["source"] + hass.states.async_set(entity_id, 1000, {ATTR_UNIT_OF_MEASUREMENT: "m³/h"}) + await hass.async_block_till_done() + + now = dt_util.utcnow() + timedelta(hours=1) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 1000, + {ATTR_UNIT_OF_MEASUREMENT: "m³/h"}, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state is not None + + # Testing a flow sensor at 1000 m³/h over 1h = 1000 m³ + assert round(float(state.state)) == 1000 + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "m³" async def test_units(hass): From aab3fcad7b1664153b5718eb69aec57dbdde8203 Mon Sep 17 00:00:00 2001 From: Alexey Zimarev Date: Tue, 31 May 2022 16:35:29 +0200 Subject: [PATCH 128/947] SmartThings issue with unique_id (#72715) Co-authored-by: Jan Bouwhuis --- homeassistant/components/smartthings/smartapp.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 28b60b57447..fbd63d41373 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -405,7 +405,7 @@ async def _continue_flow( ( flow for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN) - if flow["context"]["unique_id"] == unique_id + if flow["context"].get("unique_id") == unique_id ), None, ) From d31e43b98049921950fed9cce6c5b45fd1d0c59c Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 31 May 2022 08:53:36 -0700 Subject: [PATCH 129/947] Bump google-nest-sdm to `2.0.0` and cleanup nest auth implementation in config flow (#72770) Cleanup nest auth implementaton in config flow --- homeassistant/components/nest/api.py | 61 +++++++++++++++----- homeassistant/components/nest/config_flow.py | 12 ++-- homeassistant/components/nest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 3934b0b3cf1..830db926d9a 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -72,6 +72,36 @@ class AsyncConfigEntryAuth(AbstractAuth): return creds +class AccessTokenAuthImpl(AbstractAuth): + """Authentication implementation used during config flow, without refresh. + + This exists to allow the config flow to use the API before it has fully + created a config entry required by OAuth2Session. This does not support + refreshing tokens, which is fine since it should have been just created. + """ + + def __init__( + self, + websession: ClientSession, + access_token: str, + ) -> None: + """Init the Nest client library auth implementation.""" + super().__init__(websession, API_URL) + self._access_token = access_token + + async def async_get_access_token(self) -> str: + """Return the access token.""" + return self._access_token + + async def async_get_creds(self) -> Credentials: + """Return an OAuth credential for Pub/Sub Subscriber.""" + return Credentials( + token=self._access_token, + token_uri=OAUTH2_TOKEN, + scopes=SDM_SCOPES, + ) + + async def new_subscriber( hass: HomeAssistant, entry: ConfigEntry ) -> GoogleNestSubscriber | None: @@ -89,22 +119,27 @@ async def new_subscriber( ): _LOGGER.error("Configuration option 'subscriber_id' required") return None - return await new_subscriber_with_impl(hass, entry, subscriber_id, implementation) - - -async def new_subscriber_with_impl( - hass: HomeAssistant, - entry: ConfigEntry, - subscriber_id: str, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, -) -> GoogleNestSubscriber: - """Create a GoogleNestSubscriber, used during ConfigFlow.""" - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) auth = AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), - session, + config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), config[CONF_CLIENT_ID], config[CONF_CLIENT_SECRET], ) return GoogleNestSubscriber(auth, config[CONF_PROJECT_ID], subscriber_id) + + +def new_subscriber_with_token( + hass: HomeAssistant, + access_token: str, + project_id: str, + subscriber_id: str, +) -> GoogleNestSubscriber: + """Create a GoogleNestSubscriber with an access token.""" + return GoogleNestSubscriber( + AccessTokenAuthImpl( + aiohttp_client.async_get_clientsession(hass), + access_token, + ), + project_id, + subscriber_id, + ) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index aeebd48abb4..61a61f6c8e0 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -43,7 +43,6 @@ from google_nest_sdm.exceptions import ( from google_nest_sdm.structure import InfoTrait, Structure import voluptuous as vol -from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult @@ -319,12 +318,11 @@ class NestFlowHandler( if not (subscriber_id := data.get(CONF_SUBSCRIBER_ID, "")): subscriber_id = _generate_subscription_id(cloud_project_id) _LOGGER.debug("Creating subscriber id '%s'", subscriber_id) - # Create a placeholder ConfigEntry to use since with the auth we've already created. - entry = ConfigEntry( - version=1, domain=DOMAIN, title="", data=self._data, source="" - ) - subscriber = await api.new_subscriber_with_impl( - self.hass, entry, subscriber_id, self.flow_impl + subscriber = api.new_subscriber_with_token( + self.hass, + self._data["token"]["access_token"], + config[CONF_PROJECT_ID], + subscriber_id, ) try: await subscriber.create_subscription() diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index ce0b68c782a..4f768e08843 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -5,7 +5,7 @@ "dependencies": ["ffmpeg", "http", "auth"], "after_dependencies": ["media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", - "requirements": ["python-nest==4.2.0", "google-nest-sdm==1.8.0"], + "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.0.0"], "codeowners": ["@allenporter"], "quality_scale": "platinum", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 871980bffae..e7a00e1709c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -741,7 +741,7 @@ google-cloud-pubsub==2.11.0 google-cloud-texttospeech==2.11.0 # homeassistant.components.nest -google-nest-sdm==1.8.0 +google-nest-sdm==2.0.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba8a7cefd9c..c6154c71985 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -535,7 +535,7 @@ goodwe==0.2.15 google-cloud-pubsub==2.11.0 # homeassistant.components.nest -google-nest-sdm==1.8.0 +google-nest-sdm==2.0.0 # homeassistant.components.google_travel_time googlemaps==2.5.1 From a53aaf696c04ac1fc03f7e77bce40e62d82acbfe Mon Sep 17 00:00:00 2001 From: Khole Date: Tue, 31 May 2022 16:55:00 +0100 Subject: [PATCH 130/947] Fix hive authentication process (#72719) * Fix hive authentication process * Update hive test scripts to add new data --- homeassistant/components/hive/__init__.py | 7 ++- homeassistant/components/hive/config_flow.py | 1 + homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hive/test_config_flow.py | 60 +++++++++++++++++++- 6 files changed, 68 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index af32d39ae8f..475bb95eeb1 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -76,8 +76,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Hive from a config entry.""" websession = aiohttp_client.async_get_clientsession(hass) - hive = Hive(websession) hive_config = dict(entry.data) + hive = Hive( + websession, + deviceGroupKey=hive_config["device_data"][0], + deviceKey=hive_config["device_data"][1], + devicePassword=hive_config["device_data"][2], + ) hive_config["options"] = {} hive_config["options"].update( diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 2632a24e360..9c391f13294 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -103,6 +103,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # Setup the config entry self.data["tokens"] = self.tokens + self.data["device_data"] = await self.hive_auth.getDeviceData() if self.context["source"] == config_entries.SOURCE_REAUTH: self.hass.config_entries.async_update_entry( self.entry, title=self.data["username"], data=self.data diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 19958b51bd7..472adc137ba 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -3,7 +3,7 @@ "name": "Hive", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.4.2"], + "requirements": ["pyhiveapi==0.5.4"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/requirements_all.txt b/requirements_all.txt index e7a00e1709c..ae39f092709 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1538,7 +1538,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.4.2 +pyhiveapi==0.5.4 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6154c71985..0e80fa6e15a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1029,7 +1029,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.4.2 +pyhiveapi==0.5.4 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index ce13e52fe96..bb567b0bdfc 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -13,7 +13,7 @@ USERNAME = "username@home-assistant.com" UPDATED_USERNAME = "updated_username@home-assistant.com" PASSWORD = "test-password" UPDATED_PASSWORD = "updated-password" -INCORRECT_PASSWORD = "incoreect-password" +INCORRECT_PASSWORD = "incorrect-password" SCAN_INTERVAL = 120 UPDATED_SCAN_INTERVAL = 60 MFA_CODE = "1234" @@ -33,6 +33,13 @@ async def test_import_flow(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -57,6 +64,11 @@ async def test_import_flow(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 @@ -81,6 +93,13 @@ async def test_user_flow(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -105,6 +124,11 @@ async def test_user_flow(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(mock_setup.mock_calls) == 1 @@ -148,6 +172,13 @@ async def test_user_flow_2fa(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -171,6 +202,11 @@ async def test_user_flow_2fa(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(mock_setup.mock_calls) == 1 @@ -243,7 +279,15 @@ async def test_option_flow(hass): entry = MockConfigEntry( domain=DOMAIN, title=USERNAME, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], + }, ) entry.add_to_hass(hass) @@ -317,6 +361,13 @@ async def test_user_flow_2fa_send_new_code(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.getDeviceData", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -340,6 +391,11 @@ async def test_user_flow_2fa_send_new_code(hass): }, "ChallengeName": "SUCCESS", }, + "device_data": [ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 From 52643d9abce34509beea213ce34ffd05274735a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 08:11:44 -1000 Subject: [PATCH 131/947] Add support for async_remove_config_entry_device to isy994 (#72737) --- .coveragerc | 1 + homeassistant/components/isy994/__init__.py | 13 +++++++ homeassistant/components/isy994/services.py | 32 +++-------------- homeassistant/components/isy994/util.py | 39 +++++++++++++++++++++ 4 files changed, 57 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/isy994/util.py diff --git a/.coveragerc b/.coveragerc index 4d05e704065..c556a24e6fa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -579,6 +579,7 @@ omit = homeassistant/components/isy994/sensor.py homeassistant/components/isy994/services.py homeassistant/components/isy994/switch.py + homeassistant/components/isy994/util.py homeassistant/components/itach/remote.py homeassistant/components/itunes/media_player.py homeassistant/components/jellyfin/__init__.py diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e94b8215746..3cee445b587 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -45,6 +45,7 @@ from .const import ( ) from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables from .services import async_setup_services, async_unload_services +from .util import unique_ids_for_config_entry_id CONFIG_SCHEMA = vol.Schema( { @@ -296,3 +297,15 @@ async def async_unload_entry( async_unload_services(hass) return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + device_entry: dr.DeviceEntry, +) -> bool: + """Remove isy994 config entry from a device.""" + return not device_entry.identifiers.intersection( + (DOMAIN, unique_id) + for unique_id in unique_ids_for_config_entry_id(hass, config_entry.entry_id) + ) diff --git a/homeassistant/components/isy994/services.py b/homeassistant/components/isy994/services.py index 654b65309fc..30b5f121df3 100644 --- a/homeassistant/components/isy994/services.py +++ b/homeassistant/components/isy994/services.py @@ -21,16 +21,8 @@ from homeassistant.helpers.entity_platform import async_get_platforms import homeassistant.helpers.entity_registry as er from homeassistant.helpers.service import entity_service_call -from .const import ( - _LOGGER, - DOMAIN, - ISY994_ISY, - ISY994_NODES, - ISY994_PROGRAMS, - ISY994_VARIABLES, - PLATFORMS, - PROGRAM_PLATFORMS, -) +from .const import _LOGGER, DOMAIN, ISY994_ISY +from .util import unique_ids_for_config_entry_id # Common Services for All Platforms: SERVICE_SYSTEM_QUERY = "system_query" @@ -282,7 +274,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 """Remove extra entities that are no longer part of the integration.""" entity_registry = er.async_get(hass) config_ids = [] - current_unique_ids = [] + current_unique_ids: set[str] = set() for config_entry_id in hass.data[DOMAIN]: entries_for_this_config = er.async_entries_for_config_entry( @@ -294,23 +286,7 @@ def async_setup_services(hass: HomeAssistant) -> None: # noqa: C901 for entity in entries_for_this_config ] ) - - hass_isy_data = hass.data[DOMAIN][config_entry_id] - uuid = hass_isy_data[ISY994_ISY].configuration["uuid"] - - for platform in PLATFORMS: - for node in hass_isy_data[ISY994_NODES][platform]: - if hasattr(node, "address"): - current_unique_ids.append(f"{uuid}_{node.address}") - - for platform in PROGRAM_PLATFORMS: - for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]: - if hasattr(node, "address"): - current_unique_ids.append(f"{uuid}_{node.address}") - - for node in hass_isy_data[ISY994_VARIABLES]: - if hasattr(node, "address"): - current_unique_ids.append(f"{uuid}_{node.address}") + current_unique_ids |= unique_ids_for_config_entry_id(hass, config_entry_id) extra_entities = [ entity_id diff --git a/homeassistant/components/isy994/util.py b/homeassistant/components/isy994/util.py new file mode 100644 index 00000000000..196801c58ce --- /dev/null +++ b/homeassistant/components/isy994/util.py @@ -0,0 +1,39 @@ +"""ISY utils.""" +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import ( + DOMAIN, + ISY994_ISY, + ISY994_NODES, + ISY994_PROGRAMS, + ISY994_VARIABLES, + PLATFORMS, + PROGRAM_PLATFORMS, +) + + +def unique_ids_for_config_entry_id( + hass: HomeAssistant, config_entry_id: str +) -> set[str]: + """Find all the unique ids for a config entry id.""" + hass_isy_data = hass.data[DOMAIN][config_entry_id] + uuid = hass_isy_data[ISY994_ISY].configuration["uuid"] + current_unique_ids: set[str] = {uuid} + + for platform in PLATFORMS: + for node in hass_isy_data[ISY994_NODES][platform]: + if hasattr(node, "address"): + current_unique_ids.add(f"{uuid}_{node.address}") + + for platform in PROGRAM_PLATFORMS: + for _, node, _ in hass_isy_data[ISY994_PROGRAMS][platform]: + if hasattr(node, "address"): + current_unique_ids.add(f"{uuid}_{node.address}") + + for node in hass_isy_data[ISY994_VARIABLES]: + if hasattr(node, "address"): + current_unique_ids.add(f"{uuid}_{node.address}") + + return current_unique_ids From eda2be8489184408bd7baf4aed311178805fe2fb Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 31 May 2022 20:30:33 +0200 Subject: [PATCH 132/947] Update frontend to 20220531.0 (#72775) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 48488bc8f47..d9e80b4eff8 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220526.0"], + "requirements": ["home-assistant-frontend==20220531.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a43b4f99f63..a3d8a00bcfb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220526.0 +home-assistant-frontend==20220531.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index ae39f092709..97f204e32b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,7 +822,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220526.0 +home-assistant-frontend==20220531.0 # homeassistant.components.home_connect homeconnect==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e80fa6e15a..c13b5c5e91d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -589,7 +589,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220526.0 +home-assistant-frontend==20220531.0 # homeassistant.components.home_connect homeconnect==0.7.0 From 638992f9c4044d26d75bb498b72bfe212fc114fc Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Wed, 1 Jun 2022 04:34:52 +1000 Subject: [PATCH 133/947] Make zone condition more robust by ignoring unavailable and unknown entities (#72751) * ignore entities with state unavailable or unknown * test for unavailable entity --- homeassistant/helpers/condition.py | 6 +++ tests/components/geo_location/test_trigger.py | 42 ++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 8985b7b721c..a628cdefff4 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -829,6 +829,12 @@ def zone( else: entity_id = entity.entity_id + if entity.state in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + return False + latitude = entity.attributes.get(ATTR_LATITUDE) longitude = entity.attributes.get(ATTR_LONGITUDE) diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index bbf5f42ed60..de6276545b7 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -4,7 +4,12 @@ import logging import pytest from homeassistant.components import automation, zone -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, SERVICE_TURN_OFF +from homeassistant.const import ( + ATTR_ENTITY_ID, + ENTITY_MATCH_ALL, + SERVICE_TURN_OFF, + STATE_UNAVAILABLE, +) from homeassistant.core import Context from homeassistant.setup import async_setup_component @@ -189,6 +194,41 @@ async def test_if_fires_on_zone_leave(hass, calls): assert len(calls) == 1 +async def test_if_fires_on_zone_leave_2(hass, calls): + """Test for firing on zone leave for unavailable entity.""" + hass.states.async_set( + "geo_location.entity", + "hello", + {"latitude": 32.880586, "longitude": -117.237564, "source": "test_source"}, + ) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: { + "trigger": { + "platform": "geo_location", + "source": "test_source", + "zone": "zone.test", + "event": "enter", + }, + "action": {"service": "test.automation"}, + } + }, + ) + + hass.states.async_set( + "geo_location.entity", + STATE_UNAVAILABLE, + {"source": "test_source"}, + ) + await hass.async_block_till_done() + + assert len(calls) == 0 + + async def test_if_not_fires_for_leave_on_zone_enter(hass, calls): """Test for not firing on zone enter.""" hass.states.async_set( From a3e1b285cf35b9c500c1caf8f1d1d7db17aecdc2 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 31 May 2022 13:09:07 -0600 Subject: [PATCH 134/947] Alter RainMachine to not create entities if the underlying data is missing (#72733) --- .coveragerc | 1 + .../components/rainmachine/__init__.py | 3 -- .../components/rainmachine/binary_sensor.py | 44 ++++++++----------- homeassistant/components/rainmachine/model.py | 1 + .../components/rainmachine/sensor.py | 32 ++++++-------- homeassistant/components/rainmachine/util.py | 14 ++++++ 6 files changed, 49 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/rainmachine/util.py diff --git a/.coveragerc b/.coveragerc index c556a24e6fa..204353ffe87 100644 --- a/.coveragerc +++ b/.coveragerc @@ -970,6 +970,7 @@ omit = homeassistant/components/rainmachine/model.py homeassistant/components/rainmachine/sensor.py homeassistant/components/rainmachine/switch.py + homeassistant/components/rainmachine/util.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/__init__.py homeassistant/components/recollect_waste/sensor.py diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index d212f1638b4..6d51be9d921 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -50,10 +50,7 @@ from .const import ( LOGGER, ) -DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC" -DEFAULT_ICON = "mdi:water" DEFAULT_SSL = True -DEFAULT_UPDATE_INTERVAL = timedelta(seconds=15) CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 730b51c142a..1818222a8f4 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -1,6 +1,5 @@ """This platform provides binary sensors for key RainMachine data.""" from dataclasses import dataclass -from functools import partial from homeassistant.components.binary_sensor import ( BinarySensorEntity, @@ -21,6 +20,7 @@ from .const import ( DOMAIN, ) from .model import RainMachineDescriptionMixinApiCategory +from .util import key_exists TYPE_FLOW_SENSOR = "flow_sensor" TYPE_FREEZE = "freeze" @@ -46,6 +46,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( name="Flow Sensor", icon="mdi:water-pump", api_category=DATA_PROVISION_SETTINGS, + data_key="useFlowSensor", ), RainMachineBinarySensorDescription( key=TYPE_FREEZE, @@ -53,6 +54,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( icon="mdi:cancel", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="freeze", ), RainMachineBinarySensorDescription( key=TYPE_FREEZE_PROTECTION, @@ -60,6 +62,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( icon="mdi:weather-snowy", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_UNIVERSAL, + data_key="freezeProtectEnabled", ), RainMachineBinarySensorDescription( key=TYPE_HOT_DAYS, @@ -67,6 +70,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( icon="mdi:thermometer-lines", entity_category=EntityCategory.DIAGNOSTIC, api_category=DATA_RESTRICTIONS_UNIVERSAL, + data_key="hotDaysExtraWatering", ), RainMachineBinarySensorDescription( key=TYPE_HOURLY, @@ -75,6 +79,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="hourly", ), RainMachineBinarySensorDescription( key=TYPE_MONTH, @@ -83,6 +88,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="month", ), RainMachineBinarySensorDescription( key=TYPE_RAINDELAY, @@ -91,6 +97,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="rainDelay", ), RainMachineBinarySensorDescription( key=TYPE_RAINSENSOR, @@ -99,6 +106,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="rainSensor", ), RainMachineBinarySensorDescription( key=TYPE_WEEKDAY, @@ -107,6 +115,7 @@ BINARY_SENSOR_DESCRIPTIONS = ( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, api_category=DATA_RESTRICTIONS_CURRENT, + data_key="weekDay", ), ) @@ -118,35 +127,20 @@ async def async_setup_entry( controller = hass.data[DOMAIN][entry.entry_id][DATA_CONTROLLER] coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - @callback - def async_get_sensor_by_api_category(api_category: str) -> partial: - """Generate the appropriate sensor object for an API category.""" - if api_category == DATA_PROVISION_SETTINGS: - return partial( - ProvisionSettingsBinarySensor, - entry, - coordinators[DATA_PROVISION_SETTINGS], - ) - - if api_category == DATA_RESTRICTIONS_CURRENT: - return partial( - CurrentRestrictionsBinarySensor, - entry, - coordinators[DATA_RESTRICTIONS_CURRENT], - ) - - return partial( - UniversalRestrictionsBinarySensor, - entry, - coordinators[DATA_RESTRICTIONS_UNIVERSAL], - ) + api_category_sensor_map = { + DATA_PROVISION_SETTINGS: ProvisionSettingsBinarySensor, + DATA_RESTRICTIONS_CURRENT: CurrentRestrictionsBinarySensor, + DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsBinarySensor, + } async_add_entities( [ - async_get_sensor_by_api_category(description.api_category)( - controller, description + api_category_sensor_map[description.api_category]( + entry, coordinator, controller, description ) for description in BINARY_SENSOR_DESCRIPTIONS + if (coordinator := coordinators[description.api_category]) is not None + and key_exists(coordinator.data, description.data_key) ] ) diff --git a/homeassistant/components/rainmachine/model.py b/homeassistant/components/rainmachine/model.py index 9f638d486aa..680a47c5d42 100644 --- a/homeassistant/components/rainmachine/model.py +++ b/homeassistant/components/rainmachine/model.py @@ -7,6 +7,7 @@ class RainMachineDescriptionMixinApiCategory: """Define an entity description mixin for binary and regular sensors.""" api_category: str + data_key: str @dataclass diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index a2b0f7cd539..522c57cf7a2 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta -from functools import partial from homeassistant.components.sensor import ( SensorDeviceClass, @@ -33,6 +32,7 @@ from .model import ( RainMachineDescriptionMixinApiCategory, RainMachineDescriptionMixinUid, ) +from .util import key_exists DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE = timedelta(seconds=5) @@ -68,6 +68,7 @@ SENSOR_DESCRIPTIONS = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorClicksPerCubicMeter", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_CONSUMED_LITERS, @@ -78,6 +79,7 @@ SENSOR_DESCRIPTIONS = ( entity_registry_enabled_default=False, state_class=SensorStateClass.TOTAL_INCREASING, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorWateringClicks", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_START_INDEX, @@ -87,6 +89,7 @@ SENSOR_DESCRIPTIONS = ( native_unit_of_measurement="index", entity_registry_enabled_default=False, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorStartIndex", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FLOW_SENSOR_WATERING_CLICKS, @@ -97,6 +100,7 @@ SENSOR_DESCRIPTIONS = ( entity_registry_enabled_default=False, state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, + data_key="flowSensorWateringClicks", ), RainMachineSensorDescriptionApiCategory( key=TYPE_FREEZE_TEMP, @@ -107,6 +111,7 @@ SENSOR_DESCRIPTIONS = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, api_category=DATA_RESTRICTIONS_UNIVERSAL, + data_key="freezeProtectTemp", ), ) @@ -118,27 +123,18 @@ async def async_setup_entry( controller = hass.data[DOMAIN][entry.entry_id][DATA_CONTROLLER] coordinators = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] - @callback - def async_get_sensor_by_api_category(api_category: str) -> partial: - """Generate the appropriate sensor object for an API category.""" - if api_category == DATA_PROVISION_SETTINGS: - return partial( - ProvisionSettingsSensor, - entry, - coordinators[DATA_PROVISION_SETTINGS], - ) - - return partial( - UniversalRestrictionsSensor, - entry, - coordinators[DATA_RESTRICTIONS_UNIVERSAL], - ) + api_category_sensor_map = { + DATA_PROVISION_SETTINGS: ProvisionSettingsSensor, + DATA_RESTRICTIONS_UNIVERSAL: UniversalRestrictionsSensor, + } sensors = [ - async_get_sensor_by_api_category(description.api_category)( - controller, description + api_category_sensor_map[description.api_category]( + entry, coordinator, controller, description ) for description in SENSOR_DESCRIPTIONS + if (coordinator := coordinators[description.api_category]) is not None + and key_exists(coordinator.data, description.data_key) ] zone_coordinator = coordinators[DATA_ZONES] diff --git a/homeassistant/components/rainmachine/util.py b/homeassistant/components/rainmachine/util.py new file mode 100644 index 00000000000..27a0636688e --- /dev/null +++ b/homeassistant/components/rainmachine/util.py @@ -0,0 +1,14 @@ +"""Define RainMachine utilities.""" +from __future__ import annotations + +from typing import Any + + +def key_exists(data: dict[str, Any], search_key: str) -> bool: + """Return whether a key exists in a nested dict.""" + for key, value in data.items(): + if key == search_key: + return True + if isinstance(value, dict): + return key_exists(value, search_key) + return False From d9d22a95563c745ce6a50095f7de902eb078805d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 09:18:11 -1000 Subject: [PATCH 135/947] Initial orjson support (#72754) --- homeassistant/components/history/__init__.py | 2 +- .../components/logbook/websocket_api.py | 2 +- homeassistant/components/recorder/const.py | 8 +-- homeassistant/components/recorder/core.py | 10 ++-- homeassistant/components/recorder/models.py | 59 ++++++++++--------- .../components/websocket_api/commands.py | 18 +++--- .../components/websocket_api/connection.py | 3 +- .../components/websocket_api/const.py | 7 --- .../components/websocket_api/messages.py | 7 ++- homeassistant/helpers/aiohttp_client.py | 2 + homeassistant/helpers/json.py | 47 ++++++++++++++- homeassistant/package_constraints.txt | 1 + homeassistant/scripts/benchmark/__init__.py | 3 +- homeassistant/util/json.py | 9 ++- pyproject.toml | 2 + requirements.txt | 1 + tests/components/energy/test_validate.py | 7 ++- .../components/websocket_api/test_commands.py | 6 +- 18 files changed, 127 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 27acff54f99..77301532d3d 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -24,10 +24,10 @@ from homeassistant.components.recorder.statistics import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.websocket_api import messages -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA +from homeassistant.helpers.json import JSON_DUMP from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 0bb7877b95b..7265bcbae86 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -14,9 +14,9 @@ from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util from .helpers import ( diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index e558d19b530..e94092d2154 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,12 +1,11 @@ """Recorder constants.""" -from functools import partial -import json -from typing import Final from homeassistant.backports.enum import StrEnum from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-import + JSON_DUMP, +) DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" @@ -27,7 +26,6 @@ MAX_ROWS_TO_PURGE = 998 DB_WORKER_PREFIX = "DbWorker" -JSON_DUMP: Final = partial(json.dumps, cls=JSONEncoder, separators=(",", ":")) ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7df4cf57e56..8b15e15042f 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -744,11 +744,12 @@ class Recorder(threading.Thread): return try: - shared_data = EventData.shared_data_from_event(event) + shared_data_bytes = EventData.shared_data_bytes_from_event(event) except (TypeError, ValueError) as ex: _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) return + shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit if pending_event_data := self._pending_event_data.get(shared_data): dbevent.event_data_rel = pending_event_data @@ -756,7 +757,7 @@ class Recorder(threading.Thread): elif data_id := self._event_data_ids.get(shared_data): dbevent.data_id = data_id else: - data_hash = EventData.hash_shared_data(shared_data) + data_hash = EventData.hash_shared_data_bytes(shared_data_bytes) # Matching attributes found in the database if data_id := self._find_shared_data_in_db(data_hash, shared_data): self._event_data_ids[shared_data] = dbevent.data_id = data_id @@ -775,7 +776,7 @@ class Recorder(threading.Thread): assert self.event_session is not None try: dbstate = States.from_event(event) - shared_attrs = StateAttributes.shared_attrs_from_event( + shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event( event, self._exclude_attributes_by_domain ) except (TypeError, ValueError) as ex: @@ -786,6 +787,7 @@ class Recorder(threading.Thread): ) return + shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit if pending_attributes := self._pending_state_attributes.get(shared_attrs): @@ -794,7 +796,7 @@ class Recorder(threading.Thread): elif attributes_id := self._state_attributes_ids.get(shared_attrs): dbstate.attributes_id = attributes_id else: - attr_hash = StateAttributes.hash_shared_attrs(shared_attrs) + attr_hash = StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) # Matching attributes found in the database if attributes_id := self._find_shared_attr_in_db(attr_hash, shared_attrs): dbstate.attributes_id = attributes_id diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 70c816c2af5..e0a22184cc8 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -3,12 +3,12 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -import json import logging from typing import Any, TypedDict, cast, overload import ciso8601 from fnvhash import fnv1a_32 +import orjson from sqlalchemy import ( JSON, BigInteger, @@ -46,9 +46,10 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.json import JSON_DUMP, json_bytes import homeassistant.util.dt as dt_util -from .const import ALL_DOMAIN_EXCLUDE_ATTRS, JSON_DUMP +from .const import ALL_DOMAIN_EXCLUDE_ATTRS # SQLAlchemy Schema # pylint: disable=invalid-name @@ -132,7 +133,7 @@ class JSONLiteral(JSON): # type: ignore[misc] def process(value: Any) -> str: """Dump json.""" - return json.dumps(value) + return JSON_DUMP(value) return process @@ -199,7 +200,7 @@ class Events(Base): # type: ignore[misc,valid-type] try: return Event( self.event_type, - json.loads(self.event_data) if self.event_data else {}, + orjson.loads(self.event_data) if self.event_data else {}, EventOrigin(self.origin) if self.origin else EVENT_ORIGIN_ORDER[self.origin_idx], @@ -207,7 +208,7 @@ class Events(Base): # type: ignore[misc,valid-type] context=context, ) except ValueError: - # When json.loads fails + # When orjson.loads fails _LOGGER.exception("Error converting to event: %s", self) return None @@ -235,25 +236,26 @@ class EventData(Base): # type: ignore[misc,valid-type] @staticmethod def from_event(event: Event) -> EventData: """Create object from an event.""" - shared_data = JSON_DUMP(event.data) + shared_data = json_bytes(event.data) return EventData( - shared_data=shared_data, hash=EventData.hash_shared_data(shared_data) + shared_data=shared_data.decode("utf-8"), + hash=EventData.hash_shared_data_bytes(shared_data), ) @staticmethod - def shared_data_from_event(event: Event) -> str: - """Create shared_attrs from an event.""" - return JSON_DUMP(event.data) + def shared_data_bytes_from_event(event: Event) -> bytes: + """Create shared_data from an event.""" + return json_bytes(event.data) @staticmethod - def hash_shared_data(shared_data: str) -> int: + def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data.encode("utf-8"))) + return cast(int, fnv1a_32(shared_data_bytes)) def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], json.loads(self.shared_data)) + return cast(dict[str, Any], orjson.loads(self.shared_data)) except ValueError: _LOGGER.exception("Error converting row to event data: %s", self) return {} @@ -340,9 +342,9 @@ class States(Base): # type: ignore[misc,valid-type] parent_id=self.context_parent_id, ) try: - attrs = json.loads(self.attributes) if self.attributes else {} + attrs = orjson.loads(self.attributes) if self.attributes else {} except ValueError: - # When json.loads fails + # When orjson.loads fails _LOGGER.exception("Error converting row to state: %s", self) return None if self.last_changed is None or self.last_changed == self.last_updated: @@ -388,40 +390,39 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] """Create object from a state_changed event.""" state: State | None = event.data.get("new_state") # None state means the state was removed from the state machine - dbstate = StateAttributes( - shared_attrs="{}" if state is None else JSON_DUMP(state.attributes) - ) - dbstate.hash = StateAttributes.hash_shared_attrs(dbstate.shared_attrs) + attr_bytes = b"{}" if state is None else json_bytes(state.attributes) + dbstate = StateAttributes(shared_attrs=attr_bytes.decode("utf-8")) + dbstate.hash = StateAttributes.hash_shared_attrs_bytes(attr_bytes) return dbstate @staticmethod - def shared_attrs_from_event( + def shared_attrs_bytes_from_event( event: Event, exclude_attrs_by_domain: dict[str, set[str]] - ) -> str: + ) -> bytes: """Create shared_attrs from a state_changed event.""" state: State | None = event.data.get("new_state") # None state means the state was removed from the state machine if state is None: - return "{}" + return b"{}" domain = split_entity_id(state.entity_id)[0] exclude_attrs = ( exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS ) - return JSON_DUMP( + return json_bytes( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} ) @staticmethod - def hash_shared_attrs(shared_attrs: str) -> int: - """Return the hash of json encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs.encode("utf-8"))) + def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: + """Return the hash of orjson encoded shared attributes.""" + return cast(int, fnv1a_32(shared_attrs_bytes)) def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], json.loads(self.shared_attrs)) + return cast(dict[str, Any], orjson.loads(self.shared_attrs)) except ValueError: - # When json.loads fails + # When orjson.loads fails _LOGGER.exception("Error converting row to state attributes: %s", self) return {} @@ -835,7 +836,7 @@ def decode_attributes_from_row( if not source or source == EMPTY_JSON_OBJECT: return {} try: - attr_cache[source] = attributes = json.loads(source) + attr_cache[source] = attributes = orjson.loads(source) except ValueError: _LOGGER.exception("Error converting row to state attributes: %s", source) attr_cache[source] = attributes = {} diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 61bcb8badf0..bea08722eb0 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -29,7 +29,7 @@ from homeassistant.helpers.event import ( TrackTemplateResult, async_track_template_result, ) -from homeassistant.helpers.json import ExtendedJSONEncoder +from homeassistant.helpers.json import JSON_DUMP, ExtendedJSONEncoder from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_get_loaded_integrations @@ -241,13 +241,13 @@ def handle_get_states( # to succeed for the UI to show. response = messages.result_message(msg["id"], states) try: - connection.send_message(const.JSON_DUMP(response)) + connection.send_message(JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=const.JSON_DUMP) + find_paths_unserializable_data(response, dump=JSON_DUMP) ), ) del response @@ -256,13 +256,13 @@ def handle_get_states( serialized = [] for state in states: try: - serialized.append(const.JSON_DUMP(state)) + serialized.append(JSON_DUMP(state)) except (ValueError, TypeError): # Error is already logged above pass # We now have partially serialized states. Craft some JSON. - response2 = const.JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) + response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized)) connection.send_message(response2) @@ -315,13 +315,13 @@ def handle_subscribe_entities( # to succeed for the UI to show. response = messages.event_message(msg["id"], data) try: - connection.send_message(const.JSON_DUMP(response)) + connection.send_message(JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=const.JSON_DUMP) + find_paths_unserializable_data(response, dump=JSON_DUMP) ), ) del response @@ -330,14 +330,14 @@ def handle_subscribe_entities( cannot_serialize: list[str] = [] for entity_id, state_dict in add_entities.items(): try: - const.JSON_DUMP(state_dict) + JSON_DUMP(state_dict) except (ValueError, TypeError): cannot_serialize.append(entity_id) for entity_id in cannot_serialize: del add_entities[entity_id] - connection.send_message(const.JSON_DUMP(messages.event_message(msg["id"], data))) + connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data))) @decorators.websocket_command({vol.Required("type"): "get_services"}) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 0280863f83e..26c4c6f8321 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.auth.models import RefreshToken, User from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers.json import JSON_DUMP from . import const, messages @@ -56,7 +57,7 @@ class ActiveConnection: async def send_big_result(self, msg_id: int, result: Any) -> None: """Send a result message that would be expensive to JSON serialize.""" content = await self.hass.async_add_executor_job( - const.JSON_DUMP, messages.result_message(msg_id, result) + JSON_DUMP, messages.result_message(msg_id, result) ) self.send_message(content) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 107cf6d0270..60a00126092 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -4,12 +4,9 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from concurrent import futures -from functools import partial -import json from typing import TYPE_CHECKING, Any, Final from homeassistant.core import HomeAssistant -from homeassistant.helpers.json import JSONEncoder if TYPE_CHECKING: from .connection import ActiveConnection # noqa: F401 @@ -53,10 +50,6 @@ SIGNAL_WEBSOCKET_DISCONNECTED: Final = "websocket_disconnected" # Data used to store the current connection list DATA_CONNECTIONS: Final = f"{DOMAIN}.connections" -JSON_DUMP: Final = partial( - json.dumps, cls=JSONEncoder, allow_nan=False, separators=(",", ":") -) - COMPRESSED_STATE_STATE = "s" COMPRESSED_STATE_ATTRIBUTES = "a" COMPRESSED_STATE_CONTEXT = "c" diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index f546ba5eec6..c3e5f6bb5f5 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.json import JSON_DUMP from homeassistant.util.json import ( find_paths_unserializable_data, format_unserializable_data, @@ -193,15 +194,15 @@ def compressed_state_dict_add(state: State) -> dict[str, Any]: def message_to_json(message: dict[str, Any]) -> str: """Serialize a websocket message to json.""" try: - return const.JSON_DUMP(message) + return JSON_DUMP(message) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(message, dump=const.JSON_DUMP) + find_paths_unserializable_data(message, dump=JSON_DUMP) ), ) - return const.JSON_DUMP( + return JSON_DUMP( error_message( message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index eaabb002b0a..2e56698db41 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -14,6 +14,7 @@ from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout import async_timeout +import orjson from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ @@ -97,6 +98,7 @@ def _async_create_clientsession( """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( connector=_async_get_connector(hass, verify_ssl), + json_serialize=lambda x: orjson.dumps(x).decode("utf-8"), **kwargs, ) # Prevent packages accidentally overriding our default headers diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index c581e5a9361..912667a13b5 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -1,7 +1,10 @@ """Helpers to help with encoding Home Assistant objects in JSON.""" import datetime import json -from typing import Any +from pathlib import Path +from typing import Any, Final + +import orjson class JSONEncoder(json.JSONEncoder): @@ -22,6 +25,20 @@ class JSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, o) +def json_encoder_default(obj: Any) -> Any: + """Convert Home Assistant objects. + + Hand other objects to the original method. + """ + if isinstance(obj, set): + return list(obj) + if hasattr(obj, "as_dict"): + return obj.as_dict() + if isinstance(obj, Path): + return obj.as_posix() + raise TypeError + + class ExtendedJSONEncoder(JSONEncoder): """JSONEncoder that supports Home Assistant objects and falls back to repr(o).""" @@ -40,3 +57,31 @@ class ExtendedJSONEncoder(JSONEncoder): return super().default(o) except TypeError: return {"__type": str(type(o)), "repr": repr(o)} + + +def json_bytes(data: Any) -> bytes: + """Dump json bytes.""" + return orjson.dumps( + data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ) + + +def json_dumps(data: Any) -> str: + """Dump json string. + + orjson supports serializing dataclasses natively which + eliminates the need to implement as_dict in many places + when the data is already in a dataclass. This works + well as long as all the data in the dataclass can also + be serialized. + + If it turns out to be a problem we can disable this + with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it + will fallback to as_dict + """ + return orjson.dumps( + data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ).decode("utf-8") + + +JSON_DUMP: Final = json_dumps diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a3d8a00bcfb..c158d26a9aa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,6 +20,7 @@ httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.7 +orjson==3.6.8 paho-mqtt==1.6.1 pillow==9.1.1 pip>=21.0,<22.2 diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index a681b3e210d..efbfec5e961 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -12,14 +12,13 @@ from timeit import default_timer as timer from typing import TypeVar from homeassistant import core -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.helpers.entityfilter import convert_include_exclude_filter from homeassistant.helpers.event import ( async_track_state_change, async_track_state_change_event, ) -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSON_DUMP, JSONEncoder # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs # mypy: no-warn-return-any diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index fdee7a7a90f..82ecfd34d6d 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -7,6 +7,8 @@ import json import logging from typing import Any +import orjson + from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError @@ -30,7 +32,7 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: """ try: with open(filename, encoding="utf-8") as fdesc: - return json.loads(fdesc.read()) # type: ignore[no-any-return] + return orjson.loads(fdesc.read()) # type: ignore[no-any-return] except FileNotFoundError: # This is not a fatal error _LOGGER.debug("JSON file not found: %s", filename) @@ -56,7 +58,10 @@ def save_json( Returns True on success. """ try: - json_data = json.dumps(data, indent=4, cls=encoder) + if encoder: + json_data = json.dumps(data, indent=2, cls=encoder) + else: + json_data = orjson.dumps(data, option=orjson.OPT_INDENT_2).decode("utf-8") except TypeError as error: msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data))}" _LOGGER.error(msg) diff --git a/pyproject.toml b/pyproject.toml index cc745f58ad6..7e62bafd6af 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "PyJWT==2.4.0", # PyJWT has loose dependency. We want the latest one. "cryptography==36.0.2", + "orjson==3.6.8", "pip>=21.0,<22.2", "python-slugify==4.0.1", "pyyaml==6.0", @@ -119,6 +120,7 @@ extension-pkg-allow-list = [ "av.audio.stream", "av.stream", "ciso8601", + "orjson", "cv2", ] diff --git a/requirements.txt b/requirements.txt index fe2bf87ad25..9805ae7cd47 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 cryptography==36.0.2 +orjson==3.6.8 pip>=21.0,<22.2 python-slugify==4.0.1 pyyaml==6.0 diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 37ebe4147c5..e802688daaf 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from homeassistant.components.energy import async_get_manager, validate +from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component @@ -408,7 +409,11 @@ async def test_validation_grid( }, ) - assert (await validate.async_validate(hass)).as_dict() == { + result = await validate.async_validate(hass) + # verify its also json serializable + JSON_DUMP(result) + + assert result.as_dict() == { "energy_sources": [ [ { diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 4d3302f7c13..0f4695596fc 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -619,12 +619,15 @@ async def test_states_filters_visible(hass, hass_admin_user, websocket_client): async def test_get_states_not_allows_nan(hass, websocket_client): - """Test get_states command not allows NaN floats.""" + """Test get_states command converts NaN to None.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") await websocket_client.send_json({"id": 5, "type": "get_states"}) + bad = dict(hass.states.get("greeting.bad").as_dict()) + bad["attributes"] = dict(bad["attributes"]) + bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -632,6 +635,7 @@ async def test_get_states_not_allows_nan(hass, websocket_client): assert msg["success"] assert msg["result"] == [ hass.states.get("greeting.hello").as_dict(), + bad, hass.states.get("greeting.bye").as_dict(), ] From 935ef79156742a8e3ef58e85df8d1029b3f8b8ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 09:24:18 -1000 Subject: [PATCH 136/947] Fix queries for logbook context_ids running in the wrong executor (#72778) --- homeassistant/components/logbook/websocket_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 7265bcbae86..461ed018090 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -475,7 +475,7 @@ async def ws_get_events( ) connection.send_message( - await hass.async_add_executor_job( + await get_instance(hass).async_add_executor_job( _ws_formatted_get_events, msg["id"], start_time, From 7854aaa74698e04a82b29f2f1106bb693d33c702 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 31 May 2022 15:24:35 -0400 Subject: [PATCH 137/947] Bump ZHA quirks lib to 0.0.75 (#72765) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 8d6e6162d76..4f16b1c113e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,7 +7,7 @@ "bellows==0.30.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.74", + "zha-quirks==0.0.75", "zigpy-deconz==0.16.0", "zigpy==0.45.1", "zigpy-xbee==0.14.0", diff --git a/requirements_all.txt b/requirements_all.txt index 97f204e32b4..b6d24b95d5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2501,7 +2501,7 @@ zengge==0.2 zeroconf==0.38.6 # homeassistant.components.zha -zha-quirks==0.0.74 +zha-quirks==0.0.75 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c13b5c5e91d..230ceae7fa0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1647,7 +1647,7 @@ youless-api==0.16 zeroconf==0.38.6 # homeassistant.components.zha -zha-quirks==0.0.74 +zha-quirks==0.0.75 # homeassistant.components.zha zigpy-deconz==0.16.0 From 35ee4ad55b6ddacc130f85d1390b13a142abb9bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 10:08:04 -1000 Subject: [PATCH 138/947] Prevent live logbook from sending state changed events when we only want device ids (#72780) --- homeassistant/components/logbook/helpers.py | 6 ++++++ tests/components/logbook/test_websocket_api.py | 2 ++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index cc9ea238f8b..de021994b8d 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -132,6 +132,12 @@ def async_subscribe_events( if not _is_state_filtered(ent_reg, state): target(event) + if device_ids and not entity_ids: + # No entities to subscribe to but we are filtering + # on device ids so we do not want to get any state + # changed events + return + if entity_ids: subscriptions.append( async_track_state_change_event( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 1d35d6d897d..291c487b35b 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -1743,6 +1743,8 @@ async def test_subscribe_unsubscribe_logbook_stream_device( assert msg["type"] == "event" assert msg["event"]["events"] == [] + hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) + hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) hass.bus.async_fire("mock_event", {"device_id": device.id}) await hass.async_block_till_done() From 84779482b83aed5326821d2e6518dbd5177faef2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 31 May 2022 22:08:50 +0200 Subject: [PATCH 139/947] Don't set headers kwargs multiple times (#72779) --- homeassistant/helpers/config_entry_oauth2_flow.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 365ced24929..9322d6e9dc1 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -493,13 +493,13 @@ async def async_oauth2_request( This method will not refresh tokens. Use OAuth2 session for that. """ session = async_get_clientsession(hass) - + headers = kwargs.pop("headers", {}) return await session.request( method, url, **kwargs, headers={ - **(kwargs.get("headers") or {}), + **headers, "authorization": f"Bearer {token['access_token']}", }, ) From 9cea936c22581a88fe1eecfeabb836a8fffd1048 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 31 May 2022 22:44:06 +0200 Subject: [PATCH 140/947] Use Mapping for async_step_reauth (t-z) (#72767) * Adjust tailscale * Adjust tautulli * Adjust tile * Adjust tractive * Adjust trafikverket_ferry * Adjust trafikverket_train * Adjust unifiprotect * Adjust uptimerobot * Adjust verisure * Adjust vlc_telnet * Adjust wallbox * Adjust watttime * Adjust yale_smart_alarm --- homeassistant/components/tailscale/config_flow.py | 3 ++- homeassistant/components/tautulli/config_flow.py | 3 ++- homeassistant/components/tile/config_flow.py | 3 ++- homeassistant/components/tractive/config_flow.py | 3 ++- homeassistant/components/trafikverket_ferry/config_flow.py | 5 ++--- homeassistant/components/trafikverket_train/config_flow.py | 5 ++--- homeassistant/components/unifiprotect/config_flow.py | 3 ++- homeassistant/components/uptimerobot/config_flow.py | 5 ++--- homeassistant/components/verisure/config_flow.py | 3 ++- homeassistant/components/vlc_telnet/config_flow.py | 3 ++- homeassistant/components/wallbox/config_flow.py | 5 ++--- homeassistant/components/watttime/config_flow.py | 3 ++- homeassistant/components/yale_smart_alarm/config_flow.py | 5 ++--- 13 files changed, 26 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/tailscale/config_flow.py b/homeassistant/components/tailscale/config_flow.py index f1180db5254..a51cb722988 100644 --- a/homeassistant/components/tailscale/config_flow.py +++ b/homeassistant/components/tailscale/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Tailscale integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from tailscale import Tailscale, TailscaleAuthenticationError, TailscaleError @@ -81,7 +82,7 @@ class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Tailscale.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/tautulli/config_flow.py b/homeassistant/components/tautulli/config_flow.py index ea470e2e1d0..b4f3e3985ec 100644 --- a/homeassistant/components/tautulli/config_flow.py +++ b/homeassistant/components/tautulli/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Tautulli.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytautulli import ( @@ -70,7 +71,7 @@ class TautulliConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/tile/config_flow.py b/homeassistant/components/tile/config_flow.py index e1424453075..c47a46b3b10 100644 --- a/homeassistant/components/tile/config_flow.py +++ b/homeassistant/components/tile/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Tile integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytile import async_login @@ -74,7 +75,7 @@ class TileFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._username = config[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py index 7ba6602a520..647d97f7179 100644 --- a/homeassistant/components/tractive/config_flow.py +++ b/homeassistant/components/tractive/config_flow.py @@ -1,6 +1,7 @@ """Config flow for tractive integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -69,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, _: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index 7f9737cf686..2b0a1dec655 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Trafikverket Ferry integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketFerry @@ -59,9 +60,7 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ferry_api = TrafikverketFerry(web_session, api_key) await ferry_api.async_get_next_ferry_stop(ferry_from, ferry_to) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 823b393f7b1..521e499ec5d 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Trafikverket Train integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketTrain @@ -54,9 +55,7 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await train_api.async_get_train_station(train_from) await train_api.async_get_train_station(train_to) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index daaae214df9..23e2541e6d8 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -1,6 +1,7 @@ """Config Flow to configure UniFi Protect Integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -236,7 +237,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return nvr_data, errors - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 5b6ac1d4880..83371bdd4a7 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -1,6 +1,7 @@ """Config flow for UptimeRobot integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pyuptimerobot import ( @@ -84,9 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Return the reauth confirm step.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 612d42bdf25..91bde6db219 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Verisure integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any, cast from verisure import ( @@ -108,7 +109,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Verisure.""" self.entry = cast( ConfigEntry, diff --git a/homeassistant/components/vlc_telnet/config_flow.py b/homeassistant/components/vlc_telnet/config_flow.py index 29508ad1120..9c97e876e1b 100644 --- a/homeassistant/components/vlc_telnet/config_flow.py +++ b/homeassistant/components/vlc_telnet/config_flow.py @@ -1,6 +1,7 @@ """Config flow for VLC media player Telnet integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -104,7 +105,7 @@ class VLCTelnetConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_form_schema(user_input), errors=errors ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle reauth flow.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert self.entry diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index d2c0a048fa1..dbd1f3612a5 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Wallbox integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -47,9 +48,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=COMPONENT_DOMAIN): """Start the Wallbox config flow.""" self._reauth_entry: config_entries.ConfigEntry | None = None - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index 993e070ffe8..4d6985ec616 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -1,6 +1,7 @@ """Config flow for WattTime integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from aiowatttime import Client @@ -189,7 +190,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_coordinates() - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._data = {**config} return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index ae5f492bc6a..a3f350cef23 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Yale Smart Alarm integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -53,9 +54,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return YaleOptionsFlowHandler(config_entry) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Yale.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() From c365454afb1799c7b45cf452d67b13ede5300df0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 10:51:55 -1000 Subject: [PATCH 141/947] Revert "Initial orjson support (#72754)" (#72789) This was causing the wheels to fail to build. We need to workout why when we don't have release pressure This reverts commit d9d22a95563c745ce6a50095f7de902eb078805d. --- homeassistant/components/history/__init__.py | 2 +- .../components/logbook/websocket_api.py | 2 +- homeassistant/components/recorder/const.py | 8 ++- homeassistant/components/recorder/core.py | 10 ++-- homeassistant/components/recorder/models.py | 59 +++++++++---------- .../components/websocket_api/commands.py | 18 +++--- .../components/websocket_api/connection.py | 3 +- .../components/websocket_api/const.py | 7 +++ .../components/websocket_api/messages.py | 7 +-- homeassistant/helpers/aiohttp_client.py | 2 - homeassistant/helpers/json.py | 47 +-------------- homeassistant/package_constraints.txt | 1 - homeassistant/scripts/benchmark/__init__.py | 3 +- homeassistant/util/json.py | 9 +-- pyproject.toml | 2 - requirements.txt | 1 - tests/components/energy/test_validate.py | 7 +-- .../components/websocket_api/test_commands.py | 6 +- 18 files changed, 67 insertions(+), 127 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 77301532d3d..27acff54f99 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -24,10 +24,10 @@ from homeassistant.components.recorder.statistics import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.websocket_api import messages +from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA -from homeassistant.helpers.json import JSON_DUMP from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 461ed018090..82b1db1081c 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -14,9 +14,9 @@ from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util from .helpers import ( diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index e94092d2154..e558d19b530 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,11 +1,12 @@ """Recorder constants.""" +from functools import partial +import json +from typing import Final from homeassistant.backports.enum import StrEnum from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES -from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-import - JSON_DUMP, -) +from homeassistant.helpers.json import JSONEncoder DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" @@ -26,6 +27,7 @@ MAX_ROWS_TO_PURGE = 998 DB_WORKER_PREFIX = "DbWorker" +JSON_DUMP: Final = partial(json.dumps, cls=JSONEncoder, separators=(",", ":")) ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 8b15e15042f..7df4cf57e56 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -744,12 +744,11 @@ class Recorder(threading.Thread): return try: - shared_data_bytes = EventData.shared_data_bytes_from_event(event) + shared_data = EventData.shared_data_from_event(event) except (TypeError, ValueError) as ex: _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) return - shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit if pending_event_data := self._pending_event_data.get(shared_data): dbevent.event_data_rel = pending_event_data @@ -757,7 +756,7 @@ class Recorder(threading.Thread): elif data_id := self._event_data_ids.get(shared_data): dbevent.data_id = data_id else: - data_hash = EventData.hash_shared_data_bytes(shared_data_bytes) + data_hash = EventData.hash_shared_data(shared_data) # Matching attributes found in the database if data_id := self._find_shared_data_in_db(data_hash, shared_data): self._event_data_ids[shared_data] = dbevent.data_id = data_id @@ -776,7 +775,7 @@ class Recorder(threading.Thread): assert self.event_session is not None try: dbstate = States.from_event(event) - shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event( + shared_attrs = StateAttributes.shared_attrs_from_event( event, self._exclude_attributes_by_domain ) except (TypeError, ValueError) as ex: @@ -787,7 +786,6 @@ class Recorder(threading.Thread): ) return - shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit if pending_attributes := self._pending_state_attributes.get(shared_attrs): @@ -796,7 +794,7 @@ class Recorder(threading.Thread): elif attributes_id := self._state_attributes_ids.get(shared_attrs): dbstate.attributes_id = attributes_id else: - attr_hash = StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) + attr_hash = StateAttributes.hash_shared_attrs(shared_attrs) # Matching attributes found in the database if attributes_id := self._find_shared_attr_in_db(attr_hash, shared_attrs): dbstate.attributes_id = attributes_id diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index e0a22184cc8..70c816c2af5 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -3,12 +3,12 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta +import json import logging from typing import Any, TypedDict, cast, overload import ciso8601 from fnvhash import fnv1a_32 -import orjson from sqlalchemy import ( JSON, BigInteger, @@ -46,10 +46,9 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id -from homeassistant.helpers.json import JSON_DUMP, json_bytes import homeassistant.util.dt as dt_util -from .const import ALL_DOMAIN_EXCLUDE_ATTRS +from .const import ALL_DOMAIN_EXCLUDE_ATTRS, JSON_DUMP # SQLAlchemy Schema # pylint: disable=invalid-name @@ -133,7 +132,7 @@ class JSONLiteral(JSON): # type: ignore[misc] def process(value: Any) -> str: """Dump json.""" - return JSON_DUMP(value) + return json.dumps(value) return process @@ -200,7 +199,7 @@ class Events(Base): # type: ignore[misc,valid-type] try: return Event( self.event_type, - orjson.loads(self.event_data) if self.event_data else {}, + json.loads(self.event_data) if self.event_data else {}, EventOrigin(self.origin) if self.origin else EVENT_ORIGIN_ORDER[self.origin_idx], @@ -208,7 +207,7 @@ class Events(Base): # type: ignore[misc,valid-type] context=context, ) except ValueError: - # When orjson.loads fails + # When json.loads fails _LOGGER.exception("Error converting to event: %s", self) return None @@ -236,26 +235,25 @@ class EventData(Base): # type: ignore[misc,valid-type] @staticmethod def from_event(event: Event) -> EventData: """Create object from an event.""" - shared_data = json_bytes(event.data) + shared_data = JSON_DUMP(event.data) return EventData( - shared_data=shared_data.decode("utf-8"), - hash=EventData.hash_shared_data_bytes(shared_data), + shared_data=shared_data, hash=EventData.hash_shared_data(shared_data) ) @staticmethod - def shared_data_bytes_from_event(event: Event) -> bytes: - """Create shared_data from an event.""" - return json_bytes(event.data) + def shared_data_from_event(event: Event) -> str: + """Create shared_attrs from an event.""" + return JSON_DUMP(event.data) @staticmethod - def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: + def hash_shared_data(shared_data: str) -> int: """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data_bytes)) + return cast(int, fnv1a_32(shared_data.encode("utf-8"))) def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], orjson.loads(self.shared_data)) + return cast(dict[str, Any], json.loads(self.shared_data)) except ValueError: _LOGGER.exception("Error converting row to event data: %s", self) return {} @@ -342,9 +340,9 @@ class States(Base): # type: ignore[misc,valid-type] parent_id=self.context_parent_id, ) try: - attrs = orjson.loads(self.attributes) if self.attributes else {} + attrs = json.loads(self.attributes) if self.attributes else {} except ValueError: - # When orjson.loads fails + # When json.loads fails _LOGGER.exception("Error converting row to state: %s", self) return None if self.last_changed is None or self.last_changed == self.last_updated: @@ -390,39 +388,40 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] """Create object from a state_changed event.""" state: State | None = event.data.get("new_state") # None state means the state was removed from the state machine - attr_bytes = b"{}" if state is None else json_bytes(state.attributes) - dbstate = StateAttributes(shared_attrs=attr_bytes.decode("utf-8")) - dbstate.hash = StateAttributes.hash_shared_attrs_bytes(attr_bytes) + dbstate = StateAttributes( + shared_attrs="{}" if state is None else JSON_DUMP(state.attributes) + ) + dbstate.hash = StateAttributes.hash_shared_attrs(dbstate.shared_attrs) return dbstate @staticmethod - def shared_attrs_bytes_from_event( + def shared_attrs_from_event( event: Event, exclude_attrs_by_domain: dict[str, set[str]] - ) -> bytes: + ) -> str: """Create shared_attrs from a state_changed event.""" state: State | None = event.data.get("new_state") # None state means the state was removed from the state machine if state is None: - return b"{}" + return "{}" domain = split_entity_id(state.entity_id)[0] exclude_attrs = ( exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS ) - return json_bytes( + return JSON_DUMP( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} ) @staticmethod - def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: - """Return the hash of orjson encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs_bytes)) + def hash_shared_attrs(shared_attrs: str) -> int: + """Return the hash of json encoded shared attributes.""" + return cast(int, fnv1a_32(shared_attrs.encode("utf-8"))) def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], orjson.loads(self.shared_attrs)) + return cast(dict[str, Any], json.loads(self.shared_attrs)) except ValueError: - # When orjson.loads fails + # When json.loads fails _LOGGER.exception("Error converting row to state attributes: %s", self) return {} @@ -836,7 +835,7 @@ def decode_attributes_from_row( if not source or source == EMPTY_JSON_OBJECT: return {} try: - attr_cache[source] = attributes = orjson.loads(source) + attr_cache[source] = attributes = json.loads(source) except ValueError: _LOGGER.exception("Error converting row to state attributes: %s", source) attr_cache[source] = attributes = {} diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index bea08722eb0..61bcb8badf0 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -29,7 +29,7 @@ from homeassistant.helpers.event import ( TrackTemplateResult, async_track_template_result, ) -from homeassistant.helpers.json import JSON_DUMP, ExtendedJSONEncoder +from homeassistant.helpers.json import ExtendedJSONEncoder from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_get_loaded_integrations @@ -241,13 +241,13 @@ def handle_get_states( # to succeed for the UI to show. response = messages.result_message(msg["id"], states) try: - connection.send_message(JSON_DUMP(response)) + connection.send_message(const.JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) + find_paths_unserializable_data(response, dump=const.JSON_DUMP) ), ) del response @@ -256,13 +256,13 @@ def handle_get_states( serialized = [] for state in states: try: - serialized.append(JSON_DUMP(state)) + serialized.append(const.JSON_DUMP(state)) except (ValueError, TypeError): # Error is already logged above pass # We now have partially serialized states. Craft some JSON. - response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) + response2 = const.JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized)) connection.send_message(response2) @@ -315,13 +315,13 @@ def handle_subscribe_entities( # to succeed for the UI to show. response = messages.event_message(msg["id"], data) try: - connection.send_message(JSON_DUMP(response)) + connection.send_message(const.JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) + find_paths_unserializable_data(response, dump=const.JSON_DUMP) ), ) del response @@ -330,14 +330,14 @@ def handle_subscribe_entities( cannot_serialize: list[str] = [] for entity_id, state_dict in add_entities.items(): try: - JSON_DUMP(state_dict) + const.JSON_DUMP(state_dict) except (ValueError, TypeError): cannot_serialize.append(entity_id) for entity_id in cannot_serialize: del add_entities[entity_id] - connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data))) + connection.send_message(const.JSON_DUMP(messages.event_message(msg["id"], data))) @decorators.websocket_command({vol.Required("type"): "get_services"}) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 26c4c6f8321..0280863f83e 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -11,7 +11,6 @@ import voluptuous as vol from homeassistant.auth.models import RefreshToken, User from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers.json import JSON_DUMP from . import const, messages @@ -57,7 +56,7 @@ class ActiveConnection: async def send_big_result(self, msg_id: int, result: Any) -> None: """Send a result message that would be expensive to JSON serialize.""" content = await self.hass.async_add_executor_job( - JSON_DUMP, messages.result_message(msg_id, result) + const.JSON_DUMP, messages.result_message(msg_id, result) ) self.send_message(content) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 60a00126092..107cf6d0270 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -4,9 +4,12 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from concurrent import futures +from functools import partial +import json from typing import TYPE_CHECKING, Any, Final from homeassistant.core import HomeAssistant +from homeassistant.helpers.json import JSONEncoder if TYPE_CHECKING: from .connection import ActiveConnection # noqa: F401 @@ -50,6 +53,10 @@ SIGNAL_WEBSOCKET_DISCONNECTED: Final = "websocket_disconnected" # Data used to store the current connection list DATA_CONNECTIONS: Final = f"{DOMAIN}.connections" +JSON_DUMP: Final = partial( + json.dumps, cls=JSONEncoder, allow_nan=False, separators=(",", ":") +) + COMPRESSED_STATE_STATE = "s" COMPRESSED_STATE_ATTRIBUTES = "a" COMPRESSED_STATE_CONTEXT = "c" diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index c3e5f6bb5f5..f546ba5eec6 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -9,7 +9,6 @@ import voluptuous as vol from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.json import JSON_DUMP from homeassistant.util.json import ( find_paths_unserializable_data, format_unserializable_data, @@ -194,15 +193,15 @@ def compressed_state_dict_add(state: State) -> dict[str, Any]: def message_to_json(message: dict[str, Any]) -> str: """Serialize a websocket message to json.""" try: - return JSON_DUMP(message) + return const.JSON_DUMP(message) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(message, dump=JSON_DUMP) + find_paths_unserializable_data(message, dump=const.JSON_DUMP) ), ) - return JSON_DUMP( + return const.JSON_DUMP( error_message( message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 2e56698db41..eaabb002b0a 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -14,7 +14,6 @@ from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout import async_timeout -import orjson from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ @@ -98,7 +97,6 @@ def _async_create_clientsession( """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( connector=_async_get_connector(hass, verify_ssl), - json_serialize=lambda x: orjson.dumps(x).decode("utf-8"), **kwargs, ) # Prevent packages accidentally overriding our default headers diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 912667a13b5..c581e5a9361 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -1,10 +1,7 @@ """Helpers to help with encoding Home Assistant objects in JSON.""" import datetime import json -from pathlib import Path -from typing import Any, Final - -import orjson +from typing import Any class JSONEncoder(json.JSONEncoder): @@ -25,20 +22,6 @@ class JSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, o) -def json_encoder_default(obj: Any) -> Any: - """Convert Home Assistant objects. - - Hand other objects to the original method. - """ - if isinstance(obj, set): - return list(obj) - if hasattr(obj, "as_dict"): - return obj.as_dict() - if isinstance(obj, Path): - return obj.as_posix() - raise TypeError - - class ExtendedJSONEncoder(JSONEncoder): """JSONEncoder that supports Home Assistant objects and falls back to repr(o).""" @@ -57,31 +40,3 @@ class ExtendedJSONEncoder(JSONEncoder): return super().default(o) except TypeError: return {"__type": str(type(o)), "repr": repr(o)} - - -def json_bytes(data: Any) -> bytes: - """Dump json bytes.""" - return orjson.dumps( - data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default - ) - - -def json_dumps(data: Any) -> str: - """Dump json string. - - orjson supports serializing dataclasses natively which - eliminates the need to implement as_dict in many places - when the data is already in a dataclass. This works - well as long as all the data in the dataclass can also - be serialized. - - If it turns out to be a problem we can disable this - with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it - will fallback to as_dict - """ - return orjson.dumps( - data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default - ).decode("utf-8") - - -JSON_DUMP: Final = json_dumps diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c158d26a9aa..a3d8a00bcfb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,6 @@ httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.7 -orjson==3.6.8 paho-mqtt==1.6.1 pillow==9.1.1 pip>=21.0,<22.2 diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index efbfec5e961..a681b3e210d 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -12,13 +12,14 @@ from timeit import default_timer as timer from typing import TypeVar from homeassistant import core +from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.helpers.entityfilter import convert_include_exclude_filter from homeassistant.helpers.event import ( async_track_state_change, async_track_state_change_event, ) -from homeassistant.helpers.json import JSON_DUMP, JSONEncoder +from homeassistant.helpers.json import JSONEncoder # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs # mypy: no-warn-return-any diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 82ecfd34d6d..fdee7a7a90f 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -7,8 +7,6 @@ import json import logging from typing import Any -import orjson - from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError @@ -32,7 +30,7 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: """ try: with open(filename, encoding="utf-8") as fdesc: - return orjson.loads(fdesc.read()) # type: ignore[no-any-return] + return json.loads(fdesc.read()) # type: ignore[no-any-return] except FileNotFoundError: # This is not a fatal error _LOGGER.debug("JSON file not found: %s", filename) @@ -58,10 +56,7 @@ def save_json( Returns True on success. """ try: - if encoder: - json_data = json.dumps(data, indent=2, cls=encoder) - else: - json_data = orjson.dumps(data, option=orjson.OPT_INDENT_2).decode("utf-8") + json_data = json.dumps(data, indent=4, cls=encoder) except TypeError as error: msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data))}" _LOGGER.error(msg) diff --git a/pyproject.toml b/pyproject.toml index 7e62bafd6af..cc745f58ad6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,6 @@ dependencies = [ "PyJWT==2.4.0", # PyJWT has loose dependency. We want the latest one. "cryptography==36.0.2", - "orjson==3.6.8", "pip>=21.0,<22.2", "python-slugify==4.0.1", "pyyaml==6.0", @@ -120,7 +119,6 @@ extension-pkg-allow-list = [ "av.audio.stream", "av.stream", "ciso8601", - "orjson", "cv2", ] diff --git a/requirements.txt b/requirements.txt index 9805ae7cd47..fe2bf87ad25 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,6 @@ ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 cryptography==36.0.2 -orjson==3.6.8 pip>=21.0,<22.2 python-slugify==4.0.1 pyyaml==6.0 diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index e802688daaf..37ebe4147c5 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -4,7 +4,6 @@ from unittest.mock import patch import pytest from homeassistant.components.energy import async_get_manager, validate -from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component @@ -409,11 +408,7 @@ async def test_validation_grid( }, ) - result = await validate.async_validate(hass) - # verify its also json serializable - JSON_DUMP(result) - - assert result.as_dict() == { + assert (await validate.async_validate(hass)).as_dict() == { "energy_sources": [ [ { diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 0f4695596fc..4d3302f7c13 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -619,15 +619,12 @@ async def test_states_filters_visible(hass, hass_admin_user, websocket_client): async def test_get_states_not_allows_nan(hass, websocket_client): - """Test get_states command converts NaN to None.""" + """Test get_states command not allows NaN floats.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") await websocket_client.send_json({"id": 5, "type": "get_states"}) - bad = dict(hass.states.get("greeting.bad").as_dict()) - bad["attributes"] = dict(bad["attributes"]) - bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -635,7 +632,6 @@ async def test_get_states_not_allows_nan(hass, websocket_client): assert msg["success"] assert msg["result"] == [ hass.states.get("greeting.hello").as_dict(), - bad, hass.states.get("greeting.bye").as_dict(), ] From a8da0eedd32ac8198f06d4e32622d0f8b40b4a41 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 31 May 2022 23:04:47 +0200 Subject: [PATCH 142/947] Add comment for editable installs (#72782) --- setup.cfg | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.cfg b/setup.cfg index dbf815a56e9..b1a2172f8f1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,3 +1,6 @@ +# Setuptools v62.3 doesn't support editable installs with just 'pyproject.toml' (PEP 660). +# Keep this file until it does! + [metadata] url = https://www.home-assistant.io/ From 856e1144c992b07f3d656b2419738fa38302aae7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 11:35:28 -1000 Subject: [PATCH 143/947] Ensure the statistics_meta table is using the dynamic row format (#72784) --- homeassistant/components/recorder/migration.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index eadfc543b59..bc636d34b10 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -712,6 +712,17 @@ def _apply_update( # noqa: C901 elif new_version == 29: # Recreate statistics_meta index to block duplicated statistic_id _drop_index(session_maker, "statistics_meta", "ix_statistics_meta_statistic_id") + if engine.dialect.name == SupportedDialect.MYSQL: + # Ensure the row format is dynamic or the index + # unique will be too large + with session_scope(session=session_maker()) as session: + connection = session.connection() + # This is safe to run multiple times and fast since the table is small + connection.execute( + text( + "ALTER TABLE statistics_meta ENGINE=InnoDB, ROW_FORMAT=DYNAMIC" + ) + ) try: _create_index( session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" From 3258d572b04e6d304b64709b24ca50ab7b358c72 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 31 May 2022 23:46:12 +0200 Subject: [PATCH 144/947] Add re-auth flow to Tankerkoenig (#72682) * add reauth flow * add test * use Mapping for async_step_reauth arguments * only update changed data * improve tests * use different api key to test reauth --- .../components/tankerkoenig/__init__.py | 4 +- .../components/tankerkoenig/config_flow.py | 41 ++++++++++++++ .../components/tankerkoenig/strings.json | 8 ++- .../tankerkoenig/translations/en.json | 9 ++- .../tankerkoenig/test_config_flow.py | 55 ++++++++++++++++++- 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py index e63add83fad..b7a15e82ea8 100644 --- a/homeassistant/components/tankerkoenig/__init__.py +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -23,7 +23,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -187,6 +187,8 @@ class TankerkoenigDataUpdateCoordinator(DataUpdateCoordinator): try: station_data = pytankerkoenig.getStationData(self._api_key, station_id) except pytankerkoenig.customException as err: + if any(x in str(err).lower() for x in ("api-key", "apikey")): + raise ConfigEntryAuthFailed(err) from err station_data = { "ok": False, "message": err, diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index dd5893fe35f..77baeddfce9 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -144,6 +144,28 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): options={CONF_SHOW_ON_MAP: True}, ) + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Perform reauth confirm upon an API authentication error.""" + if not user_input: + return self._show_form_reauth() + + entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + user_input = {**entry.data, **user_input} + data = await async_get_nearby_stations(self.hass, user_input) + if not data.get("ok"): + return self._show_form_reauth(user_input, {CONF_API_KEY: "invalid_auth"}) + + self.hass.config_entries.async_update_entry(entry, data=user_input) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + def _show_form_user( self, user_input: dict[str, Any] | None = None, @@ -190,6 +212,25 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + def _show_form_reauth( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, Any] | None = None, + ) -> FlowResult: + if user_input is None: + user_input = {} + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required( + CONF_API_KEY, default=user_input.get(CONF_API_KEY, "") + ): cv.string, + } + ), + errors=errors, + ) + def _create_entry( self, data: dict[str, Any], options: dict[str, Any] ) -> FlowResult: diff --git a/homeassistant/components/tankerkoenig/strings.json b/homeassistant/components/tankerkoenig/strings.json index 5e0c367c192..e0b9b3d53e8 100644 --- a/homeassistant/components/tankerkoenig/strings.json +++ b/homeassistant/components/tankerkoenig/strings.json @@ -11,6 +11,11 @@ "radius": "Search radius" } }, + "reauth_confirm": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + }, "select_station": { "title": "Select stations to add", "description": "found {stations_count} stations in radius", @@ -20,7 +25,8 @@ } }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", diff --git a/homeassistant/components/tankerkoenig/translations/en.json b/homeassistant/components/tankerkoenig/translations/en.json index 432ad4481c8..9a69c8c812e 100644 --- a/homeassistant/components/tankerkoenig/translations/en.json +++ b/homeassistant/components/tankerkoenig/translations/en.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Location is already configured" + "already_configured": "Location is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "invalid_auth": "Invalid authentication", "no_stations": "Could not find any station in range." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + } + }, "select_station": { "data": { "stations": "Stations" @@ -31,7 +37,6 @@ "step": { "init": { "data": { - "scan_interval": "Update Interval", "show_on_map": "Show stations on map", "stations": "Stations" }, diff --git a/tests/components/tankerkoenig/test_config_flow.py b/tests/components/tankerkoenig/test_config_flow.py index f48a09fd64b..cae78a447f8 100644 --- a/tests/components/tankerkoenig/test_config_flow.py +++ b/tests/components/tankerkoenig/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant.components.tankerkoenig.const import ( CONF_STATIONS, DOMAIN, ) -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, @@ -222,6 +222,59 @@ async def test_import(hass: HomeAssistant): assert mock_setup_entry.called +async def test_reauth(hass: HomeAssistant): + """Test starting a flow by user to re-auth.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + data={**MOCK_USER_DATA, **MOCK_STATIONS_DATA}, + unique_id=f"{MOCK_USER_DATA[CONF_LOCATION][CONF_LATITUDE]}_{MOCK_USER_DATA[CONF_LOCATION][CONF_LONGITUDE]}", + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.tankerkoenig.async_setup_entry", return_value=True + ) as mock_setup_entry, patch( + "homeassistant.components.tankerkoenig.config_flow.getNearbyStations", + ) as mock_nearby_stations: + # re-auth initialized + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "entry_id": mock_config.entry_id}, + data=mock_config.data, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + + # re-auth unsuccessful + mock_nearby_stations.return_value = {"ok": False} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx", + }, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {CONF_API_KEY: "invalid_auth"} + + # re-auth successful + mock_nearby_stations.return_value = MOCK_NEARVY_STATIONS_OK + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_API_KEY: "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx", + }, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + mock_setup_entry.assert_called() + + entry = hass.config_entries.async_get_entry(mock_config.entry_id) + assert entry.data[CONF_API_KEY] == "269534f6-aaaa-bbbb-cccc-yyyyzzzzxxxx" + + async def test_options_flow(hass: HomeAssistant): """Test options flow.""" From 02068a201321f54ec26d46137ffa669ce09d970b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 31 May 2022 23:57:16 +0200 Subject: [PATCH 145/947] Stringify mikrotik device_tracker name (#72788) Co-authored-by: J. Nick Koston --- homeassistant/components/mikrotik/device_tracker.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index dee4a5de08d..16c3ed233d8 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -102,7 +102,8 @@ class MikrotikHubTracker(ScannerEntity): @property def name(self) -> str: """Return the name of the client.""" - return self.device.name + # Stringify to ensure we return a string + return str(self.device.name) @property def hostname(self) -> str: From 6d74149f22e7211173412682d999b500ccbeff42 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 31 May 2022 14:58:45 -0700 Subject: [PATCH 146/947] Sync entities when enabling/disabling Google Assistant (#72791) --- homeassistant/components/cloud/google_config.py | 9 ++++++++- homeassistant/components/google_assistant/helpers.py | 3 +++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 8f190103e87..f30be66cb42 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -195,6 +195,8 @@ class CloudGoogleConfig(AbstractConfig): ): await async_setup_component(self.hass, GOOGLE_DOMAIN, {}) + sync_entities = False + if self.should_report_state != self.is_reporting_state: if self.should_report_state: self.async_enable_report_state() @@ -203,7 +205,7 @@ class CloudGoogleConfig(AbstractConfig): # State reporting is reported as a property on entities. # So when we change it, we need to sync all entities. - await self.async_sync_entities_all() + sync_entities = True # If entity prefs are the same or we have filter in config.yaml, # don't sync. @@ -215,12 +217,17 @@ class CloudGoogleConfig(AbstractConfig): if self.enabled and not self.is_local_sdk_active: self.async_enable_local_sdk() + sync_entities = True elif not self.enabled and self.is_local_sdk_active: self.async_disable_local_sdk() + sync_entities = True self._cur_entity_prefs = prefs.google_entity_configs self._cur_default_expose = prefs.google_default_expose + if sync_entities: + await self.async_sync_entities_all() + @callback def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index b425367f5c3..15a8d832403 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -213,6 +213,9 @@ class AbstractConfig(ABC): async def async_sync_entities_all(self): """Sync all entities to Google for all registered agents.""" + if not self._store.agent_user_ids: + return 204 + res = await gather( *( self.async_sync_entities(agent_user_id) From 0df9cc907ad936df700ae61f6a4dd47514772203 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 1 Jun 2022 00:27:00 +0000 Subject: [PATCH 147/947] [ci skip] Translation update --- .../components/generic/translations/es.json | 1 + .../generic/translations/zh-Hant.json | 2 ++ .../components/hassio/translations/es.json | 1 + .../components/ialarm_xr/translations/ca.json | 1 + .../components/ialarm_xr/translations/es.json | 21 ++++++++++++++++++ .../components/ialarm_xr/translations/it.json | 1 + .../components/ialarm_xr/translations/ja.json | 1 + .../ialarm_xr/translations/zh-Hant.json | 22 +++++++++++++++++++ .../isy994/translations/zh-Hant.json | 2 +- .../components/plugwise/translations/ca.json | 6 ++++- .../components/plugwise/translations/es.json | 6 ++++- .../components/plugwise/translations/it.json | 10 ++++++--- .../components/plugwise/translations/ja.json | 6 ++++- .../plugwise/translations/zh-Hant.json | 10 ++++++--- .../components/recorder/translations/es.json | 1 + .../tankerkoenig/translations/en.json | 1 + .../tankerkoenig/translations/es.json | 3 ++- .../tankerkoenig/translations/pt-BR.json | 8 ++++++- .../tankerkoenig/translations/zh-Hant.json | 3 ++- .../totalconnect/translations/es.json | 11 ++++++++++ .../totalconnect/translations/it.json | 2 +- .../totalconnect/translations/zh-Hant.json | 11 ++++++++++ 22 files changed, 116 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/ialarm_xr/translations/es.json create mode 100644 homeassistant/components/ialarm_xr/translations/zh-Hant.json diff --git a/homeassistant/components/generic/translations/es.json b/homeassistant/components/generic/translations/es.json index 4d8019002bb..d0e2087acf7 100644 --- a/homeassistant/components/generic/translations/es.json +++ b/homeassistant/components/generic/translations/es.json @@ -39,6 +39,7 @@ "already_exists": "Ya hay una c\u00e1mara con esa URL de configuraci\u00f3n.", "stream_no_route_to_host": "No se pudo encontrar el anfitri\u00f3n mientras intentaba conectar al flujo de datos", "stream_unauthorised": "La autorizaci\u00f3n ha fallado mientras se intentaba conectar con el flujo de datos", + "template_error": "Error al renderizar la plantilla. Revise el registro para m\u00e1s informaci\u00f3n.", "timeout": "El tiempo m\u00e1ximo de carga de la URL ha expirado", "unknown": "Error inesperado" }, diff --git a/homeassistant/components/generic/translations/zh-Hant.json b/homeassistant/components/generic/translations/zh-Hant.json index d1079724252..595bf019f64 100644 --- a/homeassistant/components/generic/translations/zh-Hant.json +++ b/homeassistant/components/generic/translations/zh-Hant.json @@ -15,6 +15,7 @@ "stream_no_video": "\u4e32\u6d41\u6c92\u6709\u5f71\u50cf", "stream_not_permitted": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u4e0d\u5141\u8a31\u64cd\u4f5c\u3002\u8f38\u5165\u932f\u8aa4\u7684 RTSP \u50b3\u8f38\u5354\u5b9a\uff1f", "stream_unauthorised": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u8a8d\u8b49\u5931\u6557", + "template_error": "\u6a21\u7248\u6e32\u67d3\u932f\u8aa4\u3001\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8cc7\u6599\u3002", "timeout": "\u8f09\u5165 URL \u903e\u6642\u6642\u9593", "unable_still_load": "\u7121\u6cd5\u7531\u8a2d\u5b9a\u975c\u614b\u5f71\u50cf URL \u8f09\u5165\u6709\u6548\u5f71\u50cf\uff08\u4f8b\u5982\uff1a\u7121\u6548\u4e3b\u6a5f\u3001URL \u6216\u8a8d\u8b49\u5931\u6557\uff09\u3002\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8a0a\u606f\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" @@ -57,6 +58,7 @@ "stream_no_video": "\u4e32\u6d41\u6c92\u6709\u5f71\u50cf", "stream_not_permitted": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u4e0d\u5141\u8a31\u64cd\u4f5c\u3002\u8f38\u5165\u932f\u8aa4\u7684 RTSP \u50b3\u8f38\u5354\u5b9a\uff1f", "stream_unauthorised": "\u5617\u8a66\u4e32\u6d41\u9023\u7dda\u6642\u8a8d\u8b49\u5931\u6557", + "template_error": "\u6a21\u7248\u6e32\u67d3\u932f\u8aa4\u3001\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8cc7\u6599\u3002", "timeout": "\u8f09\u5165 URL \u903e\u6642\u6642\u9593", "unable_still_load": "\u7121\u6cd5\u7531\u8a2d\u5b9a\u975c\u614b\u5f71\u50cf URL \u8f09\u5165\u6709\u6548\u5f71\u50cf\uff08\u4f8b\u5982\uff1a\u7121\u6548\u4e3b\u6a5f\u3001URL \u6216\u8a8d\u8b49\u5931\u6557\uff09\u3002\u8acb\u53c3\u95b1\u65e5\u8a8c\u4ee5\u7372\u5f97\u66f4\u8a73\u7d30\u8a0a\u606f\u3002", "unknown": "\u672a\u9810\u671f\u932f\u8aa4" diff --git a/homeassistant/components/hassio/translations/es.json b/homeassistant/components/hassio/translations/es.json index da3730fa45b..4a6fda89d84 100644 --- a/homeassistant/components/hassio/translations/es.json +++ b/homeassistant/components/hassio/translations/es.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "agent_version": "Versi\u00f3n del agente", "board": "Placa", "disk_total": "Disco total", "disk_used": "Disco usado", diff --git a/homeassistant/components/ialarm_xr/translations/ca.json b/homeassistant/components/ialarm_xr/translations/ca.json index 6c5ca634ccc..957004fc152 100644 --- a/homeassistant/components/ialarm_xr/translations/ca.json +++ b/homeassistant/components/ialarm_xr/translations/ca.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", + "timeout": "Temps m\u00e0xim d'espera per establir la connexi\u00f3 esgotat", "unknown": "Error inesperat" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/es.json b/homeassistant/components/ialarm_xr/translations/es.json new file mode 100644 index 00000000000..f5755835d3d --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Fallo en la conexi\u00f3n", + "timeout": "Tiempo de espera para establecer la conexi\u00f3n", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "port": "Puerto", + "username": "Nombre de usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/it.json b/homeassistant/components/ialarm_xr/translations/it.json index 52fb89a1d54..ae8437aaa86 100644 --- a/homeassistant/components/ialarm_xr/translations/it.json +++ b/homeassistant/components/ialarm_xr/translations/it.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "Impossibile connettersi", + "timeout": "Tempo scaduto per stabile la connessione.", "unknown": "Errore imprevisto" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/ja.json b/homeassistant/components/ialarm_xr/translations/ja.json index 65aac61f40f..e6f384ed615 100644 --- a/homeassistant/components/ialarm_xr/translations/ja.json +++ b/homeassistant/components/ialarm_xr/translations/ja.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "timeout": "\u63a5\u7d9a\u78ba\u7acb\u6642\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" }, "step": { diff --git a/homeassistant/components/ialarm_xr/translations/zh-Hant.json b/homeassistant/components/ialarm_xr/translations/zh-Hant.json new file mode 100644 index 00000000000..b47b6268af1 --- /dev/null +++ b/homeassistant/components/ialarm_xr/translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "timeout": "\u5efa\u7acb\u9023\u7dda\u903e\u6642", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef", + "password": "\u5bc6\u78bc", + "port": "\u901a\u8a0a\u57e0", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/isy994/translations/zh-Hant.json b/homeassistant/components/isy994/translations/zh-Hant.json index c22bbd1b58d..f6625c0bb60 100644 --- a/homeassistant/components/isy994/translations/zh-Hant.json +++ b/homeassistant/components/isy994/translations/zh-Hant.json @@ -17,7 +17,7 @@ "password": "\u5bc6\u78bc", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "{username} \u6191\u8b49\u4e0d\u518d\u6709\u6548\u3002", + "description": "{host} \u6191\u8b49\u4e0d\u518d\u6709\u6548\u3002", "title": "\u91cd\u65b0\u8a8d\u8b49 ISY \u5e33\u865f" }, "user": { diff --git a/homeassistant/components/plugwise/translations/ca.json b/homeassistant/components/plugwise/translations/ca.json index 85f7c357803..bd7371210ee 100644 --- a/homeassistant/components/plugwise/translations/ca.json +++ b/homeassistant/components/plugwise/translations/ca.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "Tipus de connexi\u00f3" + "flow_type": "Tipus de connexi\u00f3", + "host": "Adre\u00e7a IP", + "password": "ID de Smile", + "port": "Port", + "username": "Usuari de Smile" }, "description": "Producte:", "title": "Tipus de Plugwise" diff --git a/homeassistant/components/plugwise/translations/es.json b/homeassistant/components/plugwise/translations/es.json index e1d1bf3359b..8fed1713398 100644 --- a/homeassistant/components/plugwise/translations/es.json +++ b/homeassistant/components/plugwise/translations/es.json @@ -12,7 +12,11 @@ "step": { "user": { "data": { - "flow_type": "Tipo de conexi\u00f3n" + "flow_type": "Tipo de conexi\u00f3n", + "host": "Direcci\u00f3n IP", + "password": "ID de sonrisa", + "port": "Puerto", + "username": "Nombre de usuario de la sonrisa" }, "description": "Producto:", "title": "Conectarse a Smile" diff --git a/homeassistant/components/plugwise/translations/it.json b/homeassistant/components/plugwise/translations/it.json index 9e051a29e13..24e75f3e846 100644 --- a/homeassistant/components/plugwise/translations/it.json +++ b/homeassistant/components/plugwise/translations/it.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Tipo di connessione" + "flow_type": "Tipo di connessione", + "host": "Indirizzo IP", + "password": "ID Smile", + "port": "Porta", + "username": "Nome utente Smile" }, - "description": "Prodotto:", - "title": "Tipo di Plugwise" + "description": "Inserisci", + "title": "Connettiti allo Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/ja.json b/homeassistant/components/plugwise/translations/ja.json index f15d62d38d1..87b8d501e0d 100644 --- a/homeassistant/components/plugwise/translations/ja.json +++ b/homeassistant/components/plugwise/translations/ja.json @@ -13,7 +13,11 @@ "step": { "user": { "data": { - "flow_type": "\u63a5\u7d9a\u30bf\u30a4\u30d7" + "flow_type": "\u63a5\u7d9a\u30bf\u30a4\u30d7", + "host": "IP\u30a2\u30c9\u30ec\u30b9", + "password": "Smile\u306eID", + "port": "\u30dd\u30fc\u30c8", + "username": "Smile\u306e\u30e6\u30fc\u30b6\u30fc\u540d" }, "description": "\u30d7\u30ed\u30c0\u30af\u30c8:", "title": "Plugwise type" diff --git a/homeassistant/components/plugwise/translations/zh-Hant.json b/homeassistant/components/plugwise/translations/zh-Hant.json index 57a7add22e0..9d37a79462b 100644 --- a/homeassistant/components/plugwise/translations/zh-Hant.json +++ b/homeassistant/components/plugwise/translations/zh-Hant.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "\u9023\u7dda\u985e\u5225" + "flow_type": "\u9023\u7dda\u985e\u5225", + "host": "IP \u4f4d\u5740", + "password": "Smile ID", + "port": "\u901a\u8a0a\u57e0", + "username": "Smile \u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u7522\u54c1\uff1a", - "title": "Plugwise \u985e\u5225" + "description": "\u8acb\u8f38\u5165", + "title": "\u9023\u7dda\u81f3 Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/recorder/translations/es.json b/homeassistant/components/recorder/translations/es.json index 81bcf29d548..ef195cd52c9 100644 --- a/homeassistant/components/recorder/translations/es.json +++ b/homeassistant/components/recorder/translations/es.json @@ -2,6 +2,7 @@ "system_health": { "info": { "current_recorder_run": "Hora de inicio de la ejecuci\u00f3n actual", + "database_version": "Versi\u00f3n de la base de datos", "estimated_db_size": "Mida estimada de la base de datos (MiB)" } } diff --git a/homeassistant/components/tankerkoenig/translations/en.json b/homeassistant/components/tankerkoenig/translations/en.json index 9a69c8c812e..64c585f838b 100644 --- a/homeassistant/components/tankerkoenig/translations/en.json +++ b/homeassistant/components/tankerkoenig/translations/en.json @@ -37,6 +37,7 @@ "step": { "init": { "data": { + "scan_interval": "Update Interval", "show_on_map": "Show stations on map", "stations": "Stations" }, diff --git a/homeassistant/components/tankerkoenig/translations/es.json b/homeassistant/components/tankerkoenig/translations/es.json index ec97b5886d3..a9d235710da 100644 --- a/homeassistant/components/tankerkoenig/translations/es.json +++ b/homeassistant/components/tankerkoenig/translations/es.json @@ -25,7 +25,8 @@ "step": { "init": { "data": { - "show_on_map": "Muestra las estaciones en el mapa" + "show_on_map": "Muestra las estaciones en el mapa", + "stations": "Estaciones" } } } diff --git a/homeassistant/components/tankerkoenig/translations/pt-BR.json b/homeassistant/components/tankerkoenig/translations/pt-BR.json index 699b98812d4..cad98a4283f 100644 --- a/homeassistant/components/tankerkoenig/translations/pt-BR.json +++ b/homeassistant/components/tankerkoenig/translations/pt-BR.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Localiza\u00e7\u00e3o j\u00e1 est\u00e1 configurada" + "already_configured": "Localiza\u00e7\u00e3o j\u00e1 est\u00e1 configurada", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "error": { "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "no_stations": "N\u00e3o foi poss\u00edvel encontrar nenhum posto ao alcance." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Chave API" + } + }, "select_station": { "data": { "stations": "Postos de combustiveis" diff --git a/homeassistant/components/tankerkoenig/translations/zh-Hant.json b/homeassistant/components/tankerkoenig/translations/zh-Hant.json index 059d07ccdc6..2a0e5b5d5c6 100644 --- a/homeassistant/components/tankerkoenig/translations/zh-Hant.json +++ b/homeassistant/components/tankerkoenig/translations/zh-Hant.json @@ -32,7 +32,8 @@ "init": { "data": { "scan_interval": "\u66f4\u65b0\u983b\u7387", - "show_on_map": "\u65bc\u5730\u5716\u986f\u793a\u52a0\u6cb9\u7ad9" + "show_on_map": "\u65bc\u5730\u5716\u986f\u793a\u52a0\u6cb9\u7ad9", + "stations": "\u52a0\u6cb9\u7ad9" }, "title": "Tankerkoenig \u9078\u9805" } diff --git a/homeassistant/components/totalconnect/translations/es.json b/homeassistant/components/totalconnect/translations/es.json index 7983aa3a11e..66f5be9ebc2 100644 --- a/homeassistant/components/totalconnect/translations/es.json +++ b/homeassistant/components/totalconnect/translations/es.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "Ignorar autom\u00e1ticamente bater\u00eda baja" + }, + "description": "Ignorar autom\u00e1ticamente las zonas en el momento en que informan de que la bater\u00eda est\u00e1 baja.", + "title": "Opciones de TotalConnect" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/it.json b/homeassistant/components/totalconnect/translations/it.json index 13a95fa9cff..dc5c1362e9c 100644 --- a/homeassistant/components/totalconnect/translations/it.json +++ b/homeassistant/components/totalconnect/translations/it.json @@ -33,7 +33,7 @@ "step": { "init": { "data": { - "auto_bypass_low_battery": "Bypass automatico della batteria scarica" + "auto_bypass_low_battery": "Esclusione automatica della batteria scarica" }, "description": "Esclusione automatica delle zone nel momento in cui segnalano una batteria scarica.", "title": "Opzioni TotalConnect" diff --git a/homeassistant/components/totalconnect/translations/zh-Hant.json b/homeassistant/components/totalconnect/translations/zh-Hant.json index 3b960a8bc43..5e471fb1746 100644 --- a/homeassistant/components/totalconnect/translations/zh-Hant.json +++ b/homeassistant/components/totalconnect/translations/zh-Hant.json @@ -28,5 +28,16 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "auto_bypass_low_battery": "\u81ea\u52d5\u5ffd\u7565\u4f4e\u96fb\u91cf" + }, + "description": "\u7576\u56de\u5831\u4f4e\u96fb\u91cf\u6642\u81ea\u52d5\u5ffd\u7565\u5340\u57df\u3002", + "title": "TotalConnect \u9078\u9805" + } + } } } \ No newline at end of file From 44f332ed5f03fcb9bf7f8a8ed5b91715bcee75b4 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Jun 2022 05:35:56 +0200 Subject: [PATCH 148/947] Improve cast HLS detection (#72787) --- homeassistant/components/cast/helpers.py | 9 +++++---- tests/components/cast/test_helpers.py | 18 +++++++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index dfeb9fce25b..d7419f69563 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -266,10 +266,8 @@ async def parse_m3u(hass, url): hls_content_types = ( # https://tools.ietf.org/html/draft-pantos-http-live-streaming-19#section-10 "application/vnd.apple.mpegurl", - # Some sites serve these as the informal HLS m3u type. - "application/x-mpegurl", - "audio/mpegurl", - "audio/x-mpegurl", + # Additional informal types used by Mozilla gecko not included as they + # don't reliably indicate HLS streams ) m3u_data = await _fetch_playlist(hass, url, hls_content_types) m3u_lines = m3u_data.splitlines() @@ -292,6 +290,9 @@ async def parse_m3u(hass, url): elif line.startswith("#EXT-X-VERSION:"): # HLS stream, supported by cast devices raise PlaylistSupported("HLS") + elif line.startswith("#EXT-X-STREAM-INF:"): + # HLS stream, supported by cast devices + raise PlaylistSupported("HLS") elif line.startswith("#"): # Ignore other extensions continue diff --git a/tests/components/cast/test_helpers.py b/tests/components/cast/test_helpers.py index d729d36a225..8ae73449b43 100644 --- a/tests/components/cast/test_helpers.py +++ b/tests/components/cast/test_helpers.py @@ -27,6 +27,11 @@ from tests.common import load_fixture "rthkaudio2.m3u8", "application/vnd.apple.mpegurl", ), + ( + "https://rthkaudio2-lh.akamaihd.net/i/radio2_1@355865/master.m3u8", + "rthkaudio2.m3u8", + None, + ), ), ) async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, content_type): @@ -38,11 +43,12 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten @pytest.mark.parametrize( - "url,fixture,expected_playlist", + "url,fixture,content_type,expected_playlist", ( ( "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", "209-hi-mp3.m3u", + "audio/x-mpegurl", [ PlaylistItem( length=["-1"], @@ -54,6 +60,7 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ( "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", "209-hi-mp3_bad_extinf.m3u", + "audio/x-mpegurl", [ PlaylistItem( length=None, @@ -65,6 +72,7 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ( "https://sverigesradio.se/topsy/direkt/209-hi-mp3.m3u", "209-hi-mp3_no_extinf.m3u", + "audio/x-mpegurl", [ PlaylistItem( length=None, @@ -76,6 +84,7 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ( "http://sverigesradio.se/topsy/direkt/164-hi-aac.pls", "164-hi-aac.pls", + "audio/x-mpegurl", [ PlaylistItem( length="-1", @@ -86,9 +95,12 @@ async def test_hls_playlist_supported(hass, aioclient_mock, url, fixture, conten ), ), ) -async def test_parse_playlist(hass, aioclient_mock, url, fixture, expected_playlist): +async def test_parse_playlist( + hass, aioclient_mock, url, fixture, content_type, expected_playlist +): """Test playlist parsing of HLS playlist.""" - aioclient_mock.get(url, text=load_fixture(fixture, "cast")) + headers = {"content-type": content_type} + aioclient_mock.get(url, text=load_fixture(fixture, "cast"), headers=headers) playlist = await parse_playlist(hass, url) assert expected_playlist == playlist From 275ea5b150bc6fb22ced52619fd50f6f8511ff9a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 31 May 2022 22:36:45 -0500 Subject: [PATCH 149/947] Support add/next/play/replace enqueue options in Sonos (#72800) --- .../components/sonos/media_player.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index fd37e546105..b970b32b87a 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -576,6 +576,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self.set_shuffle(True) if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: plex_plugin.add_to_queue(result.media) + elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = plex_plugin.add_to_queue(result.media, position=pos) + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) else: soco.clear_queue() plex_plugin.add_to_queue(result.media) @@ -586,6 +594,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if share_link.is_share_link(media_id): if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: share_link.add_share_link_to_queue(media_id) + elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = share_link.add_share_link_to_queue(media_id, position=pos) + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) else: soco.clear_queue() share_link.add_share_link_to_queue(media_id) @@ -596,6 +612,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: soco.add_uri_to_queue(media_id) + elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + MediaPlayerEnqueue.NEXT, + MediaPlayerEnqueue.PLAY, + ): + pos = (self.media.queue_position or 0) + 1 + new_pos = soco.add_uri_to_queue(media_id, position=pos) + if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + soco.play_from_queue(new_pos - 1) else: soco.play_uri(media_id, force_radio=is_radio) elif media_type == MEDIA_TYPE_PLAYLIST: From f6517884b15ca09b17da63705ff0b4af4640ec20 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 1 Jun 2022 04:40:42 +0100 Subject: [PATCH 150/947] Fix #72749 (#72794) --- homeassistant/components/utility_meter/sensor.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 8df86b3e5a8..d2a2d2ba8ca 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -99,6 +99,13 @@ PAUSED = "paused" COLLECTING = "collecting" +def validate_is_number(value): + """Validate value is a number.""" + if is_number(value): + return value + raise vol.Invalid("Value is not a number") + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -167,7 +174,7 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_CALIBRATE_METER, - {vol.Required(ATTR_VALUE): vol.Coerce(Decimal)}, + {vol.Required(ATTR_VALUE): validate_is_number}, "async_calibrate", ) @@ -244,7 +251,7 @@ async def async_setup_platform( platform.async_register_entity_service( SERVICE_CALIBRATE_METER, - {vol.Required(ATTR_VALUE): vol.Coerce(Decimal)}, + {vol.Required(ATTR_VALUE): validate_is_number}, "async_calibrate", ) @@ -446,8 +453,8 @@ class UtilityMeterSensor(RestoreSensor): async def async_calibrate(self, value): """Calibrate the Utility Meter with a given value.""" - _LOGGER.debug("Calibrate %s = %s", self._name, value) - self._state = value + _LOGGER.debug("Calibrate %s = %s type(%s)", self._name, value, type(value)) + self._state = Decimal(str(value)) self.async_write_ha_state() async def async_added_to_hass(self): From 1ef59d1e73c39db329ddf9dab9105ef16829b2a2 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 31 May 2022 23:39:07 -0500 Subject: [PATCH 151/947] Cleanup handling of new enqueue & announce features in Sonos (#72801) --- .../components/sonos/media_player.py | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index b970b32b87a..cd129d82843 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -25,6 +25,7 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_ALBUM, MEDIA_TYPE_ARTIST, @@ -527,7 +528,9 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): self.coordinator.soco.clear_queue() @soco_error() - def play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + def play_media( # noqa: C901 + self, media_type: str, media_id: str, **kwargs: Any + ) -> None: """ Send the play_media command to the media player. @@ -539,6 +542,12 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): If media_type is "playlist", media_id should be a Sonos Playlist name. Otherwise, media_id should be a URI. """ + # Use 'replace' as the default enqueue option + enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) + if kwargs.get(ATTR_MEDIA_ANNOUNCE): + # Temporary workaround until announce support is added + enqueue = MediaPlayerEnqueue.PLAY + if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) media_id = spotify.spotify_uri_from_media_browser_url(media_id) @@ -574,17 +583,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): ) if result.shuffle: self.set_shuffle(True) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: + if enqueue == MediaPlayerEnqueue.ADD: plex_plugin.add_to_queue(result.media) - elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 new_pos = plex_plugin.add_to_queue(result.media, position=pos) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) - else: + elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() plex_plugin.add_to_queue(result.media) soco.play_from_queue(0) @@ -592,17 +601,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): share_link = self.coordinator.share_link if share_link.is_share_link(media_id): - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: + if enqueue == MediaPlayerEnqueue.ADD: share_link.add_share_link_to_queue(media_id) - elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 new_pos = share_link.add_share_link_to_queue(media_id, position=pos) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) - else: + elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() share_link.add_share_link_to_queue(media_id) soco.play_from_queue(0) @@ -610,17 +619,17 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): # If media ID is a relative URL, we serve it from HA. media_id = async_process_play_media_url(self.hass, media_id) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.ADD: + if enqueue == MediaPlayerEnqueue.ADD: soco.add_uri_to_queue(media_id) - elif kwargs.get(ATTR_MEDIA_ENQUEUE) in ( + elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 new_pos = soco.add_uri_to_queue(media_id, position=pos) - if kwargs.get(ATTR_MEDIA_ENQUEUE) == MediaPlayerEnqueue.PLAY: + if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) - else: + elif enqueue == MediaPlayerEnqueue.REPLACE: soco.play_uri(media_id, force_radio=is_radio) elif media_type == MEDIA_TYPE_PLAYLIST: if media_id.startswith("S:"): From 394442e8a906a327eea1d3713f6d10eda043fdc7 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 1 Jun 2022 00:42:07 -0400 Subject: [PATCH 152/947] Use device_id for zwave_js/replace_failed_node command (#72785) --- homeassistant/components/zwave_js/api.py | 15 +++++------- tests/components/zwave_js/test_api.py | 31 ++++++++++-------------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index f9f1ad40f9a..e6ce08a78a5 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -1083,8 +1083,7 @@ async def websocket_remove_node( @websocket_api.websocket_command( { vol.Required(TYPE): "zwave_js/replace_failed_node", - vol.Required(ENTRY_ID): str, - vol.Required(NODE_ID): int, + vol.Required(DEVICE_ID): str, vol.Optional(INCLUSION_STRATEGY, default=InclusionStrategy.DEFAULT): vol.All( vol.Coerce(int), vol.In( @@ -1107,18 +1106,16 @@ async def websocket_remove_node( ) @websocket_api.async_response @async_handle_failed_command -@async_get_entry +@async_get_node async def websocket_replace_failed_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict, - entry: ConfigEntry, - client: Client, - driver: Driver, + node: Node, ) -> None: """Replace a failed node with a new node.""" - controller = driver.controller - node_id = msg[NODE_ID] + assert node.client.driver + controller = node.client.driver.controller inclusion_strategy = InclusionStrategy(msg[INCLUSION_STRATEGY]) force_security = msg.get(FORCE_SECURITY) provisioning = ( @@ -1232,7 +1229,7 @@ async def websocket_replace_failed_node( try: result = await controller.async_replace_failed_node( - controller.nodes[node_id], + node, INCLUSION_STRATEGY_NOT_SMART_START[inclusion_strategy.value], force_security=force_security, provisioning=provisioning, diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 7f64ed6d87d..da8cfad9624 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -1511,7 +1511,7 @@ async def test_replace_failed_node( dev_reg = dr.async_get(hass) # Create device registry entry for mock node - dev_reg.async_get_or_create( + device = dev_reg.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, "3245146787-67")}, name="Node 67", @@ -1526,8 +1526,7 @@ async def test_replace_failed_node( { ID: 1, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, } ) @@ -1607,10 +1606,12 @@ async def test_replace_failed_node( assert msg["event"]["event"] == "node removed" # Verify device was removed from device registry - device = dev_reg.async_get_device( - identifiers={(DOMAIN, "3245146787-67")}, + assert ( + dev_reg.async_get_device( + identifiers={(DOMAIN, "3245146787-67")}, + ) + is None ) - assert device is None client.driver.receive_event(nortek_thermostat_added_event) msg = await ws_client.receive_json() @@ -1686,8 +1687,7 @@ async def test_replace_failed_node( { ID: 2, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, PLANNED_PROVISIONING_ENTRY: { DSK: "test", @@ -1719,8 +1719,7 @@ async def test_replace_failed_node( { ID: 3, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, QR_PROVISIONING_INFORMATION: { VERSION: 0, @@ -1772,8 +1771,7 @@ async def test_replace_failed_node( { ID: 4, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", } @@ -1800,8 +1798,7 @@ async def test_replace_failed_node( { ID: 6, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", } @@ -1821,8 +1818,7 @@ async def test_replace_failed_node( { ID: 7, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, } ) msg = await ws_client.receive_json() @@ -1839,8 +1835,7 @@ async def test_replace_failed_node( { ID: 8, TYPE: "zwave_js/replace_failed_node", - ENTRY_ID: entry.entry_id, - NODE_ID: 67, + DEVICE_ID: device.id, } ) msg = await ws_client.receive_json() From a6db25219d5c94c1e19b5170145e47daf8c02b91 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Jun 2022 06:58:29 +0200 Subject: [PATCH 153/947] Use Mapping for async_step_reauth in motioneye (#72769) --- homeassistant/components/motioneye/config_flow.py | 5 +++-- tests/components/motioneye/test_config_flow.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 0361f4562c4..6b88f47a588 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -1,6 +1,7 @@ """Config flow for motionEye integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any, cast from motioneye_client.client import ( @@ -158,10 +159,10 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth( self, - config_data: dict[str, Any] | None = None, + config_data: Mapping[str, Any], ) -> FlowResult: """Handle a reauthentication flow.""" - return await self.async_step_user(config_data) + return await self.async_step_user() async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult: """Handle Supervisor discovery.""" diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 9ef0f78874d..269a2b8a4c4 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -259,6 +259,7 @@ async def test_reauth(hass: HomeAssistant) -> None: "source": config_entries.SOURCE_REAUTH, "entry_id": config_entry.entry_id, }, + data=config_entry.data, ) assert result["type"] == "form" assert not result["errors"] From 133cb7ccef96592479b3a867b09e69095e180998 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 1 Jun 2022 02:04:35 -0400 Subject: [PATCH 154/947] Add package constraint for pydantic (#72799) Co-authored-by: J. Nick Koston --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a3d8a00bcfb..c1bbe51755f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -106,3 +106,7 @@ authlib<1.0 # Pin backoff for compatibility until most libraries have been updated # https://github.com/home-assistant/core/pull/70817 backoff<2.0 + +# Breaking change in version +# https://github.com/samuelcolvin/pydantic/issues/4092 +pydantic!=1.9.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index adf57f14f97..88524ab63de 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -126,6 +126,10 @@ authlib<1.0 # Pin backoff for compatibility until most libraries have been updated # https://github.com/home-assistant/core/pull/70817 backoff<2.0 + +# Breaking change in version +# https://github.com/samuelcolvin/pydantic/issues/4092 +pydantic!=1.9.1 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From 577be70da9554993162de9c726b1404c93307b2a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Jun 2022 08:38:48 +0200 Subject: [PATCH 155/947] Add new method to pylint type-hint plugin (#72757) Enforce type hints on remove_config_entry_device --- pylint/plugins/hass_enforce_type_hints.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 24b32f2e238..f3dd7a106c6 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -88,6 +88,15 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { }, return_type="bool", ), + TypeHintMatch( + function_name="async_remove_config_entry_device", + arg_types={ + 0: "HomeAssistant", + 1: "ConfigEntry", + 2: "DeviceEntry", + }, + return_type="bool", + ), ], "__any_platform__": [ TypeHintMatch( From 4902af2f4e7761ddff6706b475ac31c1fd8b5366 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 1 Jun 2022 09:22:47 +0200 Subject: [PATCH 156/947] Fix conftest for pylint plugin (#72777) --- tests/pylint/conftest.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 887f50fb628..edbcec27375 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -1,17 +1,21 @@ """Configuration for pylint tests.""" from importlib.machinery import SourceFileLoader +from pathlib import Path from types import ModuleType from pylint.checkers import BaseChecker from pylint.testutils.unittest_linter import UnittestLinter import pytest +BASE_PATH = Path(__file__).parents[2] -@pytest.fixture(name="hass_enforce_type_hints") + +@pytest.fixture(name="hass_enforce_type_hints", scope="session") def hass_enforce_type_hints_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" loader = SourceFileLoader( - "hass_enforce_type_hints", "pylint/plugins/hass_enforce_type_hints.py" + "hass_enforce_type_hints", + str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), ) return loader.load_module(None) From 9ac0c5907f8e7a4a0906559b482ac450f42892c0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 1 Jun 2022 09:39:56 +0200 Subject: [PATCH 157/947] Add test for mikrotik device tracker with numerical device name (#72808) Add mikrotik test for numerical device name --- tests/components/mikrotik/__init__.py | 44 +++++++------------ .../mikrotik/test_device_tracker.py | 29 +++++++++++- 2 files changed, 43 insertions(+), 30 deletions(-) diff --git a/tests/components/mikrotik/__init__.py b/tests/components/mikrotik/__init__.py index 885fc9c8d83..6f67eea1a0a 100644 --- a/tests/components/mikrotik/__init__.py +++ b/tests/components/mikrotik/__init__.py @@ -45,6 +45,14 @@ DEVICE_2_DHCP = { "host-name": "Device_2", "comment": "PC", } +DEVICE_3_DHCP_NUMERIC_NAME = { + ".id": "*1C", + "address": "0.0.0.3", + "mac-address": "00:00:00:00:00:03", + "active-address": "0.0.0.3", + "host-name": 123, + "comment": "Mobile", +} DEVICE_1_WIRELESS = { ".id": "*264", "interface": "wlan1", @@ -81,38 +89,16 @@ DEVICE_1_WIRELESS = { } DEVICE_2_WIRELESS = { + **DEVICE_1_WIRELESS, ".id": "*265", - "interface": "wlan1", "mac-address": "00:00:00:00:00:02", - "ap": False, - "wds": False, - "bridge": False, - "rx-rate": "72.2Mbps-20MHz/1S/SGI", - "tx-rate": "72.2Mbps-20MHz/1S/SGI", - "packets": "59542,17464", - "bytes": "17536671,2966351", - "frames": "59542,17472", - "frame-bytes": "17655785,2862445", - "hw-frames": "78935,38395", - "hw-frame-bytes": "25636019,4063445", - "tx-frames-timed-out": 0, - "uptime": "5h49m36s", - "last-activity": "170ms", - "signal-strength": "-62@1Mbps", - "signal-to-noise": 52, - "signal-strength-ch0": -63, - "signal-strength-ch1": -69, - "strength-at-rates": "-62@1Mbps 16s330ms,-64@6Mbps 13s560ms,-65@HT20-3 52m6s30ms,-66@HT20-4 52m4s350ms,-66@HT20-5 51m58s580ms,-65@HT20-6 51m24s780ms,-65@HT20-7 5s680ms", - "tx-ccq": 93, - "p-throughput": 54928, "last-ip": "0.0.0.2", - "802.1x-port-enabled": True, - "authentication-type": "wpa2-psk", - "encryption": "aes-ccm", - "group-encryption": "aes-ccm", - "management-protection": False, - "wmm-enabled": True, - "tx-rate-set": "OFDM:6-54 BW:1x SGI:1x HT:0-7", +} +DEVICE_3_WIRELESS = { + **DEVICE_1_WIRELESS, + ".id": "*266", + "mac-address": "00:00:00:00:00:03", + "last-ip": "0.0.0.3", } DHCP_DATA = [DEVICE_1_DHCP, DEVICE_2_DHCP] diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index 715826e69d6..fd2f71b2589 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -9,7 +9,15 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from . import DEVICE_2_WIRELESS, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA +from . import ( + DEVICE_2_WIRELESS, + DEVICE_3_DHCP_NUMERIC_NAME, + DEVICE_3_WIRELESS, + DHCP_DATA, + MOCK_DATA, + MOCK_OPTIONS, + WIRELESS_DATA, +) from .test_hub import setup_mikrotik_entry from tests.common import MockConfigEntry, patch @@ -27,6 +35,7 @@ def mock_device_registry_devices(hass): ( "00:00:00:00:00:01", "00:00:00:00:00:02", + "00:00:00:00:00:03", ) ): dev_reg.async_get_or_create( @@ -115,6 +124,24 @@ async def test_device_trackers(hass, mock_device_registry_devices): assert device_2.state == "not_home" +async def test_device_trackers_numerical_name(hass, mock_device_registry_devices): + """Test device_trackers created by mikrotik with numerical device name.""" + + await setup_mikrotik_entry( + hass, dhcp_data=[DEVICE_3_DHCP_NUMERIC_NAME], wireless_data=[DEVICE_3_WIRELESS] + ) + + device_3 = hass.states.get("device_tracker.123") + assert device_3 is not None + assert device_3.state == "home" + assert device_3.attributes["friendly_name"] == "123" + assert device_3.attributes["ip"] == "0.0.0.3" + assert "ip_address" not in device_3.attributes + assert device_3.attributes["mac"] == "00:00:00:00:00:03" + assert device_3.attributes["host_name"] == 123 + assert "mac_address" not in device_3.attributes + + async def test_restoring_devices(hass): """Test restoring existing device_tracker entities if not detected on startup.""" config_entry = MockConfigEntry( From 1aeba8a9bdbf44c12bf10af564d1901c3137151c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Jun 2022 09:44:22 +0200 Subject: [PATCH 158/947] Use Mapping for async_step_reauth in discord (#72812) * Fix tests * Cleanup code accordingly --- homeassistant/components/discord/config_flow.py | 10 ++++------ tests/components/discord/test_config_flow.py | 7 +------ 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/discord/config_flow.py b/homeassistant/components/discord/config_flow.py index bce27feced3..93027132850 100644 --- a/homeassistant/components/discord/config_flow.py +++ b/homeassistant/components/discord/config_flow.py @@ -1,7 +1,9 @@ """Config flow for Discord integration.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from aiohttp.client_exceptions import ClientConnectorError import nextcord @@ -21,13 +23,9 @@ CONFIG_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) class DiscordFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Discord.""" - async def async_step_reauth(self, user_input: dict | None = None) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" - if user_input is not None: - return await self.async_step_reauth_confirm() - - self._set_confirm_only() - return self.async_show_form(step_id="reauth") + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, str] | None = None diff --git a/tests/components/discord/test_config_flow.py b/tests/components/discord/test_config_flow.py index 59030187866..9d4966929be 100644 --- a/tests/components/discord/test_config_flow.py +++ b/tests/components/discord/test_config_flow.py @@ -128,14 +128,9 @@ async def test_flow_reauth(hass: HomeAssistant) -> None: "entry_id": entry.entry_id, "unique_id": entry.unique_id, }, + data=entry.data, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "reauth" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={}, - ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "reauth_confirm" From e874a043197f7cb539a4d8b7f1d0d36ab5259d9d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 22:56:05 -1000 Subject: [PATCH 159/947] Bump sqlalchemy to 1.4.37 (#72809) Fixes a bug where reconnects might fail with MySQL 8.0.24+ Changelog: https://docs.sqlalchemy.org/en/14/changelog/changelog_14.html#change-1.4.37 --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 0fb44f99ae2..38897c42e1a 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -2,7 +2,7 @@ "domain": "recorder", "name": "Recorder", "documentation": "https://www.home-assistant.io/integrations/recorder", - "requirements": ["sqlalchemy==1.4.36", "fnvhash==0.1.0", "lru-dict==1.1.7"], + "requirements": ["sqlalchemy==1.4.37", "fnvhash==0.1.0", "lru-dict==1.1.7"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index c779e4567cd..4562b945008 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -2,7 +2,7 @@ "domain": "sql", "name": "SQL", "documentation": "https://www.home-assistant.io/integrations/sql", - "requirements": ["sqlalchemy==1.4.36"], + "requirements": ["sqlalchemy==1.4.37"], "codeowners": ["@dgomes", "@gjohansson-ST"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c1bbe51755f..ad2539233f4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ pyudev==0.22.0 pyyaml==6.0 requests==2.27.1 scapy==2.4.5 -sqlalchemy==1.4.36 +sqlalchemy==1.4.37 typing-extensions>=3.10.0.2,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index b6d24b95d5c..f3c55964e5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2223,7 +2223,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.36 +sqlalchemy==1.4.37 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 230ceae7fa0..dca09840bf3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1465,7 +1465,7 @@ spotipy==2.19.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.36 +sqlalchemy==1.4.37 # homeassistant.components.srp_energy srpenergy==1.3.6 From ee861c8ea5e7c4db1289c1c95f7da47009f7afd2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jun 2022 11:35:36 +0200 Subject: [PATCH 160/947] Bump actions/cache from 3.0.2 to 3.0.3 (#72817) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6264c1379fd..b8d81856f3c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -172,7 +172,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: >- @@ -189,7 +189,7 @@ jobs: # ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}- - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: ${{ env.PIP_CACHE }} key: >- @@ -212,7 +212,7 @@ jobs: hashFiles('.pre-commit-config.yaml') }}" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: ${{ env.PRE_COMMIT_CACHE }} key: >- @@ -241,7 +241,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -253,7 +253,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -291,7 +291,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -303,7 +303,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -342,7 +342,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -354,7 +354,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -384,7 +384,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -396,7 +396,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -502,7 +502,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -531,7 +531,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -573,7 +573,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: >- @@ -590,7 +590,7 @@ jobs: # ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}- - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: ${{ env.PIP_CACHE }} key: >- @@ -629,7 +629,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -671,7 +671,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -715,7 +715,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -758,7 +758,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.2 + uses: actions/cache@v3.0.3 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ From d8b037694284cf388b9751b4d54eecfd5452280f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 31 May 2022 23:56:06 -1000 Subject: [PATCH 161/947] Fix purge of legacy database events that are not state changed (#72815) --- homeassistant/components/recorder/queries.py | 2 +- tests/components/recorder/test_purge.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index 5532c5c0703..e27d3d692cc 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -631,7 +631,7 @@ def find_legacy_event_state_and_attributes_and_data_ids_to_purge( lambda: select( Events.event_id, Events.data_id, States.state_id, States.attributes_id ) - .join(States, Events.event_id == States.event_id) + .outerjoin(States, Events.event_id == States.event_id) .filter(Events.time_fired < purge_before) .limit(MAX_ROWS_TO_PURGE) ) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index c8ba5e9d076..f4e998c5388 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -26,7 +26,7 @@ from homeassistant.components.recorder.services import ( ) from homeassistant.components.recorder.tasks import PurgeTask from homeassistant.components.recorder.util import session_scope -from homeassistant.const import EVENT_STATE_CHANGED, STATE_ON +from homeassistant.const import EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util @@ -925,6 +925,15 @@ async def test_purge_without_state_attributes_filtered_states_to_empty( time_fired=timestamp, ) ) + session.add( + Events( + event_id=event_id + 1, + event_type=EVENT_THEMES_UPDATED, + event_data="{}", + origin="LOCAL", + time_fired=timestamp, + ) + ) service_data = {"keep_days": 10} _add_db_entries(hass) From d57a650290181c4f35da96b0bd58df01c1ae8752 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 1 Jun 2022 03:12:54 -0700 Subject: [PATCH 162/947] Don't trigger entity sync when Google Assistant gets disabled (#72805) --- homeassistant/components/cloud/google_config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index f30be66cb42..a0a68aaf84a 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -220,7 +220,6 @@ class CloudGoogleConfig(AbstractConfig): sync_entities = True elif not self.enabled and self.is_local_sdk_active: self.async_disable_local_sdk() - sync_entities = True self._cur_entity_prefs = prefs.google_entity_configs self._cur_default_expose = prefs.google_default_expose From 023990577ceccffeb0596daab4702d1e4f265aec Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 1 Jun 2022 12:33:13 +0200 Subject: [PATCH 163/947] Add Motionblinds WoodShutter support (#72814) --- .../components/motion_blinds/cover.py | 58 +++++++++++++++++++ .../components/motion_blinds/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index a9f6df82ae0..7bac3a5fb20 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -9,6 +9,7 @@ from homeassistant.components.cover import ( ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, + CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -64,6 +65,10 @@ TILT_DEVICE_MAP = { BlindType.VerticalBlindRight: CoverDeviceClass.BLIND, } +TILT_ONLY_DEVICE_MAP = { + BlindType.WoodShutter: CoverDeviceClass.BLIND, +} + TDBU_DEVICE_MAP = { BlindType.TopDownBottomUp: CoverDeviceClass.SHADE, } @@ -108,6 +113,16 @@ async def async_setup_entry( ) ) + elif blind.type in TILT_ONLY_DEVICE_MAP: + entities.append( + MotionTiltOnlyDevice( + coordinator, + blind, + TILT_ONLY_DEVICE_MAP[blind.type], + sw_version, + ) + ) + elif blind.type in TDBU_DEVICE_MAP: entities.append( MotionTDBUDevice( @@ -356,6 +371,49 @@ class MotionTiltDevice(MotionPositionDevice): await self.hass.async_add_executor_job(self._blind.Stop) +class MotionTiltOnlyDevice(MotionTiltDevice): + """Representation of a Motion Blind Device.""" + + _restore_tilt = False + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = ( + CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.STOP_TILT + ) + + if self.current_cover_tilt_position is not None: + supported_features |= CoverEntityFeature.SET_TILT_POSITION + + return supported_features + + @property + def current_cover_position(self): + """Return current position of cover.""" + return None + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + if self._blind.angle is None: + return None + return self._blind.angle == 0 + + async def async_set_absolute_position(self, **kwargs): + """Move the cover to a specific absolute position (see TDBU).""" + angle = kwargs.get(ATTR_TILT_POSITION) + if angle is not None: + angle = angle * 180 / 100 + async with self._api_lock: + await self.hass.async_add_executor_job( + self._blind.Set_angle, + angle, + ) + + class MotionTDBUDevice(MotionPositionDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 1e8ad0eb0a1..bc09d3e9e38 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -3,7 +3,7 @@ "name": "Motion Blinds", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/motion_blinds", - "requirements": ["motionblinds==0.6.7"], + "requirements": ["motionblinds==0.6.8"], "dependencies": ["network"], "dhcp": [ { "registered_devices": true }, diff --git a/requirements_all.txt b/requirements_all.txt index f3c55964e5c..603ad612ae3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1038,7 +1038,7 @@ mitemp_bt==0.0.5 moehlenhoff-alpha2==1.2.1 # homeassistant.components.motion_blinds -motionblinds==0.6.7 +motionblinds==0.6.8 # homeassistant.components.motioneye motioneye-client==0.3.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dca09840bf3..a1fa8d0fdc3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -715,7 +715,7 @@ minio==5.0.10 moehlenhoff-alpha2==1.2.1 # homeassistant.components.motion_blinds -motionblinds==0.6.7 +motionblinds==0.6.8 # homeassistant.components.motioneye motioneye-client==0.3.12 From 5d2326386d15ae422d3ebc2d03f714929c842d4d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Jun 2022 00:33:46 -1000 Subject: [PATCH 164/947] Fix logbook spinner never disappearing when all entities are filtered (#72816) --- .../components/logbook/websocket_api.py | 4 +- .../components/logbook/test_websocket_api.py | 49 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 82b1db1081c..1af44440803 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -356,7 +356,7 @@ async def ws_event_stream( ) await _async_wait_for_recorder_sync(hass) - if not subscriptions: + if msg_id not in connection.subscriptions: # Unsubscribe happened while waiting for recorder return @@ -388,6 +388,8 @@ async def ws_event_stream( if not subscriptions: # Unsubscribe happened while waiting for formatted events + # or there are no supported entities (all UOM or state class) + # or devices return live_stream.task = asyncio.create_task( diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 291c487b35b..2dd08ec44ce 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2089,3 +2089,52 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo assert msg["success"] assert "Recorder is behind" in caplog.text + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_client): + """Test subscribe/unsubscribe logbook stream with entities that are always filtered.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + await async_wait_recording_done(hass) + + init_count = sum(hass.bus.async_listeners().values()) + hass.states.async_set("sensor.uom", "1", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "2", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "3", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": ["sensor.uom"], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + hass.states.async_set("sensor.uom", "1", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "2", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + hass.states.async_set("sensor.uom", "3", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + + await websocket_client.close() + await hass.async_block_till_done() + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count From 4c7837a5762a741100b07d89803340f53b1d04b1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Jun 2022 13:09:53 +0200 Subject: [PATCH 165/947] Enforce type hints for config_flow (#72756) * Enforce type hints for config_flow * Keep astroid migration for another PR * Defer elif case * Adjust tests * Use ancestors * Match on single base_class * Invert for loops * Review comments * slots is new in 3.10 --- pylint/plugins/hass_enforce_type_hints.py | 93 ++++++++++++++++++++++- tests/pylint/test_enforce_type_hints.py | 72 ++++++++++++++++++ 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f3dd7a106c6..31b396c3196 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -25,6 +25,14 @@ class TypeHintMatch: return_type: list[str] | str | None | object +@dataclass +class ClassTypeHintMatch: + """Class for pattern matching.""" + + base_class: str + matches: list[TypeHintMatch] + + _TYPE_HINT_MATCHERS: dict[str, re.Pattern[str]] = { # a_or_b matches items such as "DiscoveryInfoType | None" "a_or_b": re.compile(r"^(\w+) \| (\w+)$"), @@ -368,6 +376,65 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { ], } +_CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { + "config_flow": [ + ClassTypeHintMatch( + base_class="ConfigFlow", + matches=[ + TypeHintMatch( + function_name="async_step_dhcp", + arg_types={ + 1: "DhcpServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_hassio", + arg_types={ + 1: "HassioServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_homekit", + arg_types={ + 1: "ZeroconfServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_mqtt", + arg_types={ + 1: "MqttServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_ssdp", + arg_types={ + 1: "SsdpServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_usb", + arg_types={ + 1: "UsbServiceInfo", + }, + return_type="FlowResult", + ), + TypeHintMatch( + function_name="async_step_zeroconf", + arg_types={ + 1: "ZeroconfServiceInfo", + }, + return_type="FlowResult", + ), + ], + ), + ] +} + def _is_valid_type( expected_type: list[str] | str | None | object, node: astroid.NodeNG @@ -494,10 +561,12 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] def __init__(self, linter: PyLinter | None = None) -> None: super().__init__(linter) self._function_matchers: list[TypeHintMatch] = [] + self._class_matchers: list[ClassTypeHintMatch] = [] def visit_module(self, node: astroid.Module) -> None: """Called when a Module node is visited.""" self._function_matchers = [] + self._class_matchers = [] if (module_platform := _get_module_platform(node.name)) is None: return @@ -505,8 +574,28 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] if module_platform in _PLATFORMS: self._function_matchers.extend(_FUNCTION_MATCH["__any_platform__"]) - if matches := _FUNCTION_MATCH.get(module_platform): - self._function_matchers.extend(matches) + if function_matches := _FUNCTION_MATCH.get(module_platform): + self._function_matchers.extend(function_matches) + + if class_matches := _CLASS_MATCH.get(module_platform): + self._class_matchers = class_matches + + def visit_classdef(self, node: astroid.ClassDef) -> None: + """Called when a ClassDef node is visited.""" + ancestor: astroid.ClassDef + for ancestor in node.ancestors(): + for class_matches in self._class_matchers: + if ancestor.name == class_matches.base_class: + self._visit_class_functions(node, class_matches.matches) + + def _visit_class_functions( + self, node: astroid.ClassDef, matches: list[TypeHintMatch] + ) -> None: + for match in matches: + for function_node in node.mymethods(): + function_name: str | None = function_node.name + if match.function_name == function_name: + self._check_function(function_node, match) def visit_functiondef(self, node: astroid.FunctionDef) -> None: """Called when a FunctionDef node is visited.""" diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 0bd273985e3..fa3d32dcb04 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -277,3 +277,75 @@ def test_valid_list_dict_str_any( with assert_no_messages(linter): type_hint_checker.visit_asyncfunctiondef(func_node) + + +def test_invalid_config_flow_step( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure invalid hints are rejected for ConfigFlow step.""" + class_node, func_node, arg_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + async def async_step_zeroconf( #@ + self, + device_config: dict #@ + ): + pass + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=arg_node, + args=(2, "ZeroconfServiceInfo"), + line=10, + col_offset=8, + end_line=10, + end_col_offset=27, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="FlowResult", + line=8, + col_offset=4, + end_line=8, + end_col_offset=33, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +def test_valid_config_flow_step( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure valid hints are accepted for ConfigFlow step.""" + class_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + async def async_step_zeroconf( + self, + device_config: ZeroconfServiceInfo + ) -> FlowResult: + pass + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) From df5285f68138f066c92aabcc8d6deae8fcc2fba0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 1 Jun 2022 16:49:43 +0200 Subject: [PATCH 166/947] Improve pylint disable rule in zha (#72835) --- .../components/zha/core/channels/manufacturerspecific.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 3bcab38e026..0ec9c7c2f4e 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -60,7 +60,7 @@ class OppleRemote(ZigbeeChannel): """Initialize Opple channel.""" super().__init__(cluster, ch_pool) if self.cluster.endpoint.model == "lumi.motion.ac02": - self.ZCL_INIT_ATTRS = { # pylint: disable=C0103 + self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name "detection_interval": True, "motion_sensitivity": True, "trigger_indicator": True, From 74e2d5c5c312cf3ba154b5206ceb19ba884c6fb4 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 1 Jun 2022 18:25:49 +0300 Subject: [PATCH 167/947] Remove deprecated YAML for `transmission` (#72832) --- .../components/transmission/__init__.py | 38 +------------ .../components/transmission/config_flow.py | 7 --- .../transmission/test_config_flow.py | 56 +------------------ 3 files changed, 3 insertions(+), 98 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index a824185b13e..ac6659b048e 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -8,7 +8,7 @@ import transmissionrpc from transmissionrpc.error import TransmissionError import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_HOST, CONF_ID, @@ -24,7 +24,6 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.typing import ConfigType from .const import ( ATTR_DELETE_DATA, @@ -34,9 +33,7 @@ from .const import ( DATA_UPDATED, DEFAULT_DELETE_DATA, DEFAULT_LIMIT, - DEFAULT_NAME, DEFAULT_ORDER, - DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DOMAIN, EVENT_DOWNLOADED_TORRENT, @@ -78,42 +75,11 @@ SERVICE_STOP_TORRENT_SCHEMA = vol.Schema( } ) -TRANS_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - } - ) -) - -CONFIG_SCHEMA = vol.Schema( - vol.All(cv.deprecated(DOMAIN), {DOMAIN: vol.All(cv.ensure_list, [TRANS_SCHEMA])}), - extra=vol.ALLOW_EXTRA, -) +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) PLATFORMS = [Platform.SENSOR, Platform.SWITCH] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Transmission Component from config.""" - if DOMAIN in config: - for entry in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Transmission Component.""" client = TransmissionClient(hass, config_entry) diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index d5c63aa736f..ce62475b2eb 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -83,13 +83,6 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config): - """Import from Transmission client config.""" - import_config[CONF_SCAN_INTERVAL] = import_config[ - CONF_SCAN_INTERVAL - ].total_seconds() - return await self.async_step_user(user_input=import_config) - class TransmissionOptionsFlowHandler(config_entries.OptionsFlow): """Handle Transmission client options.""" diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index 91dfa25fd35..4e3a7c73d6c 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for Transmission config flow.""" -from datetime import timedelta from unittest.mock import patch import pytest @@ -8,15 +7,7 @@ from transmissionrpc.error import TransmissionError from homeassistant import config_entries, data_entry_flow from homeassistant.components import transmission from homeassistant.components.transmission import config_flow -from homeassistant.components.transmission.const import ( - CONF_LIMIT, - CONF_ORDER, - DEFAULT_LIMIT, - DEFAULT_NAME, - DEFAULT_ORDER, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, -) +from homeassistant.components.transmission.const import DEFAULT_SCAN_INTERVAL from homeassistant.const import ( CONF_HOST, CONF_NAME, @@ -153,51 +144,6 @@ async def test_options(hass): assert result["data"][CONF_SCAN_INTERVAL] == 10 -async def test_import(hass, api): - """Test import step.""" - flow = init_config_flow(hass) - - # import with minimum fields only - result = await flow.async_step_import( - { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: HOST, - CONF_PORT: DEFAULT_PORT, - CONF_SCAN_INTERVAL: timedelta(seconds=DEFAULT_SCAN_INTERVAL), - CONF_LIMIT: DEFAULT_LIMIT, - CONF_ORDER: DEFAULT_ORDER, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_NAME] == DEFAULT_NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == DEFAULT_PORT - assert result["data"][CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL - - # import with all - result = await flow.async_step_import( - { - CONF_NAME: NAME, - CONF_HOST: HOST, - CONF_USERNAME: USERNAME, - CONF_PASSWORD: PASSWORD, - CONF_PORT: PORT, - CONF_SCAN_INTERVAL: timedelta(seconds=SCAN_INTERVAL), - CONF_LIMIT: DEFAULT_LIMIT, - CONF_ORDER: DEFAULT_ORDER, - } - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_NAME] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_USERNAME] == USERNAME - assert result["data"][CONF_PASSWORD] == PASSWORD - assert result["data"][CONF_PORT] == PORT - assert result["data"][CONF_SCAN_INTERVAL] == SCAN_INTERVAL - - async def test_host_already_configured(hass, api): """Test host is already configured.""" entry = MockConfigEntry( From d6e5c26b2482623d43eef10a4690a47a5719d1d9 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Thu, 2 Jun 2022 03:41:20 +1000 Subject: [PATCH 168/947] Add configuration_url to hunterdouglas_powerview (#72837) Co-authored-by: J. Nick Koston --- homeassistant/components/hunterdouglas_powerview/__init__.py | 3 +++ homeassistant/components/hunterdouglas_powerview/const.py | 1 + homeassistant/components/hunterdouglas_powerview/entity.py | 4 ++++ 3 files changed, 8 insertions(+) diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 97f7f8de931..587bf8aef12 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -37,6 +37,7 @@ from .const import ( HUB_NAME, MAC_ADDRESS_IN_USERDATA, PV_API, + PV_HUB_ADDRESS, PV_ROOM_DATA, PV_SCENE_DATA, PV_SHADE_DATA, @@ -72,6 +73,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with async_timeout.timeout(10): device_info = await async_get_device_info(pv_request) + device_info[PV_HUB_ADDRESS] = hub_address async with async_timeout.timeout(10): rooms = Rooms(pv_request) @@ -97,6 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) coordinator.async_set_updated_data(PowerviewShadeData()) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { PV_API: pv_request, PV_ROOM_DATA: room_data, diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index 1cc9f79df40..7146d40c737 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -54,6 +54,7 @@ STATE_ATTRIBUTE_ROOM_NAME = "roomName" PV_API = "pv_api" PV_HUB = "pv_hub" +PV_HUB_ADDRESS = "pv_hub_address" PV_SHADES = "pv_shades" PV_SCENE_DATA = "pv_scene_data" PV_SHADE_DATA = "pv_shade_data" diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 174e3def2d6..04885fa576e 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -21,6 +21,7 @@ from .const import ( FIRMWARE_REVISION, FIRMWARE_SUB_REVISION, MANUFACTURER, + PV_HUB_ADDRESS, ) from .coordinator import PowerviewShadeUpdateCoordinator from .shade_data import PowerviewShadeData, PowerviewShadePositions @@ -40,6 +41,7 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): super().__init__(coordinator) self._room_name = room_name self._attr_unique_id = unique_id + self._hub_address = device_info[PV_HUB_ADDRESS] self._device_info = device_info @property @@ -62,6 +64,7 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): name=self._device_info[DEVICE_NAME], suggested_area=self._room_name, sw_version=sw_version, + configuration_url=f"http://{self._hub_address}/api/shades", ) @@ -97,6 +100,7 @@ class ShadeEntity(HDEntity): manufacturer=MANUFACTURER, model=str(self._shade.raw_data[ATTR_TYPE]), via_device=(DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]), + configuration_url=f"http://{self._hub_address}/api/shades/{self._shade.id}", ) for shade in self._shade.shade_types: From 2ba45a9f99cdb3a01942e230e997c90b383c44c9 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 1 Jun 2022 22:54:22 +0100 Subject: [PATCH 169/947] System Bridge 3.x.x (#71218) * Change to new package and tcp * Rework integration pt1 * Show by default * Handle auth error * Use const * New version avaliable (to be replaced in future by update entity) * Remove visible * Version latest * Filesystem space use * Dev package * Fix sensor * Add services * Update package * Add temperature and voltage * GPU * Bump package version * Update config flow * Add displays * Fix displays connected * Round to whole number * GPU fan speed in RPM * Handle disconnections * Update package * Fix * Update tests * Handle more errors * Check submodule and return missing uuid in test * Handle auth error on config flow * Fix test * Bump package version * Handle key errors * Update package to release version * Client session in config flow * Log * Increase timeout and use similar logic in config flow to init * 30 secs * Add test for timeout error * Cleanup logs Co-authored-by: Martin Hjelmare * Update tests/components/system_bridge/test_config_flow.py Co-authored-by: Martin Hjelmare * uuid raise specific error * Type * Lambda to functions for complex logic * Unknown error test * Bump package to 3.0.5 * Bump package to 3.0.6 * Use typings from package and pydantic * Use dict() * Use data listener function and map to models * Use passed module handler * Use lists from models * Update to 3.1.0 * Update coordinator to use passed module * Improve coordinator * Add debug * Bump package and avaliable -> available * Add version check Co-authored-by: Martin Hjelmare --- .../components/system_bridge/__init__.py | 230 +++---- .../components/system_bridge/binary_sensor.py | 29 +- .../components/system_bridge/config_flow.py | 124 ++-- .../components/system_bridge/const.py | 25 +- .../components/system_bridge/coordinator.py | 205 ++++--- .../components/system_bridge/manifest.json | 6 +- .../components/system_bridge/sensor.py | 569 +++++++++--------- .../components/system_bridge/services.yaml | 64 +- homeassistant/generated/zeroconf.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../system_bridge/test_config_flow.py | 365 +++++++---- 12 files changed, 892 insertions(+), 731 deletions(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index c6edf5b61ea..68a017628b8 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -3,102 +3,114 @@ from __future__ import annotations import asyncio import logging -import shlex import async_timeout -from systembridge import Bridge -from systembridge.client import BridgeClient -from systembridge.exceptions import BridgeAuthenticationException -from systembridge.objects.command.response import CommandResponse -from systembridge.objects.keyboard.payload import KeyboardPayload +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, +) +from systembridgeconnector.version import SUPPORTED_VERSION, Version import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_KEY, - CONF_COMMAND, CONF_HOST, CONF_PATH, CONF_PORT, + CONF_URL, Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) -from homeassistant.helpers import ( - aiohttp_client, - config_validation as cv, - device_registry as dr, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .const import DOMAIN, MODULES from .coordinator import SystemBridgeDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.SENSOR, +] -CONF_ARGUMENTS = "arguments" CONF_BRIDGE = "bridge" CONF_KEY = "key" -CONF_MODIFIERS = "modifiers" CONF_TEXT = "text" -CONF_WAIT = "wait" -SERVICE_SEND_COMMAND = "send_command" -SERVICE_OPEN = "open" +SERVICE_OPEN_PATH = "open_path" +SERVICE_OPEN_URL = "open_url" SERVICE_SEND_KEYPRESS = "send_keypress" SERVICE_SEND_TEXT = "send_text" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up System Bridge from a config entry.""" - bridge = Bridge( - BridgeClient(aiohttp_client.async_get_clientsession(hass)), - f"http://{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}", - entry.data[CONF_API_KEY], - ) + # Check version before initialising + version = Version( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) try: - async with async_timeout.timeout(30): - await bridge.async_get_information() - except BridgeAuthenticationException as exception: - raise ConfigEntryAuthFailed( - f"Authentication failed for {entry.title} ({entry.data[CONF_HOST]})" - ) from exception - except BRIDGE_CONNECTION_ERRORS as exception: + if not await version.check_supported(): + raise ConfigEntryNotReady( + f"You are not running a supported version of System Bridge. Please update to {SUPPORTED_VERSION} or higher." + ) + except AuthenticationException as exception: + _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) + raise ConfigEntryAuthFailed from exception + except (ConnectionClosedException, ConnectionErrorException) as exception: raise ConfigEntryNotReady( f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." ) from exception + except asyncio.TimeoutError as exception: + raise ConfigEntryNotReady( + f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + ) from exception + + coordinator = SystemBridgeDataUpdateCoordinator( + hass, + _LOGGER, + entry=entry, + ) + try: + async with async_timeout.timeout(30): + await coordinator.async_get_data(MODULES) + except AuthenticationException as exception: + _LOGGER.error("Authentication failed for %s: %s", entry.title, exception) + raise ConfigEntryAuthFailed from exception + except (ConnectionClosedException, ConnectionErrorException) as exception: + raise ConfigEntryNotReady( + f"Could not connect to {entry.title} ({entry.data[CONF_HOST]})." + ) from exception + except asyncio.TimeoutError as exception: + raise ConfigEntryNotReady( + f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." + ) from exception - coordinator = SystemBridgeDataUpdateCoordinator(hass, bridge, _LOGGER, entry=entry) await coordinator.async_config_entry_first_refresh() - # Wait for initial data + _LOGGER.debug("Data: %s", coordinator.data) + try: - async with async_timeout.timeout(60): - while ( - coordinator.bridge.battery is None - or coordinator.bridge.cpu is None - or coordinator.bridge.display is None - or coordinator.bridge.filesystem is None - or coordinator.bridge.graphics is None - or coordinator.bridge.information is None - or coordinator.bridge.memory is None - or coordinator.bridge.network is None - or coordinator.bridge.os is None - or coordinator.bridge.processes is None - or coordinator.bridge.system is None + # Wait for initial data + async with async_timeout.timeout(30): + while coordinator.data is None or all( + getattr(coordinator.data, module) is None for module in MODULES ): _LOGGER.debug( - "Waiting for initial data from %s (%s)", + "Waiting for initial data from %s (%s): %s", entry.title, entry.data[CONF_HOST], + coordinator.data, ) await asyncio.sleep(1) except asyncio.TimeoutError as exception: @@ -111,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) - if hass.services.has_service(DOMAIN, SERVICE_SEND_COMMAND): + if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): return True def valid_device(device: str): @@ -129,104 +141,56 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise vol.Invalid from exception raise vol.Invalid(f"Device {device} does not exist") - async def handle_send_command(call: ServiceCall) -> None: - """Handle the send_command service call.""" + async def handle_open_path(call: ServiceCall) -> None: + """Handle the open path service call.""" + _LOGGER.info("Open: %s", call.data) coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.bridge + await coordinator.websocket_client.open_path(call.data[CONF_PATH]) - command = call.data[CONF_COMMAND] - arguments = shlex.split(call.data[CONF_ARGUMENTS]) - - _LOGGER.debug( - "Command payload: %s", - {CONF_COMMAND: command, CONF_ARGUMENTS: arguments, CONF_WAIT: False}, - ) - try: - response: CommandResponse = await bridge.async_send_command( - {CONF_COMMAND: command, CONF_ARGUMENTS: arguments, CONF_WAIT: False} - ) - if not response.success: - raise HomeAssistantError( - f"Error sending command. Response message was: {response.message}" - ) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending command") from exception - _LOGGER.debug("Sent command. Response message was: %s", response.message) - - async def handle_open(call: ServiceCall) -> None: - """Handle the open service call.""" + async def handle_open_url(call: ServiceCall) -> None: + """Handle the open url service call.""" + _LOGGER.info("Open: %s", call.data) coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.bridge - - path = call.data[CONF_PATH] - - _LOGGER.debug("Open payload: %s", {CONF_PATH: path}) - try: - await bridge.async_open({CONF_PATH: path}) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending") from exception - _LOGGER.debug("Sent open request") + await coordinator.websocket_client.open_url(call.data[CONF_URL]) async def handle_send_keypress(call: ServiceCall) -> None: """Handle the send_keypress service call.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.data - - keyboard_payload: KeyboardPayload = { - CONF_KEY: call.data[CONF_KEY], - CONF_MODIFIERS: shlex.split(call.data.get(CONF_MODIFIERS, "")), - } - - _LOGGER.debug("Keypress payload: %s", keyboard_payload) - try: - await bridge.async_send_keypress(keyboard_payload) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending") from exception - _LOGGER.debug("Sent keypress request") + await coordinator.websocket_client.keyboard_keypress(call.data[CONF_KEY]) async def handle_send_text(call: ServiceCall) -> None: """Handle the send_keypress service call.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ call.data[CONF_BRIDGE] ] - bridge: Bridge = coordinator.data - - keyboard_payload: KeyboardPayload = {CONF_TEXT: call.data[CONF_TEXT]} - - _LOGGER.debug("Text payload: %s", keyboard_payload) - try: - await bridge.async_send_keypress(keyboard_payload) - except (BridgeAuthenticationException, *BRIDGE_CONNECTION_ERRORS) as exception: - raise HomeAssistantError("Error sending") from exception - _LOGGER.debug("Sent text request") + await coordinator.websocket_client.keyboard_text(call.data[CONF_TEXT]) hass.services.async_register( DOMAIN, - SERVICE_SEND_COMMAND, - handle_send_command, + SERVICE_OPEN_PATH, + handle_open_path, schema=vol.Schema( { vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_COMMAND): cv.string, - vol.Optional(CONF_ARGUMENTS, ""): cv.string, + vol.Required(CONF_PATH): cv.string, }, ), ) hass.services.async_register( DOMAIN, - SERVICE_OPEN, - handle_open, + SERVICE_OPEN_URL, + handle_open_url, schema=vol.Schema( { vol.Required(CONF_BRIDGE): valid_device, - vol.Required(CONF_PATH): cv.string, + vol.Required(CONF_URL): cv.string, }, ), ) @@ -239,7 +203,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: { vol.Required(CONF_BRIDGE): valid_device, vol.Required(CONF_KEY): cv.string, - vol.Optional(CONF_MODIFIERS): cv.string, }, ), ) @@ -271,15 +234,17 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ] # Ensure disconnected and cleanup stop sub - await coordinator.bridge.async_close_websocket() + await coordinator.websocket_client.close() if coordinator.unsub: coordinator.unsub() del hass.data[DOMAIN][entry.entry_id] if not hass.data[DOMAIN]: - hass.services.async_remove(DOMAIN, SERVICE_SEND_COMMAND) - hass.services.async_remove(DOMAIN, SERVICE_OPEN) + hass.services.async_remove(DOMAIN, SERVICE_OPEN_PATH) + hass.services.async_remove(DOMAIN, SERVICE_OPEN_URL) + hass.services.async_remove(DOMAIN, SERVICE_SEND_KEYPRESS) + hass.services.async_remove(DOMAIN, SERVICE_SEND_TEXT) return unload_ok @@ -295,20 +260,21 @@ class SystemBridgeEntity(CoordinatorEntity[SystemBridgeDataUpdateCoordinator]): def __init__( self, coordinator: SystemBridgeDataUpdateCoordinator, + api_port: int, key: str, name: str | None, ) -> None: """Initialize the System Bridge entity.""" super().__init__(coordinator) - bridge: Bridge = coordinator.data - self._key = f"{bridge.information.host}_{key}" - self._name = f"{bridge.information.host} {name}" - self._configuration_url = bridge.get_configuration_url() - self._hostname = bridge.information.host - self._mac = bridge.information.mac - self._manufacturer = bridge.system.system.manufacturer - self._model = bridge.system.system.model - self._version = bridge.system.system.version + + self._hostname = coordinator.data.system.hostname + self._key = f"{self._hostname}_{key}" + self._name = f"{self._hostname} {name}" + self._configuration_url = ( + f"http://{self._hostname}:{api_port}/app/settings.html" + ) + self._mac_address = coordinator.data.system.mac_address + self._version = coordinator.data.system.version @property def unique_id(self) -> str: @@ -329,9 +295,7 @@ class SystemBridgeDeviceEntity(SystemBridgeEntity): """Return device information about this System Bridge instance.""" return DeviceInfo( configuration_url=self._configuration_url, - connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, - manufacturer=self._manufacturer, - model=self._model, + connections={(dr.CONNECTION_NETWORK_MAC, self._mac_address)}, name=self._hostname, sw_version=self._version, ) diff --git a/homeassistant/components/system_bridge/binary_sensor.py b/homeassistant/components/system_bridge/binary_sensor.py index e592c8e82e4..9225aebf492 100644 --- a/homeassistant/components/system_bridge/binary_sensor.py +++ b/homeassistant/components/system_bridge/binary_sensor.py @@ -4,14 +4,13 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from systembridge import Bridge - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,7 +31,7 @@ BASE_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, ...] key="version_available", name="New Version Available", device_class=BinarySensorDeviceClass.UPDATE, - value=lambda bridge: bridge.information.updates.available, + value=lambda data: data.system.version_newer_available, ), ) @@ -41,7 +40,7 @@ BATTERY_BINARY_SENSOR_TYPES: tuple[SystemBridgeBinarySensorEntityDescription, .. key="battery_is_charging", name="Battery Is Charging", device_class=BinarySensorDeviceClass.BATTERY_CHARGING, - value=lambda bridge: bridge.battery.isCharging, + value=lambda data: data.battery.is_charging, ), ) @@ -51,15 +50,24 @@ async def async_setup_entry( ) -> None: """Set up System Bridge binary sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] - bridge: Bridge = coordinator.data entities = [] for description in BASE_BINARY_SENSOR_TYPES: - entities.append(SystemBridgeBinarySensor(coordinator, description)) + entities.append( + SystemBridgeBinarySensor(coordinator, description, entry.data[CONF_PORT]) + ) - if bridge.battery and bridge.battery.hasBattery: + if ( + coordinator.data.battery + and coordinator.data.battery.percentage + and coordinator.data.battery.percentage > -1 + ): for description in BATTERY_BINARY_SENSOR_TYPES: - entities.append(SystemBridgeBinarySensor(coordinator, description)) + entities.append( + SystemBridgeBinarySensor( + coordinator, description, entry.data[CONF_PORT] + ) + ) async_add_entities(entities) @@ -73,10 +81,12 @@ class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): self, coordinator: SystemBridgeDataUpdateCoordinator, description: SystemBridgeBinarySensorEntityDescription, + api_port: int, ) -> None: """Initialize.""" super().__init__( coordinator, + api_port, description.key, description.name, ) @@ -85,5 +95,4 @@ class SystemBridgeBinarySensor(SystemBridgeDeviceEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return the boolean state of the binary sensor.""" - bridge: Bridge = self.coordinator.data - return self.entity_description.value(bridge) + return self.entity_description.value(self.coordinator.data) diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 26ccf83c345..0c1241fcf2c 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -1,13 +1,18 @@ """Config flow for System Bridge integration.""" from __future__ import annotations +import asyncio import logging from typing import Any import async_timeout -from systembridge import Bridge -from systembridge.client import BridgeClient -from systembridge.exceptions import BridgeAuthenticationException +from systembridgeconnector.const import EVENT_MODULE, EVENT_TYPE, TYPE_DATA_UPDATE +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, +) +from systembridgeconnector.websocket_client import WebSocketClient import voluptuous as vol from homeassistant import config_entries, exceptions @@ -15,9 +20,10 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -31,39 +37,84 @@ STEP_USER_DATA_SCHEMA = vol.Schema( ) -async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]: +async def validate_input( + hass: HomeAssistant, + data: dict[str, Any], +) -> dict[str, str]: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - bridge = Bridge( - BridgeClient(aiohttp_client.async_get_clientsession(hass)), - f"http://{data[CONF_HOST]}:{data[CONF_PORT]}", + host = data[CONF_HOST] + + websocket_client = WebSocketClient( + host, + data[CONF_PORT], data[CONF_API_KEY], ) - - hostname = data[CONF_HOST] try: async with async_timeout.timeout(30): - await bridge.async_get_information() - if ( - bridge.information is not None - and bridge.information.host is not None - and bridge.information.uuid is not None - ): - hostname = bridge.information.host - uuid = bridge.information.uuid - except BridgeAuthenticationException as exception: - _LOGGER.info(exception) + await websocket_client.connect(session=async_get_clientsession(hass)) + await websocket_client.get_data(["system"]) + while True: + message = await websocket_client.receive_message() + _LOGGER.debug("Message: %s", message) + if ( + message[EVENT_TYPE] == TYPE_DATA_UPDATE + and message[EVENT_MODULE] == "system" + ): + break + except AuthenticationException as exception: + _LOGGER.warning( + "Authentication error when connecting to %s: %s", data[CONF_HOST], exception + ) raise InvalidAuth from exception - except BRIDGE_CONNECTION_ERRORS as exception: - _LOGGER.info(exception) + except ( + ConnectionClosedException, + ConnectionErrorException, + ) as exception: + _LOGGER.warning( + "Connection error when connecting to %s: %s", data[CONF_HOST], exception + ) + raise CannotConnect from exception + except asyncio.TimeoutError as exception: + _LOGGER.warning("Timed out connecting to %s: %s", data[CONF_HOST], exception) raise CannotConnect from exception - return {"hostname": hostname, "uuid": uuid} + _LOGGER.debug("%s Message: %s", TYPE_DATA_UPDATE, message) + + if "uuid" not in message["data"]: + error = "No UUID in result!" + raise CannotConnect(error) + + return {"hostname": host, "uuid": message["data"]["uuid"]} -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +async def _async_get_info( + hass: HomeAssistant, + user_input: dict[str, Any], +) -> tuple[dict[str, str], dict[str, str] | None]: + errors = {} + + try: + info = await validate_input(hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return errors, info + + return errors, None + + +class ConfigFlow( + config_entries.ConfigFlow, + domain=DOMAIN, +): """Handle a config flow for System Bridge.""" VERSION = 1 @@ -74,25 +125,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._input: dict[str, Any] = {} self._reauth = False - async def _async_get_info( - self, user_input: dict[str, Any] - ) -> tuple[dict[str, str], dict[str, str] | None]: - errors = {} - - try: - info = await validate_input(self.hass, user_input) - except CannotConnect: - errors["base"] = "cannot_connect" - except InvalidAuth: - errors["base"] = "invalid_auth" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return errors, info - - return errors, None - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -102,7 +134,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) - errors, info = await self._async_get_info(user_input) + errors, info = await _async_get_info(self.hass, user_input) if not errors and info is not None: # Check if already configured await self.async_set_unique_id(info["uuid"], raise_on_progress=False) @@ -122,7 +154,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: user_input = {**self._input, **user_input} - errors, info = await self._async_get_info(user_input) + errors, info = await _async_get_info(self.hass, user_input) if not errors and info is not None: # Check if already configured existing_entry = await self.async_set_unique_id(info["uuid"]) diff --git a/homeassistant/components/system_bridge/const.py b/homeassistant/components/system_bridge/const.py index f2e83ceb186..c71ee86c920 100644 --- a/homeassistant/components/system_bridge/const.py +++ b/homeassistant/components/system_bridge/const.py @@ -1,20 +1,13 @@ """Constants for the System Bridge integration.""" -import asyncio - -from aiohttp.client_exceptions import ( - ClientConnectionError, - ClientConnectorError, - ClientResponseError, -) -from systembridge.exceptions import BridgeException DOMAIN = "system_bridge" -BRIDGE_CONNECTION_ERRORS = ( - asyncio.TimeoutError, - BridgeException, - ClientConnectionError, - ClientConnectorError, - ClientResponseError, - OSError, -) +MODULES = [ + "battery", + "cpu", + "disk", + "display", + "gpu", + "memory", + "system", +] diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 896309f2593..89a0c85c1d9 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -6,40 +6,71 @@ from collections.abc import Callable from datetime import timedelta import logging -from systembridge import Bridge -from systembridge.exceptions import ( - BridgeAuthenticationException, - BridgeConnectionClosedException, - BridgeException, +import async_timeout +from pydantic import BaseModel # pylint: disable=no-name-in-module +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, ) -from systembridge.objects.events import Event +from systembridgeconnector.models.battery import Battery +from systembridgeconnector.models.cpu import Cpu +from systembridgeconnector.models.disk import Disk +from systembridgeconnector.models.display import Display +from systembridgeconnector.models.gpu import Gpu +from systembridgeconnector.models.memory import Memory +from systembridgeconnector.models.system import System +from systembridgeconnector.websocket_client import WebSocketClient from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, +) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import BRIDGE_CONNECTION_ERRORS, DOMAIN +from .const import DOMAIN, MODULES -class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[Bridge]): +class SystemBridgeCoordinatorData(BaseModel): + """System Bridge Coordianator Data.""" + + battery: Battery = None + cpu: Cpu = None + disk: Disk = None + display: Display = None + gpu: Gpu = None + memory: Memory = None + system: System = None + + +class SystemBridgeDataUpdateCoordinator( + DataUpdateCoordinator[SystemBridgeCoordinatorData] +): """Class to manage fetching System Bridge data from single endpoint.""" def __init__( self, hass: HomeAssistant, - bridge: Bridge, LOGGER: logging.Logger, *, entry: ConfigEntry, ) -> None: """Initialize global System Bridge data updater.""" - self.bridge = bridge self.title = entry.title - self.host = entry.data[CONF_HOST] self.unsub: Callable | None = None + self.systembridge_data = SystemBridgeCoordinatorData() + self.websocket_client = WebSocketClient( + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_API_KEY], + ) + super().__init__( hass, LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) ) @@ -49,97 +80,117 @@ class SystemBridgeDataUpdateCoordinator(DataUpdateCoordinator[Bridge]): for update_callback in self._listeners: update_callback() - async def async_handle_event(self, event: Event): - """Handle System Bridge events from the WebSocket.""" - # No need to update anything, as everything is updated in the caller - self.logger.debug( - "New event from %s (%s): %s", self.title, self.host, event.name - ) - self.async_set_updated_data(self.bridge) + async def async_get_data( + self, + modules: list[str], + ) -> None: + """Get data from WebSocket.""" + if not self.websocket_client.connected: + await self._setup_websocket() - async def _listen_for_events(self) -> None: + await self.websocket_client.get_data(modules) + + async def async_handle_module( + self, + module_name: str, + module, + ) -> None: + """Handle data from the WebSocket client.""" + self.logger.debug("Set new data for: %s", module_name) + setattr(self.systembridge_data, module_name, module) + self.async_set_updated_data(self.systembridge_data) + + async def _listen_for_data(self) -> None: """Listen for events from the WebSocket.""" + try: - await self.bridge.async_send_event( - "get-data", - [ - {"service": "battery", "method": "findAll", "observe": True}, - {"service": "cpu", "method": "findAll", "observe": True}, - {"service": "display", "method": "findAll", "observe": True}, - {"service": "filesystem", "method": "findSizes", "observe": True}, - {"service": "graphics", "method": "findAll", "observe": True}, - {"service": "memory", "method": "findAll", "observe": True}, - {"service": "network", "method": "findAll", "observe": True}, - {"service": "os", "method": "findAll", "observe": False}, - { - "service": "processes", - "method": "findCurrentLoad", - "observe": True, - }, - {"service": "system", "method": "findAll", "observe": False}, - ], - ) - await self.bridge.listen_for_events(callback=self.async_handle_event) - except BridgeConnectionClosedException as exception: + await self.websocket_client.register_data_listener(MODULES) + await self.websocket_client.listen(callback=self.async_handle_module) + except AuthenticationException as exception: self.last_update_success = False - self.logger.info( - "Websocket Connection Closed for %s (%s). Will retry: %s", - self.title, - self.host, - exception, - ) - except BridgeException as exception: + self.logger.error("Authentication failed for %s: %s", self.title, exception) + if self.unsub: + self.unsub() + self.unsub = None self.last_update_success = False self.update_listeners() - self.logger.warning( - "Exception occurred for %s (%s). Will retry: %s", + except (ConnectionClosedException, ConnectionResetError) as exception: + self.logger.info( + "Websocket connection closed for %s. Will retry: %s", self.title, - self.host, exception, ) + if self.unsub: + self.unsub() + self.unsub = None + self.last_update_success = False + self.update_listeners() + except ConnectionErrorException as exception: + self.logger.warning( + "Connection error occurred for %s. Will retry: %s", + self.title, + exception, + ) + if self.unsub: + self.unsub() + self.unsub = None + self.last_update_success = False + self.update_listeners() async def _setup_websocket(self) -> None: """Use WebSocket for updates.""" - try: - self.logger.debug( - "Connecting to ws://%s:%s", - self.host, - self.bridge.information.websocketPort, - ) - await self.bridge.async_connect_websocket( - self.host, self.bridge.information.websocketPort - ) - except BridgeAuthenticationException as exception: + async with async_timeout.timeout(20): + await self.websocket_client.connect( + session=async_get_clientsession(self.hass), + ) + except AuthenticationException as exception: + self.last_update_success = False + self.logger.error("Authentication failed for %s: %s", self.title, exception) if self.unsub: self.unsub() self.unsub = None - raise ConfigEntryAuthFailed() from exception - except (*BRIDGE_CONNECTION_ERRORS, ConnectionRefusedError) as exception: - if self.unsub: - self.unsub() - self.unsub = None - raise UpdateFailed( - f"Could not connect to {self.title} ({self.host})." - ) from exception - asyncio.create_task(self._listen_for_events()) + self.last_update_success = False + self.update_listeners() + except ConnectionErrorException as exception: + self.logger.warning( + "Connection error occurred for %s. Will retry: %s", + self.title, + exception, + ) + self.last_update_success = False + self.update_listeners() + except asyncio.TimeoutError as exception: + self.logger.warning( + "Timed out waiting for %s. Will retry: %s", + self.title, + exception, + ) + self.last_update_success = False + self.update_listeners() + + self.hass.async_create_task(self._listen_for_data()) + self.last_update_success = True + self.update_listeners() async def close_websocket(_) -> None: """Close WebSocket connection.""" - await self.bridge.async_close_websocket() + await self.websocket_client.close() # Clean disconnect WebSocket on Home Assistant shutdown self.unsub = self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STOP, close_websocket ) - async def _async_update_data(self) -> Bridge: + async def _async_update_data(self) -> SystemBridgeCoordinatorData: """Update System Bridge data from WebSocket.""" self.logger.debug( "_async_update_data - WebSocket Connected: %s", - self.bridge.websocket_connected, + self.websocket_client.connected, ) - if not self.bridge.websocket_connected: + if not self.websocket_client.connected: await self._setup_websocket() - return self.bridge + self.logger.debug("_async_update_data done") + + return self.systembridge_data diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 8fba9dd30cf..76449e3f3ac 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,11 +3,11 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridge==2.3.1"], + "requirements": ["systembridgeconnector==3.1.3"], "codeowners": ["@timmo001"], - "zeroconf": ["_system-bridge._udp.local."], + "zeroconf": ["_system-bridge._tcp.local."], "after_dependencies": ["zeroconf"], "quality_scale": "silver", "iot_class": "local_push", - "loggers": ["systembridge"] + "loggers": ["systembridgeconnector"] } diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index e66749820a7..b37ff66896e 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -6,8 +6,6 @@ from dataclasses import dataclass from datetime import datetime, timedelta from typing import Final, cast -from systembridge import Bridge - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -16,6 +14,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_PORT, DATA_GIGABYTES, ELECTRIC_POTENTIAL_VOLT, FREQUENCY_GIGAHERTZ, @@ -28,10 +27,11 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType +from homeassistant.util.dt import utcnow from . import SystemBridgeDeviceEntity from .const import DOMAIN -from .coordinator import SystemBridgeDataUpdateCoordinator +from .coordinator import SystemBridgeCoordinatorData, SystemBridgeDataUpdateCoordinator ATTR_AVAILABLE: Final = "available" ATTR_FILESYSTEM: Final = "filesystem" @@ -41,6 +41,7 @@ ATTR_TYPE: Final = "type" ATTR_USED: Final = "used" PIXELS: Final = "px" +RPM: Final = "RPM" @dataclass @@ -50,21 +51,87 @@ class SystemBridgeSensorEntityDescription(SensorEntityDescription): value: Callable = round +def battery_time_remaining(data: SystemBridgeCoordinatorData) -> datetime | None: + """Return the battery time remaining.""" + if data.battery.sensors_secsleft is not None: + return utcnow() + timedelta(seconds=data.battery.sensors_secsleft) + return None + + +def cpu_speed(data: SystemBridgeCoordinatorData) -> float | None: + """Return the CPU speed.""" + if data.cpu.frequency_current is not None: + return round(data.cpu.frequency_current / 1000, 2) + return None + + +def gpu_core_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the GPU core clock speed.""" + if getattr(data.gpu, f"{key}_core_clock") is not None: + return round(getattr(data.gpu, f"{key}_core_clock")) + return None + + +def gpu_memory_clock_speed(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the GPU memory clock speed.""" + if getattr(data.gpu, f"{key}_memory_clock") is not None: + return round(getattr(data.gpu, f"{key}_memory_clock")) + return None + + +def gpu_memory_free(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the free GPU memory.""" + if getattr(data.gpu, f"{key}_memory_free") is not None: + return round(getattr(data.gpu, f"{key}_memory_free") / 10**3, 2) + return None + + +def gpu_memory_used(data: SystemBridgeCoordinatorData, key: str) -> float | None: + """Return the used GPU memory.""" + if getattr(data.gpu, f"{key}_memory_used") is not None: + return round(getattr(data.gpu, f"{key}_memory_used") / 10**3, 2) + return None + + +def gpu_memory_used_percentage( + data: SystemBridgeCoordinatorData, key: str +) -> float | None: + """Return the used GPU memory percentage.""" + if ( + getattr(data.gpu, f"{key}_memory_used") is not None + and getattr(data.gpu, f"{key}_memory_total") is not None + ): + return round( + getattr(data.gpu, f"{key}_memory_used") + / getattr(data.gpu, f"{key}_memory_total") + * 100, + 2, + ) + return None + + +def memory_free(data: SystemBridgeCoordinatorData) -> float | None: + """Return the free memory.""" + if data.memory.virtual_free is not None: + return round(data.memory.virtual_free / 1000**3, 2) + return None + + +def memory_used(data: SystemBridgeCoordinatorData) -> float | None: + """Return the used memory.""" + if data.memory.virtual_used is not None: + return round(data.memory.virtual_used / 1000**3, 2) + return None + + BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( - SystemBridgeSensorEntityDescription( - key="bios_version", - name="BIOS Version", - entity_registry_enabled_default=False, - icon="mdi:chip", - value=lambda bridge: bridge.system.bios.version, - ), SystemBridgeSensorEntityDescription( key="cpu_speed", name="CPU Speed", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=FREQUENCY_GIGAHERTZ, icon="mdi:speedometer", - value=lambda bridge: bridge.cpu.currentSpeed.avg, + value=cpu_speed, ), SystemBridgeSensorEntityDescription( key="cpu_temperature", @@ -73,7 +140,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=TEMP_CELSIUS, - value=lambda bridge: bridge.cpu.temperature.main, + value=lambda data: data.cpu.temperature, ), SystemBridgeSensorEntityDescription( key="cpu_voltage", @@ -82,21 +149,14 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, - value=lambda bridge: bridge.cpu.cpu.voltage, - ), - SystemBridgeSensorEntityDescription( - key="displays_connected", - name="Displays Connected", - state_class=SensorStateClass.MEASUREMENT, - icon="mdi:monitor", - value=lambda bridge: len(bridge.display.displays), + value=lambda data: data.cpu.voltage, ), SystemBridgeSensorEntityDescription( key="kernel", name="Kernel", state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", - value=lambda bridge: bridge.os.kernel, + value=lambda data: data.system.platform, ), SystemBridgeSensorEntityDescription( key="memory_free", @@ -104,7 +164,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:memory", - value=lambda bridge: round(bridge.memory.free / 1000**3, 2), + value=memory_free, ), SystemBridgeSensorEntityDescription( key="memory_used_percentage", @@ -112,7 +172,7 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", - value=lambda bridge: round((bridge.memory.used / bridge.memory.total) * 100, 2), + value=lambda data: data.memory.virtual_percent, ), SystemBridgeSensorEntityDescription( key="memory_used", @@ -121,14 +181,14 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=DATA_GIGABYTES, icon="mdi:memory", - value=lambda bridge: round(bridge.memory.used / 1000**3, 2), + value=memory_used, ), SystemBridgeSensorEntityDescription( key="os", name="Operating System", state_class=SensorStateClass.MEASUREMENT, icon="mdi:devices", - value=lambda bridge: f"{bridge.os.distro} {bridge.os.release}", + value=lambda data: f"{data.system.platform} {data.system.platform_version}", ), SystemBridgeSensorEntityDescription( key="processes_load", @@ -136,46 +196,19 @@ BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoad, 2), - ), - SystemBridgeSensorEntityDescription( - key="processes_load_idle", - name="Idle Load", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoadIdle, 2), - ), - SystemBridgeSensorEntityDescription( - key="processes_load_system", - name="System Load", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoadSystem, 2), - ), - SystemBridgeSensorEntityDescription( - key="processes_load_user", - name="User Load", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge: round(bridge.processes.load.currentLoadUser, 2), + value=lambda data: data.cpu.usage, ), SystemBridgeSensorEntityDescription( key="version", name="Version", icon="mdi:counter", - value=lambda bridge: bridge.information.version, + value=lambda data: data.system.version, ), SystemBridgeSensorEntityDescription( key="version_latest", name="Latest Version", icon="mdi:counter", - value=lambda bridge: bridge.information.updates.version.new, + value=lambda data: data.system.version_latest, ), ) @@ -186,238 +219,270 @@ BATTERY_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.BATTERY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, - value=lambda bridge: bridge.battery.percent, + value=lambda data: data.battery.percentage, ), SystemBridgeSensorEntityDescription( key="battery_time_remaining", name="Battery Time Remaining", device_class=SensorDeviceClass.TIMESTAMP, state_class=SensorStateClass.MEASUREMENT, - value=lambda bridge: str( - datetime.now() + timedelta(minutes=bridge.battery.timeRemaining) - ), + value=battery_time_remaining, ), ) async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up System Bridge sensor based on a config entry.""" coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] entities = [] for description in BASE_SENSOR_TYPES: - entities.append(SystemBridgeSensor(coordinator, description)) + entities.append( + SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) + ) - for key, _ in coordinator.data.filesystem.fsSize.items(): - uid = key.replace(":", "") + for partition in coordinator.data.disk.partitions: entities.append( SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"filesystem_{uid}", - name=f"{key} Space Used", + key=f"filesystem_{partition.replace(':', '')}", + name=f"{partition} Space Used", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", - value=lambda bridge, i=key: round( - bridge.filesystem.fsSize[i]["use"], 2 + value=lambda data, p=partition: getattr( + data.disk, f"usage_{p}_percent" ), ), + entry.data[CONF_PORT], ) ) - if coordinator.data.battery.hasBattery: + if ( + coordinator.data.battery + and coordinator.data.battery.percentage + and coordinator.data.battery.percentage > -1 + ): for description in BATTERY_SENSOR_TYPES: - entities.append(SystemBridgeSensor(coordinator, description)) + entities.append( + SystemBridgeSensor(coordinator, description, entry.data[CONF_PORT]) + ) - for index, _ in enumerate(coordinator.data.display.displays): - name = index + 1 + displays = [] + for display in coordinator.data.display.displays: + displays.append( + { + "key": display, + "name": getattr(coordinator.data.display, f"{display}_name").replace( + "Display ", "" + ), + }, + ) + display_count = len(displays) + + entities.append( + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key="displays_connected", + name="Displays Connected", + state_class=SensorStateClass.MEASUREMENT, + icon="mdi:monitor", + value=lambda _, count=display_count: count, + ), + entry.data[CONF_PORT], + ) + ) + + for _, display in enumerate(displays): entities = [ *entities, SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"display_{name}_resolution_x", - name=f"Display {name} Resolution X", + key=f"display_{display['name']}_resolution_x", + name=f"Display {display['name']} Resolution X", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", - value=lambda bridge, i=index: bridge.display.displays[ - i - ].resolutionX, + value=lambda data, k=display["key"]: getattr( + data.display, f"{k}_resolution_horizontal" + ), ), + entry.data[CONF_PORT], ), SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"display_{name}_resolution_y", - name=f"Display {name} Resolution Y", + key=f"display_{display['name']}_resolution_y", + name=f"Display {display['name']} Resolution Y", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PIXELS, icon="mdi:monitor", - value=lambda bridge, i=index: bridge.display.displays[ - i - ].resolutionY, + value=lambda data, k=display["key"]: getattr( + data.display, f"{k}_resolution_vertical" + ), ), + entry.data[CONF_PORT], ), SystemBridgeSensor( coordinator, SystemBridgeSensorEntityDescription( - key=f"display_{name}_refresh_rate", - name=f"Display {name} Refresh Rate", + key=f"display_{display['name']}_refresh_rate", + name=f"Display {display['name']} Refresh Rate", state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=FREQUENCY_HERTZ, icon="mdi:monitor", - value=lambda bridge, i=index: bridge.display.displays[ - i - ].currentRefreshRate, + value=lambda data, k=display["key"]: getattr( + data.display, f"{k}_refresh_rate" + ), ), + entry.data[CONF_PORT], ), ] - for index, _ in enumerate(coordinator.data.graphics.controllers): - if coordinator.data.graphics.controllers[index].name is not None: - # Remove vendor from name - name = ( - coordinator.data.graphics.controllers[index] - .name.replace(coordinator.data.graphics.controllers[index].vendor, "") - .strip() - ) - entities = [ - *entities, - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_core_clock_speed", - name=f"{name} Clock Speed", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=FREQUENCY_MEGAHERTZ, - icon="mdi:speedometer", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].clockCore, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_clock_speed", - name=f"{name} Memory Clock Speed", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=FREQUENCY_MEGAHERTZ, - icon="mdi:speedometer", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].clockMemory, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_free", - name=f"{name} Memory Free", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=DATA_GIGABYTES, - icon="mdi:memory", - value=lambda bridge, i=index: round( - bridge.graphics.controllers[i].memoryFree / 10**3, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_used_percentage", - name=f"{name} Memory Used %", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:memory", - value=lambda bridge, i=index: round( - ( - bridge.graphics.controllers[i].memoryUsed - / bridge.graphics.controllers[i].memoryTotal - ) - * 100, - 2, - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_memory_used", - name=f"{name} Memory Used", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=DATA_GIGABYTES, - icon="mdi:memory", - value=lambda bridge, i=index: round( - bridge.graphics.controllers[i].memoryUsed / 10**3, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_fan_speed", - name=f"{name} Fan Speed", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:fan", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].fanSpeed, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_power_usage", - name=f"{name} Power Usage", - entity_registry_enabled_default=False, - device_class=SensorDeviceClass.POWER, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=POWER_WATT, - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].powerDraw, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_temperature", - name=f"{name} Temperature", - entity_registry_enabled_default=False, - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=TEMP_CELSIUS, - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].temperatureGpu, - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"gpu_{index}_usage_percentage", - name=f"{name} Usage %", - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, i=index: bridge.graphics.controllers[ - i - ].utilizationGpu, - ), - ), - ] + gpus = [] + for gpu in coordinator.data.gpu.gpus: + gpus.append( + { + "key": gpu, + "name": getattr(coordinator.data.gpu, f"{gpu}_name"), + }, + ) - for index, _ in enumerate(coordinator.data.processes.load.cpus): + for index, gpu in enumerate(gpus): + entities = [ + *entities, + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_core_clock_speed", + name=f"{gpu['name']} Clock Speed", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda data, k=gpu["key"]: gpu_core_clock_speed(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_clock_speed", + name=f"{gpu['name']} Memory Clock Speed", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=FREQUENCY_MEGAHERTZ, + icon="mdi:speedometer", + value=lambda data, k=gpu["key"]: gpu_memory_clock_speed(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_free", + name=f"{gpu['name']} Memory Free", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda data, k=gpu["key"]: gpu_memory_free(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used_percentage", + name=f"{gpu['name']} Memory Used %", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + value=lambda data, k=gpu["key"]: gpu_memory_used_percentage( + data, k + ), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_memory_used", + name=f"{gpu['name']} Memory Used", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=DATA_GIGABYTES, + icon="mdi:memory", + value=lambda data, k=gpu["key"]: gpu_memory_used(data, k), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_fan_speed", + name=f"{gpu['name']} Fan Speed", + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=RPM, + icon="mdi:fan", + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_fan_speed" + ), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_power_usage", + name=f"{gpu['name']} Power Usage", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=POWER_WATT, + value=lambda data, k=gpu["key"]: getattr(data.gpu, f"{k}_power"), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_temperature", + name=f"{gpu['name']} Temperature", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=TEMP_CELSIUS, + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_temperature" + ), + ), + entry.data[CONF_PORT], + ), + SystemBridgeSensor( + coordinator, + SystemBridgeSensorEntityDescription( + key=f"gpu_{index}_usage_percentage", + name=f"{gpu['name']} Usage %", + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:percent", + value=lambda data, k=gpu["key"]: getattr( + data.gpu, f"{k}_core_load" + ), + ), + entry.data[CONF_PORT], + ), + ] + + for index in range(coordinator.data.cpu.count): entities = [ *entities, SystemBridgeSensor( @@ -429,52 +494,9 @@ async def async_setup_entry( state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].load, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"processes_load_cpu_{index}_idle", - name=f"Idle Load CPU {index}", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].loadIdle, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"processes_load_cpu_{index}_system", - name=f"System Load CPU {index}", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].loadSystem, 2 - ), - ), - ), - SystemBridgeSensor( - coordinator, - SystemBridgeSensorEntityDescription( - key=f"processes_load_cpu_{index}_user", - name=f"User Load CPU {index}", - entity_registry_enabled_default=False, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - icon="mdi:percent", - value=lambda bridge, index=index: round( - bridge.processes.load.cpus[index].loadUser, 2 - ), + value=lambda data, k=index: getattr(data.cpu, f"usage_{k}"), ), + entry.data[CONF_PORT], ), ] @@ -490,10 +512,12 @@ class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): self, coordinator: SystemBridgeDataUpdateCoordinator, description: SystemBridgeSensorEntityDescription, + api_port: int, ) -> None: """Initialize.""" super().__init__( coordinator, + api_port, description.key, description.name, ) @@ -502,8 +526,7 @@ class SystemBridgeSensor(SystemBridgeDeviceEntity, SensorEntity): @property def native_value(self) -> StateType: """Return the state.""" - bridge: Bridge = self.coordinator.data try: - return cast(StateType, self.entity_description.value(bridge)) + return cast(StateType, self.entity_description.value(self.coordinator.data)) except TypeError: return None diff --git a/homeassistant/components/system_bridge/services.yaml b/homeassistant/components/system_bridge/services.yaml index aff0094501e..d33235ffba4 100644 --- a/homeassistant/components/system_bridge/services.yaml +++ b/homeassistant/components/system_bridge/services.yaml @@ -1,32 +1,6 @@ -send_command: - name: Send Command - description: Sends a command to the server to run. - fields: - bridge: - name: Bridge - description: The server to send the command to. - required: true - selector: - device: - integration: system_bridge - command: - name: Command - description: Command to send to the server. - required: true - example: "echo" - selector: - text: - arguments: - name: Arguments - description: Arguments to send to the server. - required: false - default: "" - example: "hello" - selector: - text: -open: - name: Open Path/URL - description: Open a URL or file on the server using the default application. +open_path: + name: Open Path + description: Open a file on the server using the default application. fields: bridge: name: Bridge @@ -36,8 +10,26 @@ open: device: integration: system_bridge path: - name: Path/URL - description: Path/URL to open. + name: Path + description: Path to open. + required: true + example: "C:\\test\\image.png" + selector: + text: +open_url: + name: Open URL + description: Open a URL on the server using the default application. + fields: + bridge: + name: Bridge + description: The server to talk to. + required: true + selector: + device: + integration: system_bridge + url: + name: URL + description: URL to open. required: true example: "https://www.home-assistant.io" selector: @@ -60,16 +52,6 @@ send_keypress: example: "audio_play" selector: text: - modifiers: - name: Modifiers - description: "List of modifier(s). Accepts alt, command/win, control, and shift." - required: false - default: "" - example: - - "control" - - "shift" - selector: - text: send_text: name: Send Keyboard Text description: Sends text for the server to type. diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 415e2746c6d..692132c9a75 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -370,7 +370,7 @@ ZEROCONF = { "name": "smappee50*" } ], - "_system-bridge._udp.local.": [ + "_system-bridge._tcp.local.": [ { "domain": "system_bridge" } diff --git a/requirements_all.txt b/requirements_all.txt index 603ad612ae3..d2082f7c965 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2271,7 +2271,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridge==2.3.1 +systembridgeconnector==3.1.3 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1fa8d0fdc3..154d64e4cf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1498,7 +1498,7 @@ sunwatcher==0.2.1 surepy==0.7.2 # homeassistant.components.system_bridge -systembridge==2.3.1 +systembridgeconnector==3.1.3 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index 94d116bbd36..515146bc16c 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -1,13 +1,28 @@ """Test the System Bridge config flow.""" +import asyncio from unittest.mock import patch -from aiohttp.client_exceptions import ClientConnectionError -from systembridge.exceptions import BridgeAuthenticationException +from systembridgeconnector.const import ( + EVENT_DATA, + EVENT_MESSAGE, + EVENT_MODULE, + EVENT_SUBTYPE, + EVENT_TYPE, + SUBTYPE_BAD_API_KEY, + TYPE_DATA_UPDATE, + TYPE_ERROR, +) +from systembridgeconnector.exceptions import ( + AuthenticationException, + ConnectionClosedException, + ConnectionErrorException, +) from homeassistant import config_entries, data_entry_flow from homeassistant.components import zeroconf from homeassistant.components.system_bridge.const import DOMAIN from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -29,7 +44,7 @@ FIXTURE_ZEROCONF_INPUT = { } FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", + host="test-bridge", addresses=["1.1.1.1"], port=9170, hostname="test-bridge.local.", @@ -58,37 +73,40 @@ FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( }, ) - -FIXTURE_INFORMATION = { - "address": "http://test-bridge:9170", - "apiPort": 9170, - "fqdn": "test-bridge", - "host": "test-bridge", - "ip": "1.1.1.1", - "mac": FIXTURE_MAC_ADDRESS, - "updates": { - "available": False, - "newer": False, - "url": "https://github.com/timmo001/system-bridge/releases/tag/v2.3.2", - "version": {"current": "2.3.2", "new": "2.3.2"}, +FIXTURE_DATA_SYSTEM = { + EVENT_TYPE: TYPE_DATA_UPDATE, + EVENT_MESSAGE: "Data changed", + EVENT_MODULE: "system", + EVENT_DATA: { + "uuid": FIXTURE_UUID, }, - "uuid": FIXTURE_UUID, - "version": "2.3.2", - "websocketAddress": "ws://test-bridge:9172", - "websocketPort": 9172, +} + +FIXTURE_DATA_SYSTEM_BAD = { + EVENT_TYPE: TYPE_DATA_UPDATE, + EVENT_MESSAGE: "Data changed", + EVENT_MODULE: "system", + EVENT_DATA: {}, +} + +FIXTURE_DATA_AUTH_ERROR = { + EVENT_TYPE: TYPE_ERROR, + EVENT_SUBTYPE: SUBTYPE_BAD_API_KEY, + EVENT_MESSAGE: "Invalid api-key", } -FIXTURE_BASE_URL = ( - f"http://{FIXTURE_USER_INPUT[CONF_HOST]}:{FIXTURE_USER_INPUT[CONF_PORT]}" -) +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) -FIXTURE_ZEROCONF_BASE_URL = f"http://{FIXTURE_ZEROCONF.host}:{FIXTURE_ZEROCONF.port}" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" -async def test_user_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_user_flow(hass: HomeAssistant) -> None: """Test full user flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -97,20 +115,19 @@ async def test_user_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", - headers={"Content-Type": "application/json"}, - json=FIXTURE_INFORMATION, - ) - with patch( + "homeassistant.components.system_bridge.config_flow.WebSocketClient.connect" + ), patch("systembridgeconnector.websocket_client.WebSocketClient.get_data"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM, + ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "test-bridge" @@ -118,34 +135,7 @@ async def test_user_flow( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] is None - - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["step_id"] == "user" - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_form_cannot_connect(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -154,11 +144,13 @@ async def test_form_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_USER_INPUT - ) + with patch( + "systembridgeconnector.websocket_client.WebSocketClient.connect", + side_effect=ConnectionErrorException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -166,10 +158,8 @@ async def test_form_cannot_connect( assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unknown_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: - """Test we handle unknown error.""" +async def test_form_connection_closed_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle connection closed cannot connect error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -177,23 +167,123 @@ async def test_form_unknown_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] is None - with patch( - "homeassistant.components.system_bridge.config_flow.Bridge.async_get_information", - side_effect=Exception("Boom"), + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=ConnectionClosedException, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_USER_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_timeout_cannot_connect(hass: HomeAssistant) -> None: + """Test we handle timeout cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_invalid_auth(hass: HomeAssistant) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=AuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_uuid_error(hass: HomeAssistant) -> None: + """Test we handle error from bad uuid.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM_BAD, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unknown_error(hass: HomeAssistant) -> None: + """Test we handle unknown errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] is None + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "user" assert result2["errors"] == {"base": "unknown"} -async def test_reauth_authorization_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_reauth_authorization_error(hass: HomeAssistant) -> None: """Test we show user form on authorization error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT @@ -202,13 +292,15 @@ async def test_reauth_authorization_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", exc=BridgeAuthenticationException - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_AUTH_INPUT - ) + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=AuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -216,9 +308,7 @@ async def test_reauth_authorization_error( assert result2["errors"] == {"base": "invalid_auth"} -async def test_reauth_connection_error( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_reauth_connection_error(hass: HomeAssistant) -> None: """Test we show user form on connection error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT @@ -227,11 +317,13 @@ async def test_reauth_connection_error( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get(f"{FIXTURE_BASE_URL}/information", exc=ClientConnectionError) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_AUTH_INPUT - ) + with patch( + "systembridgeconnector.websocket_client.WebSocketClient.connect", + side_effect=ConnectionErrorException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -239,9 +331,32 @@ async def test_reauth_connection_error( assert result2["errors"] == {"base": "cannot_connect"} -async def test_reauth_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_reauth_connection_closed_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth"}, data=FIXTURE_USER_INPUT + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "authenticate" + + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + side_effect=ConnectionClosedException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "authenticate" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: """Test reauth flow.""" mock_config = MockConfigEntry( domain=DOMAIN, unique_id=FIXTURE_UUID, data=FIXTURE_USER_INPUT @@ -255,20 +370,19 @@ async def test_reauth_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "authenticate" - aioclient_mock.get( - f"{FIXTURE_BASE_URL}/information", - headers={"Content-Type": "application/json"}, - json=FIXTURE_INFORMATION, - ) - - with patch( + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM, + ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result2["reason"] == "reauth_successful" @@ -276,9 +390,7 @@ async def test_reauth_flow( assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_flow( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_zeroconf_flow(hass: HomeAssistant) -> None: """Test zeroconf flow.""" result = await hass.config_entries.flow.async_init( @@ -290,30 +402,27 @@ async def test_zeroconf_flow( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get( - f"{FIXTURE_ZEROCONF_BASE_URL}/information", - headers={"Content-Type": "application/json"}, - json=FIXTURE_INFORMATION, - ) - - with patch( + with patch("systembridgeconnector.websocket_client.WebSocketClient.connect"), patch( + "systembridgeconnector.websocket_client.WebSocketClient.get_data" + ), patch( + "systembridgeconnector.websocket_client.WebSocketClient.receive_message", + return_value=FIXTURE_DATA_SYSTEM, + ), patch( "homeassistant.components.system_bridge.async_setup_entry", return_value=True, ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], FIXTURE_AUTH_INPUT ) - await hass.async_block_till_done() + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "test-bridge" + assert result2["title"] == "1.1.1.1" assert result2["data"] == FIXTURE_ZEROCONF_INPUT assert len(mock_setup_entry.mock_calls) == 1 -async def test_zeroconf_cannot_connect( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_zeroconf_cannot_connect(hass: HomeAssistant) -> None: """Test zeroconf cannot connect flow.""" result = await hass.config_entries.flow.async_init( @@ -325,13 +434,13 @@ async def test_zeroconf_cannot_connect( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert not result["errors"] - aioclient_mock.get( - f"{FIXTURE_ZEROCONF_BASE_URL}/information", exc=ClientConnectionError - ) - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], FIXTURE_AUTH_INPUT - ) + with patch( + "systembridgeconnector.websocket_client.WebSocketClient.connect", + side_effect=ConnectionErrorException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_AUTH_INPUT + ) await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM @@ -339,9 +448,7 @@ async def test_zeroconf_cannot_connect( assert result2["errors"] == {"base": "cannot_connect"} -async def test_zeroconf_bad_zeroconf_info( - hass, aiohttp_client, aioclient_mock, current_request_with_host -) -> None: +async def test_zeroconf_bad_zeroconf_info(hass: HomeAssistant) -> None: """Test zeroconf cannot connect flow.""" result = await hass.config_entries.flow.async_init( From 321394d3e2403454e63eb58174906697af8760e3 Mon Sep 17 00:00:00 2001 From: Arne Mauer Date: Thu, 2 Jun 2022 00:00:58 +0200 Subject: [PATCH 170/947] Add Particulate Matter 2.5 of ZCL concentration clusters to ZHA component (#72826) * Add Particulate Matter 2.5 of ZCL concentration clusters to ZHA component * Fixed black and flake8 test --- .../components/zha/core/channels/measurement.py | 12 ++++++++++++ homeassistant/components/zha/sensor.py | 11 +++++++++++ 2 files changed, 23 insertions(+) diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 093c04245c4..7368309cf99 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -128,6 +128,18 @@ class CarbonDioxideConcentration(ZigbeeChannel): ] +@registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PM25.cluster_id) +class PM25(ZigbeeChannel): + """Particulate Matter 2.5 microns or less measurement channel.""" + + REPORT_CONFIG = [ + { + "attr": "measured_value", + "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), + } + ] + + @registries.ZIGBEE_CHANNEL_REGISTRY.register( measurement.FormaldehydeConcentration.cluster_id ) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 36ca873188f..cdc37876889 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -607,6 +607,17 @@ class PPBVOCLevel(Sensor): _unit = CONCENTRATION_PARTS_PER_BILLION +@MULTI_MATCH(channel_names="pm25") +class PM25(Sensor): + """Particulate Matter 2.5 microns or less sensor.""" + + SENSOR_ATTR = "measured_value" + _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT + _decimals = 0 + _multiplier = 1 + _unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + + @MULTI_MATCH(channel_names="formaldehyde_concentration") class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" From d1a8f1ae40d652c78f39d5e1471b1b915a77339a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 2 Jun 2022 00:04:14 +0200 Subject: [PATCH 171/947] Update frontend to 20220601.0 (#72855) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d9e80b4eff8..7d07bbd543c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220531.0"], + "requirements": ["home-assistant-frontend==20220601.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ad2539233f4..076b58b5185 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220531.0 +home-assistant-frontend==20220601.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index d2082f7c965..e50da9b1758 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,7 +822,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220531.0 +home-assistant-frontend==20220601.0 # homeassistant.components.home_connect homeconnect==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 154d64e4cf6..d3fefd79973 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -589,7 +589,7 @@ hole==0.7.0 holidays==0.13 # homeassistant.components.frontend -home-assistant-frontend==20220531.0 +home-assistant-frontend==20220601.0 # homeassistant.components.home_connect homeconnect==0.7.0 From fe5fe148fa8e56b3d8cbd2db36d2c5676cf0258a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Jun 2022 00:06:19 +0200 Subject: [PATCH 172/947] Add mypy checks to pylint plugins (#72821) --- .github/workflows/ci.yaml | 2 +- .pre-commit-config.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b8d81856f3c..30756ddb501 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -689,7 +689,7 @@ jobs: run: | . venv/bin/activate python --version - mypy homeassistant + mypy homeassistant pylint - name: Run mypy (partially) if: needs.changes.outputs.test_full_suite == 'false' shell: bash diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ff00ce07e0c..1104ecf07e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -93,7 +93,7 @@ repos: language: script types: [python] require_serial: true - files: ^homeassistant/.+\.py$ + files: ^(homeassistant|pylint)/.+\.py$ - id: pylint name: pylint entry: script/run-in-env.sh pylint -j 0 From 77467155905d5633349085097e21592d7ef071f1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 2 Jun 2022 00:27:51 +0000 Subject: [PATCH 173/947] [ci skip] Translation update --- .../accuweather/translations/es.json | 3 + .../aladdin_connect/translations/es.json | 1 + .../components/apple_tv/translations/es.json | 1 + .../components/asuswrt/translations/es.json | 3 +- .../aussie_broadband/translations/es.json | 12 ++- .../components/bsblan/translations/es.json | 3 +- .../components/coinbase/translations/es.json | 1 + .../components/deluge/translations/es.json | 6 +- .../components/denonavr/translations/es.json | 3 + .../derivative/translations/es.json | 9 ++ .../components/discord/translations/es.json | 8 +- .../components/dlna_dms/translations/es.json | 11 ++- .../components/doorbird/translations/es.json | 3 + .../components/elkm1/translations/es.json | 1 + .../components/fibaro/translations/es.json | 8 +- .../components/filesize/translations/es.json | 16 +++- .../components/fritz/translations/es.json | 6 +- .../components/generic/translations/es.json | 27 +++++- .../geocaching/translations/es.json | 1 + .../components/geofency/translations/es.json | 1 + .../components/github/translations/es.json | 3 + .../components/google/translations/es.json | 20 +++++ .../components/group/translations/es.json | 72 +++++++++++----- .../here_travel_time/translations/es.json | 82 +++++++++++++++++++ .../components/homekit/translations/es.json | 9 +- .../translations/select.es.json | 1 + .../components/honeywell/translations/es.json | 6 +- .../components/ialarm_xr/translations/es.json | 1 + .../components/ifttt/translations/es.json | 1 + .../integration/translations/es.json | 5 +- .../intellifire/translations/es.json | 3 +- .../components/iss/translations/es.json | 3 +- .../components/isy994/translations/es.json | 1 + .../kaleidescape/translations/es.json | 5 ++ .../components/knx/translations/es.json | 32 ++++++-- .../components/laundrify/translations/es.json | 25 ++++++ .../litterrobot/translations/sensor.es.json | 16 +++- .../components/meater/translations/es.json | 10 ++- .../meteoclimatic/translations/es.json | 3 + .../components/min_max/translations/es.json | 2 + .../components/nanoleaf/translations/es.json | 5 +- .../components/onewire/translations/es.json | 6 +- .../components/plugwise/translations/es.json | 5 +- .../components/powerwall/translations/es.json | 5 +- .../components/ps4/translations/es.json | 6 ++ .../components/recorder/translations/es.json | 4 +- .../rtsp_to_webrtc/translations/es.json | 2 + .../components/samsungtv/translations/es.json | 3 + .../components/season/translations/es.json | 14 ++++ .../components/sense/translations/es.json | 9 +- .../components/senseme/translations/es.json | 3 +- .../components/sensibo/translations/es.json | 3 +- .../components/shelly/translations/es.json | 1 + .../simplisafe/translations/es.json | 1 + .../components/siren/translations/es.json | 3 + .../components/sleepiq/translations/es.json | 1 + .../components/solax/translations/es.json | 2 + .../components/sql/translations/es.json | 5 +- .../steam_online/translations/es.json | 7 +- .../components/subaru/translations/es.json | 5 +- .../components/sun/translations/es.json | 10 +++ .../switch_as_x/translations/es.json | 3 +- .../tankerkoenig/translations/ca.json | 8 +- .../tankerkoenig/translations/de.json | 8 +- .../tankerkoenig/translations/es.json | 20 ++++- .../tankerkoenig/translations/fr.json | 8 +- .../tankerkoenig/translations/ja.json | 8 +- .../tankerkoenig/translations/no.json | 8 +- .../tankerkoenig/translations/zh-Hant.json | 8 +- .../components/tautulli/translations/es.json | 9 +- .../components/threshold/translations/es.json | 20 ++++- .../components/tod/translations/es.json | 6 +- .../tomorrowio/translations/es.json | 5 +- .../tomorrowio/translations/sensor.es.json | 11 +++ .../components/traccar/translations/es.json | 1 + .../trafikverket_ferry/translations/es.json | 6 +- .../trafikverket_train/translations/es.json | 19 ++++- .../tuya/translations/select.es.json | 10 ++- .../ukraine_alarm/translations/es.json | 8 ++ .../unifiprotect/translations/es.json | 7 +- .../components/uptime/translations/es.json | 13 +++ .../uptimerobot/translations/sensor.es.json | 2 + .../utility_meter/translations/es.json | 8 +- .../components/vera/translations/es.json | 3 + .../components/vulcan/translations/es.json | 37 +++++++-- .../components/webostv/translations/es.json | 1 + .../components/whois/translations/es.json | 2 + .../translations/select.pt.json | 22 +++++ .../components/yolink/translations/es.json | 25 ++++++ 89 files changed, 716 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/here_travel_time/translations/es.json create mode 100644 homeassistant/components/laundrify/translations/es.json create mode 100644 homeassistant/components/season/translations/es.json create mode 100644 homeassistant/components/siren/translations/es.json create mode 100644 homeassistant/components/uptime/translations/es.json create mode 100644 homeassistant/components/yamaha_musiccast/translations/select.pt.json create mode 100644 homeassistant/components/yolink/translations/es.json diff --git a/homeassistant/components/accuweather/translations/es.json b/homeassistant/components/accuweather/translations/es.json index 0c3d5560d67..ef91348a727 100644 --- a/homeassistant/components/accuweather/translations/es.json +++ b/homeassistant/components/accuweather/translations/es.json @@ -3,6 +3,9 @@ "abort": { "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, + "create_entry": { + "default": "Algunos sensores no est\u00e1n habilitados de forma predeterminada. Puede habilitarlos en el registro de la entidad despu\u00e9s de la configuraci\u00f3n de la integraci\u00f3n.\n El pron\u00f3stico del tiempo no est\u00e1 habilitado de forma predeterminada. Puedes habilitarlo en las opciones de integraci\u00f3n." + }, "error": { "cannot_connect": "No se pudo conectar", "invalid_api_key": "Clave API no v\u00e1lida", diff --git a/homeassistant/components/aladdin_connect/translations/es.json b/homeassistant/components/aladdin_connect/translations/es.json index 67e509e2626..ac10503ab3c 100644 --- a/homeassistant/components/aladdin_connect/translations/es.json +++ b/homeassistant/components/aladdin_connect/translations/es.json @@ -13,6 +13,7 @@ "data": { "password": "Contrase\u00f1a" }, + "description": "La integraci\u00f3n de Aladdin Connect necesita volver a autenticar su cuenta", "title": "Reautenticaci\u00f3n de la integraci\u00f3n" }, "user": { diff --git a/homeassistant/components/apple_tv/translations/es.json b/homeassistant/components/apple_tv/translations/es.json index 1a0ed773169..3fe2345d6e1 100644 --- a/homeassistant/components/apple_tv/translations/es.json +++ b/homeassistant/components/apple_tv/translations/es.json @@ -7,6 +7,7 @@ "device_did_not_pair": "No se ha intentado finalizar el proceso de emparejamiento desde el dispositivo.", "device_not_found": "No se ha encontrado el dispositivo durante la detecci\u00f3n, por favor, intente a\u00f1adirlo de nuevo.", "inconsistent_device": "No se encontraron los protocolos esperados durante el descubrimiento. Esto normalmente indica un problema con el DNS de multidifusi\u00f3n (Zeroconf). Por favor, intente a\u00f1adir el dispositivo de nuevo.", + "ipv6_not_supported": "IPv6 no est\u00e1 soportado.", "no_devices_found": "No se encontraron dispositivos en la red", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente", "setup_failed": "No se ha podido configurar el dispositivo.", diff --git a/homeassistant/components/asuswrt/translations/es.json b/homeassistant/components/asuswrt/translations/es.json index 9a2e0485aa7..a2e899ef113 100644 --- a/homeassistant/components/asuswrt/translations/es.json +++ b/homeassistant/components/asuswrt/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "invalid_unique_id": "No se pudo determinar ning\u00fan identificador \u00fanico v\u00e1lido del dispositivo" + "invalid_unique_id": "No se pudo determinar ning\u00fan identificador \u00fanico v\u00e1lido del dispositivo", + "no_unique_id": "Un dispositivo sin una identificaci\u00f3n \u00fanica v\u00e1lida ya est\u00e1 configurado. La configuraci\u00f3n de una instancia m\u00faltiple no es posible" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/aussie_broadband/translations/es.json b/homeassistant/components/aussie_broadband/translations/es.json index 1410af6ad76..497215525cb 100644 --- a/homeassistant/components/aussie_broadband/translations/es.json +++ b/homeassistant/components/aussie_broadband/translations/es.json @@ -1,12 +1,14 @@ { "config": { "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", "no_services_found": "No se han encontrado servicios para esta cuenta", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { @@ -31,8 +33,16 @@ } }, "options": { + "abort": { + "cannot_connect": "Fallo en la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, "step": { "init": { + "data": { + "services": "Servicios" + }, "title": "Selecciona servicios" } } diff --git a/homeassistant/components/bsblan/translations/es.json b/homeassistant/components/bsblan/translations/es.json index 830752dd863..6e533b5916b 100644 --- a/homeassistant/components/bsblan/translations/es.json +++ b/homeassistant/components/bsblan/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "Fallo en la conexi\u00f3n" }, "error": { "cannot_connect": "No se pudo conectar" diff --git a/homeassistant/components/coinbase/translations/es.json b/homeassistant/components/coinbase/translations/es.json index 5454aca8ec6..7ecd0a753c9 100644 --- a/homeassistant/components/coinbase/translations/es.json +++ b/homeassistant/components/coinbase/translations/es.json @@ -23,6 +23,7 @@ }, "options": { "error": { + "currency_unavailable": "La API de Coinbase no proporciona uno o m\u00e1s de los saldos de divisas solicitados.", "exchange_rate_unavailable": "El API de Coinbase no proporciona alguno/s de los tipos de cambio que has solicitado.", "unknown": "Error inesperado" }, diff --git a/homeassistant/components/deluge/translations/es.json b/homeassistant/components/deluge/translations/es.json index 7ba1b8c3bf9..1724791221f 100644 --- a/homeassistant/components/deluge/translations/es.json +++ b/homeassistant/components/deluge/translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "El servicio ya est\u00e1 configurado" + }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" @@ -12,7 +15,8 @@ "port": "Puerto", "username": "Usuario", "web_port": "Puerto web (para el servicio de visita)" - } + }, + "description": "Para poder usar esta integraci\u00f3n, debe habilitar la siguiente opci\u00f3n en la configuraci\u00f3n de diluvio: Daemon > Permitir controles remotos" } } } diff --git a/homeassistant/components/denonavr/translations/es.json b/homeassistant/components/denonavr/translations/es.json index 3cfe69aeac4..f5993f46cf7 100644 --- a/homeassistant/components/denonavr/translations/es.json +++ b/homeassistant/components/denonavr/translations/es.json @@ -25,6 +25,9 @@ "user": { "data": { "host": "Direcci\u00f3n IP" + }, + "data_description": { + "host": "D\u00e9jelo en blanco para usar el descubrimiento autom\u00e1tico" } } } diff --git a/homeassistant/components/derivative/translations/es.json b/homeassistant/components/derivative/translations/es.json index e6df75ba4d6..e9b0919f06f 100644 --- a/homeassistant/components/derivative/translations/es.json +++ b/homeassistant/components/derivative/translations/es.json @@ -4,11 +4,17 @@ "user": { "data": { "name": "Nombre", + "round": "Precisi\u00f3n", "source": "Sensor de entrada", "time_window": "Ventana de tiempo", "unit_prefix": "Prefijo m\u00e9trico", "unit_time": "Unidad de tiempo" }, + "data_description": { + "round": "Controla el n\u00famero de d\u00edgitos decimales en la salida.", + "time_window": "Si se establece, el valor del sensor es un promedio m\u00f3vil ponderado en el tiempo de las derivadas dentro de esta ventana.", + "unit_prefix": "La salida se escalar\u00e1 seg\u00fan el prefijo m\u00e9trico seleccionado y la unidad de tiempo de la derivada." + }, "description": "Crea un sensor que ama la derivada de otro sensor.", "title": "A\u00f1ade sensor derivativo" } @@ -20,11 +26,14 @@ "data": { "name": "Nombre", "round": "Precisi\u00f3n", + "source": "Sensor de entrada", + "time_window": "Ventana de tiempo", "unit_prefix": "Prefijo m\u00e9trico", "unit_time": "Unidad de tiempo" }, "data_description": { "round": "Controla el n\u00famero de d\u00edgitos decimales en la salida.", + "time_window": "Si se establece, el valor del sensor es una media m\u00f3vil ponderada en el tiempo de las derivadas dentro de esta ventana.", "unit_prefix": "a salida se escalar\u00e1 seg\u00fan el prefijo m\u00e9trico y la unidad de tiempo de la derivada seleccionados." } } diff --git a/homeassistant/components/discord/translations/es.json b/homeassistant/components/discord/translations/es.json index 768afb877f3..f4f7bd49fc5 100644 --- a/homeassistant/components/discord/translations/es.json +++ b/homeassistant/components/discord/translations/es.json @@ -1,10 +1,13 @@ { "config": { "abort": { + "already_configured": "El servicio ya est\u00e1 configurado", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, "error": { - "cannot_connect": "Fall\u00f3 la conexi\u00f3n" + "cannot_connect": "Fall\u00f3 la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { @@ -16,7 +19,8 @@ "user": { "data": { "api_token": "Token API" - } + }, + "description": "Consulte la documentaci\u00f3n sobre c\u00f3mo obtener su clave de bot de Discord. \n\n {url}" } } } diff --git a/homeassistant/components/dlna_dms/translations/es.json b/homeassistant/components/dlna_dms/translations/es.json index 1ce13967ea5..f9d08f90451 100644 --- a/homeassistant/components/dlna_dms/translations/es.json +++ b/homeassistant/components/dlna_dms/translations/es.json @@ -1,16 +1,23 @@ { "config": { "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El proceso de configuraci\u00f3n ya est\u00e1 en curso", "bad_ssdp": "Falta un valor necesario en los datos SSDP", - "no_devices_found": "No se han encontrado dispositivos en la red" + "no_devices_found": "No se han encontrado dispositivos en la red", + "not_dms": "El dispositivo no es un servidor multimedia compatible" }, "flow_title": "{name}", "step": { + "confirm": { + "description": "\u00bfQuiere empezar a configurar?" + }, "user": { "data": { "host": "Host" }, - "description": "Escoge un dispositivo a configurar" + "description": "Escoge un dispositivo a configurar", + "title": "Dispositivos DLNA DMA descubiertos" } } } diff --git a/homeassistant/components/doorbird/translations/es.json b/homeassistant/components/doorbird/translations/es.json index 68de2419d2a..355a48e9191 100644 --- a/homeassistant/components/doorbird/translations/es.json +++ b/homeassistant/components/doorbird/translations/es.json @@ -27,6 +27,9 @@ "init": { "data": { "events": "Lista de eventos separados por comas." + }, + "data_description": { + "events": "A\u00f1ade un nombre de evento separado por comas para cada evento que desee rastrear. Despu\u00e9s de ingresarlos aqu\u00ed, use la aplicaci\u00f3n DoorBird para asignarlos a un evento espec\u00edfico. \n\n Ejemplo: alguien_puls\u00f3_el_bot\u00f3n, movimiento" } } } diff --git a/homeassistant/components/elkm1/translations/es.json b/homeassistant/components/elkm1/translations/es.json index 2bd75f83e2f..9df8e1f4ddf 100644 --- a/homeassistant/components/elkm1/translations/es.json +++ b/homeassistant/components/elkm1/translations/es.json @@ -5,6 +5,7 @@ "already_configured": "Ya est\u00e1 configurado un Elk-M1 con este prefijo", "already_in_progress": "La configuraci\u00f3n ya se encuentra en proceso", "cannot_connect": "Error al conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, "error": { diff --git a/homeassistant/components/fibaro/translations/es.json b/homeassistant/components/fibaro/translations/es.json index 00a7eeb8ece..99f29f3bee5 100644 --- a/homeassistant/components/fibaro/translations/es.json +++ b/homeassistant/components/fibaro/translations/es.json @@ -1,11 +1,17 @@ { "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, "error": { - "cannot_connect": "Fall\u00f3 la conexi\u00f3n" + "cannot_connect": "Fall\u00f3 la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" }, "step": { "user": { "data": { + "import_plugins": "\u00bfImportar entidades desde los plugins de fibaro?", "password": "Contrase\u00f1a", "url": "URL en el format http://HOST/api/", "username": "Usuario" diff --git a/homeassistant/components/filesize/translations/es.json b/homeassistant/components/filesize/translations/es.json index e2bc079b961..ee030e86cf9 100644 --- a/homeassistant/components/filesize/translations/es.json +++ b/homeassistant/components/filesize/translations/es.json @@ -1,7 +1,19 @@ { "config": { + "abort": { + "already_configured": "El servicio ya est\u00e1 configurado" + }, "error": { - "not_allowed": "Ruta no permitida" + "not_allowed": "Ruta no permitida", + "not_valid": "La ruta no es v\u00e1lida" + }, + "step": { + "user": { + "data": { + "file_path": "Ruta al archivo" + } + } } - } + }, + "title": "Tama\u00f1o del archivo" } \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/es.json b/homeassistant/components/fritz/translations/es.json index 964db5b5325..f1386a9e87f 100644 --- a/homeassistant/components/fritz/translations/es.json +++ b/homeassistant/components/fritz/translations/es.json @@ -10,7 +10,8 @@ "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en proceso", "cannot_connect": "No se pudo conectar", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "upnp_not_configured": "Falta la configuraci\u00f3n de UPnP en el dispositivo." }, "flow_title": "FRITZ!Box Tools: {name}", "step": { @@ -46,7 +47,8 @@ "step": { "init": { "data": { - "consider_home": "Segundos para considerar un dispositivo en 'casa'" + "consider_home": "Segundos para considerar un dispositivo en 'casa'", + "old_discovery": "Habilitar m\u00e9todo de descubrimiento antiguo" } } } diff --git a/homeassistant/components/generic/translations/es.json b/homeassistant/components/generic/translations/es.json index d0e2087acf7..8e1189f30f1 100644 --- a/homeassistant/components/generic/translations/es.json +++ b/homeassistant/components/generic/translations/es.json @@ -1,20 +1,29 @@ { "config": { "abort": { + "no_devices_found": "No se encontraron dispositivos en la red", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { "already_exists": "Ya hay una c\u00e1mara con esa URL de configuraci\u00f3n.", "invalid_still_image": "La URL no ha devuelto una imagen fija v\u00e1lida", "no_still_image_or_stream_url": "Tienes que especificar al menos una imagen una URL de flujo", + "stream_file_not_found": "No se encontr\u00f3 el archivo al intentar conectarse a la transmisi\u00f3n (\u00bfest\u00e1 instalado ffmpeg?)", "stream_http_not_found": "HTTP 404 'Not found' al intentar conectarse al flujo de datos ('stream')", + "stream_io_error": "Error de entrada/salida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_no_route_to_host": "No se pudo encontrar el anfitri\u00f3n mientras intentaba conectar al flujo de datos", "stream_no_video": "El flujo no contiene v\u00eddeo", + "stream_not_permitted": "Operaci\u00f3n no permitida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_unauthorised": "La autorizaci\u00f3n ha fallado mientras se intentaba conectar con el flujo de datos", + "template_error": "Error al renderizar la plantilla. Revise el registro para m\u00e1s informaci\u00f3n.", "timeout": "El tiempo m\u00e1ximo de carga de la URL ha expirado", + "unable_still_load": "No se puede cargar una imagen v\u00e1lida desde la URL de la imagen fija (p. ej., host no v\u00e1lido, URL o error de autenticaci\u00f3n). Revise el registro para obtener m\u00e1s informaci\u00f3n.", "unknown": "Error inesperado" }, "step": { + "confirm": { + "description": "\u00bfQuiere empezar a configurar?" + }, "content_type": { "data": { "content_type": "Tipos de contenido" @@ -26,21 +35,32 @@ "authentication": "Autenticaci\u00f3n", "framerate": "Frecuencia de visualizaci\u00f3n (Hz)", "limit_refetch_to_url_change": "Limita la lectura al cambio de URL", + "password": "Contrase\u00f1a", + "rtsp_transport": "Protocolo de transporte RTSP", "still_image_url": "URL de imagen fija (ej. http://...)", "stream_source": "URL origen del flux (p. ex. rtsp://...)", "username": "Usuario", "verify_ssl": "Verifica el certificat SSL" - } + }, + "description": "Introduzca los ajustes para conectarse a la c\u00e1mara." } } }, "options": { "error": { "already_exists": "Ya hay una c\u00e1mara con esa URL de configuraci\u00f3n.", + "invalid_still_image": "La URL no devolvi\u00f3 una imagen fija v\u00e1lida", + "no_still_image_or_stream_url": "Debe especificar al menos una imagen fija o URL de transmisi\u00f3n", + "stream_file_not_found": "No se encontr\u00f3 el archivo al intentar conectarse a la transmisi\u00f3n (\u00bfest\u00e1 instalado ffmpeg?)", + "stream_http_not_found": "HTTP 404 No encontrado al intentar conectarse a la transmisi\u00f3n", + "stream_io_error": "Error de entrada/salida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_no_route_to_host": "No se pudo encontrar el anfitri\u00f3n mientras intentaba conectar al flujo de datos", + "stream_no_video": "La transmisi\u00f3n no tiene video", + "stream_not_permitted": "Operaci\u00f3n no permitida al intentar conectarse a la transmisi\u00f3n. \u00bfProtocolo de transporte RTSP incorrecto?", "stream_unauthorised": "La autorizaci\u00f3n ha fallado mientras se intentaba conectar con el flujo de datos", "template_error": "Error al renderizar la plantilla. Revise el registro para m\u00e1s informaci\u00f3n.", "timeout": "El tiempo m\u00e1ximo de carga de la URL ha expirado", + "unable_still_load": "No se puede cargar una imagen v\u00e1lida desde la URL de la imagen fija (por ejemplo, host no v\u00e1lido, URL o error de autenticaci\u00f3n). Revise el registro para obtener m\u00e1s informaci\u00f3n.", "unknown": "Error inesperado" }, "step": { @@ -58,8 +78,13 @@ "password": "Contrase\u00f1a", "rtsp_transport": "Protocolo de transporte RTSP", "still_image_url": "URL de imagen fija (ej. http://...)", + "stream_source": "URL de origen de la transmisi\u00f3n (por ejemplo, rtsp://...)", + "use_wallclock_as_timestamps": "Usar el reloj de pared como marca de tiempo", "username": "Usuario", "verify_ssl": "Verifica el certificado SSL" + }, + "data_description": { + "use_wallclock_as_timestamps": "Esta opci\u00f3n puede corregir los problemas de segmentaci\u00f3n o bloqueo que surgen de las implementaciones de marcas de tiempo defectuosas en algunas c\u00e1maras" } } } diff --git a/homeassistant/components/geocaching/translations/es.json b/homeassistant/components/geocaching/translations/es.json index 8b03adca234..8fd4800c588 100644 --- a/homeassistant/components/geocaching/translations/es.json +++ b/homeassistant/components/geocaching/translations/es.json @@ -5,6 +5,7 @@ "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", "authorize_url_timeout": "Tiempo de espera agotado durante la generaci\u00f3n de la URL de autorizaci\u00f3n.", "missing_configuration": "El componente no est\u00e1 configurado. Mira su documentaci\u00f3n.", + "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [compruebe la secci\u00f3n de ayuda]({docs_url})", "oauth_error": "Se han recibido datos token inv\u00e1lidos.", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, diff --git a/homeassistant/components/geofency/translations/es.json b/homeassistant/components/geofency/translations/es.json index 22aa30cc182..6726a4d2e3b 100644 --- a/homeassistant/components/geofency/translations/es.json +++ b/homeassistant/components/geofency/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cloud_not_connected": "No est\u00e1 conectado a Home Assistant Cloud.", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, diff --git a/homeassistant/components/github/translations/es.json b/homeassistant/components/github/translations/es.json index d53004c3cb5..37bf1d4689e 100644 --- a/homeassistant/components/github/translations/es.json +++ b/homeassistant/components/github/translations/es.json @@ -4,6 +4,9 @@ "already_configured": "El servicio ya est\u00e1 configurado", "could_not_register": "No se pudo registrar la integraci\u00f3n con GitHub" }, + "progress": { + "wait_for_device": "1. Abra {url}\n 2.Pegue la siguiente clave para autorizar la integraci\u00f3n:\n ```\n {code}\n ```\n" + }, "step": { "repositories": { "data": { diff --git a/homeassistant/components/google/translations/es.json b/homeassistant/components/google/translations/es.json index c6d990c2caa..aed1ec5d9ad 100644 --- a/homeassistant/components/google/translations/es.json +++ b/homeassistant/components/google/translations/es.json @@ -1,6 +1,10 @@ { "config": { "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "already_in_progress": "El proceso de configuraci\u00f3n ya est\u00e1 en curso", + "code_expired": "El c\u00f3digo de autenticaci\u00f3n caduc\u00f3 o la configuraci\u00f3n de la credencial no es v\u00e1lida, int\u00e9ntelo de nuevo.", + "invalid_access_token": "Token de acceso no v\u00e1lido", "missing_configuration": "El componente no est\u00e1 configurado. Por favor, sigue la documentaci\u00f3n.", "oauth_error": "Se han recibido datos token inv\u00e1lidos.", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" @@ -8,12 +12,28 @@ "create_entry": { "default": "Autenticaci\u00f3n exitosa" }, + "progress": { + "exchange": "Para vincular su cuenta de Google, visite [ {url} ]( {url} ) e ingrese el c\u00f3digo: \n\n {user_code}" + }, "step": { "auth": { "title": "Vincular cuenta de Google" }, "pick_implementation": { "title": "Selecciona el m\u00e9todo de autenticaci\u00f3n" + }, + "reauth_confirm": { + "description": "La integraci\u00f3n de Google Calendar necesita volver a autenticar su cuenta", + "title": "Integraci\u00f3n de la reautenticaci\u00f3n" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "calendar_access": "Acceso de Home Assistant a Google Calendar" + } } } } diff --git a/homeassistant/components/group/translations/es.json b/homeassistant/components/group/translations/es.json index 67742954447..c58cb744a92 100644 --- a/homeassistant/components/group/translations/es.json +++ b/homeassistant/components/group/translations/es.json @@ -3,34 +3,43 @@ "step": { "binary_sensor": { "data": { - "hide_members": "Esconde miembros" + "all": "Todas las entidades", + "entities": "Miembros", + "hide_members": "Esconde miembros", + "name": "Nombre" }, + "description": "Si \"todas las entidades\" est\u00e1n habilitadas, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1n deshabilitadas, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado.", "title": "Agregar grupo" }, "cover": { "data": { + "entities": "Miembros", "hide_members": "Esconde miembros", "name": "Nombre del Grupo" - } + }, + "title": "A\u00f1adir grupo" }, "fan": { "data": { "entities": "Miembros", - "hide_members": "Esconde miembros" + "hide_members": "Esconde miembros", + "name": "Nombre" }, "title": "Agregar grupo" }, "light": { "data": { "entities": "Miembros", - "hide_members": "Esconde miembros" + "hide_members": "Esconde miembros", + "name": "Nombre" }, "title": "Agregar grupo" }, "lock": { "data": { "entities": "Miembros", - "hide_members": "Esconde miembros" + "hide_members": "Esconde miembros", + "name": "Nombre" }, "title": "Agregar grupo" }, @@ -44,16 +53,24 @@ }, "switch": { "data": { - "entities": "Miembros" - } + "entities": "Miembros", + "hide_members": "Ocultar miembros", + "name": "Nombre" + }, + "title": "A\u00f1adir grupo" }, "user": { + "description": "Los grupos permiten crear una nueva entidad que representa a varias entidades del mismo tipo.", "menu_options": { "binary_sensor": "Grupo de sensores binarios", "cover": "Grupo de cubiertas", "fan": "Grupo de ventiladores", + "light": "Grupo de luz", + "lock": "Bloquear el grupo", + "media_player": "Grupo de reproductores multimedia", "switch": "Grupo de conmutadores" - } + }, + "title": "A\u00f1adir grupo" } } }, @@ -62,28 +79,35 @@ "binary_sensor": { "data": { "all": "Todas las entidades", + "entities": "Miembros", "hide_members": "Esconde miembros" - } + }, + "description": "Si \"todas las entidades\" est\u00e1 habilitado, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1 deshabilitado, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado." }, "cover": { - "data": { - "hide_members": "Esconde miembros" - } - }, - "fan": { - "data": { - "hide_members": "Esconde miembros" - } - }, - "light": { "data": { "entities": "Miembros", "hide_members": "Esconde miembros" } }, + "fan": { + "data": { + "entities": "Miembros", + "hide_members": "Esconde miembros" + } + }, + "light": { + "data": { + "all": "Todas las entidades", + "entities": "Miembros", + "hide_members": "Esconde miembros" + }, + "description": "Si \"todas las entidades\" est\u00e1 habilitado, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1 deshabilitado, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado." + }, "lock": { "data": { - "entities": "Miembros" + "entities": "Miembros", + "hide_members": "Ocultar miembros" } }, "media_player": { @@ -91,6 +115,14 @@ "entities": "Miembros", "hide_members": "Esconde miembros" } + }, + "switch": { + "data": { + "all": "Todas las entidades", + "entities": "Miembros", + "hide_members": "Ocultar miembros" + }, + "description": "Si \"todas las entidades\" est\u00e1 habilitado, el estado del grupo est\u00e1 activado solo si todos los miembros est\u00e1n activados. Si \"todas las entidades\" est\u00e1 deshabilitado, el estado del grupo es activado si alg\u00fan miembro est\u00e1 activado." } } }, diff --git a/homeassistant/components/here_travel_time/translations/es.json b/homeassistant/components/here_travel_time/translations/es.json new file mode 100644 index 00000000000..c1a8d9cef11 --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/es.json @@ -0,0 +1,82 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "destination_coordinates": { + "data": { + "destination": "Destino como coordenadas GPS" + }, + "title": "Elija el destino" + }, + "destination_entity_id": { + "data": { + "destination_entity_id": "Destino usando una entidad" + }, + "title": "Elija el destino" + }, + "destination_menu": { + "menu_options": { + "destination_coordinates": "Usando una ubicaci\u00f3n en el mapa", + "destination_entity": "Usando una entidad" + }, + "title": "Elija el destino" + }, + "origin_coordinates": { + "data": { + "origin": "Origen como coordenadas GPS" + }, + "title": "Elija el origen" + }, + "origin_entity_id": { + "data": { + "origin_entity_id": "Origen usando una entidad" + }, + "title": "Elija el origen" + }, + "user": { + "data": { + "api_key": "Clave API", + "mode": "Modo de viaje", + "name": "Nombre" + } + } + } + }, + "options": { + "step": { + "arrival_time": { + "data": { + "arrival_time": "Hora de llegada" + }, + "title": "Elija la hora de llegada" + }, + "departure_time": { + "data": { + "departure_time": "Hora de salida" + }, + "title": "Elija la hora de salida" + }, + "init": { + "data": { + "route_mode": "Modo de ruta", + "traffic_mode": "Modo de tr\u00e1fico", + "unit_system": "Sistema de unidades" + } + }, + "time_menu": { + "menu_options": { + "arrival_time": "Configurar una hora de llegada", + "departure_time": "Configurar una hora de salida", + "no_time": "No configurar una hora" + }, + "title": "Elija el tipo de hora" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit/translations/es.json b/homeassistant/components/homekit/translations/es.json index 9aa71faef12..5076e7b8800 100644 --- a/homeassistant/components/homekit/translations/es.json +++ b/homeassistant/components/homekit/translations/es.json @@ -22,7 +22,8 @@ "accessory": { "data": { "entities": "Entidad" - } + }, + "title": "Seleccione la entidad para el accesorio" }, "advanced": { "data": { @@ -43,16 +44,20 @@ "data": { "entities": "Entidades" }, + "description": "Se incluir\u00e1n todas las entidades de \" {domains} \" excepto las entidades excluidas y las entidades categorizadas.", "title": "Selecciona las entidades a excluir" }, "include": { "data": { "entities": "Entidades" - } + }, + "description": "Se incluir\u00e1n todas las entidades de \" {domains} \" a menos que se seleccionen entidades espec\u00edficas.", + "title": "Seleccione las entidades a incluir" }, "init": { "data": { "domains": "Dominios a incluir", + "include_exclude_mode": "Modo de inclusi\u00f3n", "mode": "Mode de HomeKit" }, "description": "Las entidades de los \"Dominios que se van a incluir\" se establecer\u00e1n en HomeKit. Podr\u00e1 seleccionar qu\u00e9 entidades excluir de esta lista en la siguiente pantalla.", diff --git a/homeassistant/components/homekit_controller/translations/select.es.json b/homeassistant/components/homekit_controller/translations/select.es.json index 0cbfbc71373..13c45f8e538 100644 --- a/homeassistant/components/homekit_controller/translations/select.es.json +++ b/homeassistant/components/homekit_controller/translations/select.es.json @@ -2,6 +2,7 @@ "state": { "homekit_controller__ecobee_mode": { "away": "Afuera", + "home": "Inicio", "sleep": "Durmiendo" } } diff --git a/homeassistant/components/honeywell/translations/es.json b/homeassistant/components/honeywell/translations/es.json index f30e9606a4d..98ad6871b81 100644 --- a/homeassistant/components/honeywell/translations/es.json +++ b/homeassistant/components/honeywell/translations/es.json @@ -17,8 +17,10 @@ "step": { "init": { "data": { - "away_cool_temperature": "Temperatura fria, modo fuera" - } + "away_cool_temperature": "Temperatura fria, modo fuera", + "away_heat_temperature": "Temperatura del calor exterior" + }, + "description": "Opciones de configuraci\u00f3n adicionales de Honeywell. Las temperaturas se establecen en Fahrenheit." } } } diff --git a/homeassistant/components/ialarm_xr/translations/es.json b/homeassistant/components/ialarm_xr/translations/es.json index f5755835d3d..41cc88c7e6f 100644 --- a/homeassistant/components/ialarm_xr/translations/es.json +++ b/homeassistant/components/ialarm_xr/translations/es.json @@ -11,6 +11,7 @@ "step": { "user": { "data": { + "host": "Anfitri\u00f3n", "password": "Contrase\u00f1a", "port": "Puerto", "username": "Nombre de usuario" diff --git a/homeassistant/components/ifttt/translations/es.json b/homeassistant/components/ifttt/translations/es.json index b29862c38e2..e65f12b6295 100644 --- a/homeassistant/components/ifttt/translations/es.json +++ b/homeassistant/components/ifttt/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cloud_not_connected": "No est\u00e1 conectado a Home Assistant Cloud.", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, diff --git a/homeassistant/components/integration/translations/es.json b/homeassistant/components/integration/translations/es.json index 8034e4746fe..4b4f1306dc9 100644 --- a/homeassistant/components/integration/translations/es.json +++ b/homeassistant/components/integration/translations/es.json @@ -4,6 +4,7 @@ "user": { "data": { "method": "M\u00e9todo de integraci\u00f3n", + "name": "Nombre", "round": "Precisi\u00f3n", "source": "Sensor de entrada", "unit_prefix": "Prefijo m\u00e9trico", @@ -13,7 +14,9 @@ "round": "Controla el n\u00famero de d\u00edgitos decimales en la salida.", "unit_prefix": "La salida se escalar\u00e1 seg\u00fan el prefijo m\u00e9trico seleccionado.", "unit_time": "La salida se escalar\u00e1 seg\u00fan la unidad de tiempo seleccionada." - } + }, + "description": "Cree un sensor que calcule una suma de Riemann para estimar la integral de un sensor.", + "title": "A\u00f1adir sensor integral de suma de Riemann" } } }, diff --git a/homeassistant/components/intellifire/translations/es.json b/homeassistant/components/intellifire/translations/es.json index 8d19b2ba3bf..4b61f7f4b3e 100644 --- a/homeassistant/components/intellifire/translations/es.json +++ b/homeassistant/components/intellifire/translations/es.json @@ -24,7 +24,8 @@ "manual_device_entry": { "data": { "host": "Host (direcci\u00f3n IP)" - } + }, + "description": "Configuraci\u00f3n local" }, "pick_device": { "data": { diff --git a/homeassistant/components/iss/translations/es.json b/homeassistant/components/iss/translations/es.json index 6ca3e875615..02a03ee5995 100644 --- a/homeassistant/components/iss/translations/es.json +++ b/homeassistant/components/iss/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "latitude_longitude_not_defined": "La latitud y la longitud no est\u00e1n definidas en Home Assistant." + "latitude_longitude_not_defined": "La latitud y la longitud no est\u00e1n definidas en Home Assistant.", + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." }, "step": { "user": { diff --git a/homeassistant/components/isy994/translations/es.json b/homeassistant/components/isy994/translations/es.json index b9b2c1f1ed5..7fd765fb437 100644 --- a/homeassistant/components/isy994/translations/es.json +++ b/homeassistant/components/isy994/translations/es.json @@ -7,6 +7,7 @@ "cannot_connect": "Error al conectar", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "invalid_host": "La entrada del host no estaba en formato URL completo, por ejemplo, http://192.168.10.100:80", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa", "unknown": "Error inesperado" }, "flow_title": "{name} ({host})", diff --git a/homeassistant/components/kaleidescape/translations/es.json b/homeassistant/components/kaleidescape/translations/es.json index 6586a20f202..5cb7047f4f5 100644 --- a/homeassistant/components/kaleidescape/translations/es.json +++ b/homeassistant/components/kaleidescape/translations/es.json @@ -3,8 +3,13 @@ "abort": { "already_configured": "El dispositivo ya est\u00e1 configurado", "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en curso", + "unknown": "Error inesperado", "unsupported": "Dispositiu no compatible" }, + "error": { + "cannot_connect": "Fallo en la conexi\u00f3n", + "unsupported": "Dispositivo no compatible" + }, "flow_title": "{model} ({name})", "step": { "discovery_confirm": { diff --git a/homeassistant/components/knx/translations/es.json b/homeassistant/components/knx/translations/es.json index 6c8ec3e2eb6..d982d96b330 100644 --- a/homeassistant/components/knx/translations/es.json +++ b/homeassistant/components/knx/translations/es.json @@ -6,17 +6,21 @@ }, "error": { "cannot_connect": "Error al conectar", + "file_not_found": "El archivo `.knxkeys` especificado no se encontr\u00f3 en la ruta config/.storage/knx/", "invalid_individual_address": "El valor no coincide con el patr\u00f3n de direcci\u00f3n KNX individual. 'area.line.device'", - "invalid_ip_address": "Direcci\u00f3n IPv4 inv\u00e1lida." + "invalid_ip_address": "Direcci\u00f3n IPv4 inv\u00e1lida.", + "invalid_signature": "La contrase\u00f1a para descifrar el archivo `.knxkeys` es incorrecta." }, "step": { "manual_tunnel": { "data": { "host": "Host", "local_ip": "IP local de Home Assistant", - "port": "Puerto" + "port": "Puerto", + "tunneling_type": "Tipo de t\u00fanel KNX" }, "data_description": { + "host": "Direcci\u00f3n IP del dispositivo de t\u00fanel KNX/IP.", "local_ip": "D\u00e9jalo en blanco para utilizar el descubrimiento autom\u00e1tico.", "port": "Puerto del dispositivo de tunelizaci\u00f3n KNX/IP.Puerto del dispositivo de tunelizaci\u00f3n KNX/IP." }, @@ -29,29 +33,40 @@ "multicast_group": "El grupo de multidifusi\u00f3n utilizado para el enrutamiento", "multicast_port": "El puerto de multidifusi\u00f3n utilizado para el enrutamiento" }, + "data_description": { + "individual_address": "Direcci\u00f3n KNX que usar\u00e1 Home Assistant, por ejemplo, `0.0.4`", + "local_ip": "D\u00e9jelo en blanco para usar el descubrimiento autom\u00e1tico." + }, "description": "Por favor, configure las opciones de enrutamiento." }, "secure_knxkeys": { "data": { + "knxkeys_filename": "El nombre de su archivo `.knxkeys` (incluyendo la extensi\u00f3n)", "knxkeys_password": "Contrase\u00f1a para descifrar el archivo `.knxkeys`." }, "data_description": { + "knxkeys_filename": "Se espera que el archivo se encuentre en su directorio de configuraci\u00f3n en `.storage/knx/`.\n En el sistema operativo Home Assistant, ser\u00eda `/config/.storage/knx/`\n Ejemplo: `mi_proyecto.knxkeys`", "knxkeys_password": "Se ha definido durante la exportaci\u00f3n del archivo desde ETS.Se ha definido durante la exportaci\u00f3n del archivo desde ETS." }, "description": "Introduce la informaci\u00f3n de tu archivo `.knxkeys`." }, "secure_manual": { "data": { - "user_id": "ID de usuario" + "device_authentication": "Contrase\u00f1a de autenticaci\u00f3n del dispositivo", + "user_id": "ID de usuario", + "user_password": "Contrase\u00f1a del usuario" }, "data_description": { - "user_id": "A menudo, es el n\u00famero del t\u00fanel +1. Por tanto, 'T\u00fanel 2' tendr\u00eda el ID de usuario '3'." + "device_authentication": "Esto se configura en el panel 'IP' de la interfaz en ETS.", + "user_id": "A menudo, es el n\u00famero del t\u00fanel +1. Por tanto, 'T\u00fanel 2' tendr\u00eda el ID de usuario '3'.", + "user_password": "Contrase\u00f1a para la conexi\u00f3n espec\u00edfica del t\u00fanel establecida en el panel de \"Propiedades\" del t\u00fanel en ETS." }, "description": "Introduce la informaci\u00f3n de seguridad IP (IP Secure)." }, "secure_tunneling": { "description": "Selecciona c\u00f3mo quieres configurar KNX/IP Secure.", "menu_options": { + "secure_knxkeys": "Use un archivo `.knxkeys` que contenga claves seguras de IP", "secure_manual": "Configura manualmente las claves de seguridad IP (IP Secure)" } }, @@ -83,13 +98,18 @@ }, "data_description": { "individual_address": "Direcci\u00f3n KNX para utilizar con Home Assistant, ej. `0.0.4`", - "rate_limit": "Telegramas de salida m\u00e1ximos por segundo. \nRecomendado: de 20 a 40" + "local_ip": "Usar `0.0.0.0` para el descubrimiento autom\u00e1tico.", + "multicast_group": "Se usa para el enrutamiento y el descubrimiento. Predeterminado: `224.0.23.12`", + "multicast_port": "Se usa para el enrutamiento y el descubrimiento. Predeterminado: `3671`", + "rate_limit": "Telegramas de salida m\u00e1ximos por segundo. \nRecomendado: de 20 a 40", + "state_updater": "Establece los valores predeterminados para leer los estados del bus KNX. Cuando est\u00e1 deshabilitado, Home Assistant no recuperar\u00e1 activamente estados de entidad del bus KNX. Puede ser anulado por las opciones de entidad `sync_state`." } }, "tunnel": { "data": { "host": "Host", - "port": "Puerto" + "port": "Puerto", + "tunneling_type": "Tipo de t\u00fanel KNX" }, "data_description": { "host": "Direcci\u00f3n IP del dispositivo de tunelizaci\u00f3n KNX/IP.", diff --git a/homeassistant/components/laundrify/translations/es.json b/homeassistant/components/laundrify/translations/es.json new file mode 100644 index 00000000000..05c019700bc --- /dev/null +++ b/homeassistant/components/laundrify/translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "error": { + "cannot_connect": "Fallo en la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_format": "Formato no v\u00e1lido. Por favor, especif\u00edquelo como xxx-xxx.", + "unknown": "Error inesperado" + }, + "step": { + "init": { + "data": { + "code": "C\u00f3digo de autenticaci\u00f3n (xxx-xxx)" + }, + "description": "Por favor, introduzca su c\u00f3digo de autenticaci\u00f3n personal que se muestra en la aplicaci\u00f3n Laundrify." + }, + "reauth_confirm": { + "description": "La integraci\u00f3n de laundrify necesita volver a autentificarse.", + "title": "Integraci\u00f3n de la reautenticaci\u00f3n" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/sensor.es.json b/homeassistant/components/litterrobot/translations/sensor.es.json index 1bf023d68bc..ca5695d1347 100644 --- a/homeassistant/components/litterrobot/translations/sensor.es.json +++ b/homeassistant/components/litterrobot/translations/sensor.es.json @@ -4,11 +4,25 @@ "br": "Bolsa extra\u00edda", "ccc": "Ciclo de limpieza completado", "ccp": "Ciclo de limpieza en curso", + "csf": "Fallo del sensor de gatos", + "csi": "Sensor de gatos interrumpido", + "cst": "Tiempo del sensor de gatos", + "df1": "Caj\u00f3n casi lleno - Quedan 2 ciclos", + "df2": "Caj\u00f3n casi lleno - Queda 1 ciclo", + "dfs": "Caj\u00f3n lleno", "dhf": "Error de posici\u00f3n de vertido + inicio", + "dpf": "Fallo de posici\u00f3n de descarga", "ec": "Ciclo vac\u00edo", + "hpf": "Fallo de posici\u00f3n inicial", "off": "Apagado", "offline": "Desconectado", - "rdy": "Listo" + "otf": "Fallo de par excesivo", + "p": "Pausada", + "pd": "Detecci\u00f3n de pellizcos", + "rdy": "Listo", + "scf": "Fallo del sensor de gatos al inicio", + "sdf": "Caj\u00f3n lleno al inicio", + "spf": "Detecci\u00f3n de pellizco al inicio" } } } \ No newline at end of file diff --git a/homeassistant/components/meater/translations/es.json b/homeassistant/components/meater/translations/es.json index 39d35b38d4a..44e5f3984f4 100644 --- a/homeassistant/components/meater/translations/es.json +++ b/homeassistant/components/meater/translations/es.json @@ -1,18 +1,26 @@ { "config": { "error": { + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "service_unavailable_error": "La API no est\u00e1 disponible actualmente, vuelva a intentarlo m\u00e1s tarde.", "unknown_auth_error": "Error inesperado" }, "step": { "reauth_confirm": { + "data": { + "password": "Contrase\u00f1a" + }, "description": "Confirma la contrase\u00f1a de la cuenta de Meater Cloud {username}." }, "user": { "data": { "password": "Contrase\u00f1a", "username": "Usuario" - } + }, + "data_description": { + "username": "Nombre de usuario de Meater Cloud, normalmente una direcci\u00f3n de correo electr\u00f3nico." + }, + "description": "Configure su cuenta de Meater Cloud." } } } diff --git a/homeassistant/components/meteoclimatic/translations/es.json b/homeassistant/components/meteoclimatic/translations/es.json index 027f85c60df..1b6e29770cc 100644 --- a/homeassistant/components/meteoclimatic/translations/es.json +++ b/homeassistant/components/meteoclimatic/translations/es.json @@ -11,6 +11,9 @@ "user": { "data": { "code": "C\u00f3digo de la estaci\u00f3n" + }, + "data_description": { + "code": "Parece ESCAT4300000043206B" } } } diff --git a/homeassistant/components/min_max/translations/es.json b/homeassistant/components/min_max/translations/es.json index aceaa287a3b..fadad650eaa 100644 --- a/homeassistant/components/min_max/translations/es.json +++ b/homeassistant/components/min_max/translations/es.json @@ -5,11 +5,13 @@ "data": { "entity_ids": "Entidades de entrada", "name": "Nombre", + "round_digits": "Precisi\u00f3n", "type": "Caracter\u00edstica estad\u00edstica" }, "data_description": { "round_digits": "Controla el n\u00famero de d\u00edgitos decimales cuando la caracter\u00edstica estad\u00edstica es la media o mediana." }, + "description": "Cree un sensor que calcule un valor m\u00ednimo, m\u00e1ximo, medio o mediano a partir de una lista de sensores de entrada.", "title": "Agregar sensor m\u00edn / m\u00e1x / media / mediana" } } diff --git a/homeassistant/components/nanoleaf/translations/es.json b/homeassistant/components/nanoleaf/translations/es.json index 187517ed478..9899d30d36f 100644 --- a/homeassistant/components/nanoleaf/translations/es.json +++ b/homeassistant/components/nanoleaf/translations/es.json @@ -27,7 +27,10 @@ }, "device_automation": { "trigger_type": { - "swipe_down": "Desliza hacia abajo" + "swipe_down": "Desliza hacia abajo", + "swipe_left": "Deslizar a la izquierda", + "swipe_right": "Deslizar a la derecha", + "swipe_up": "Deslizar hacia arriba" } } } \ No newline at end of file diff --git a/homeassistant/components/onewire/translations/es.json b/homeassistant/components/onewire/translations/es.json index 9fa4912166f..3617f4c8fd4 100644 --- a/homeassistant/components/onewire/translations/es.json +++ b/homeassistant/components/onewire/translations/es.json @@ -25,11 +25,13 @@ "data": { "precision": "Precisi\u00f3n del sensor" }, - "description": "Selecciona la precisi\u00f3n del sensor {sensor_id}" + "description": "Selecciona la precisi\u00f3n del sensor {sensor_id}", + "title": "Precisi\u00f3n del sensor OneWire" }, "device_selection": { "data": { - "clear_device_options": "Borra todas las configuraciones de dispositivo" + "clear_device_options": "Borra todas las configuraciones de dispositivo", + "device_selection": "Seleccionar los dispositivos a configurar" }, "description": "Seleccione los pasos de configuraci\u00f3n a procesar", "title": "Opciones de dispositivo OneWire" diff --git a/homeassistant/components/plugwise/translations/es.json b/homeassistant/components/plugwise/translations/es.json index 8fed1713398..16fae70586d 100644 --- a/homeassistant/components/plugwise/translations/es.json +++ b/homeassistant/components/plugwise/translations/es.json @@ -5,7 +5,8 @@ }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida, comprueba los 8 caracteres de tu Smile ID", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "invalid_setup": "Agregue su Adam en lugar de su Anna, consulte la documentaci\u00f3n de integraci\u00f3n de Home Assistant Plugwise para obtener m\u00e1s informaci\u00f3n", "unknown": "Error inesperado" }, "flow_title": "{name}", @@ -14,7 +15,7 @@ "data": { "flow_type": "Tipo de conexi\u00f3n", "host": "Direcci\u00f3n IP", - "password": "ID de sonrisa", + "password": "ID Smile", "port": "Puerto", "username": "Nombre de usuario de la sonrisa" }, diff --git a/homeassistant/components/powerwall/translations/es.json b/homeassistant/components/powerwall/translations/es.json index c0f73c2ac99..ae578b37e50 100644 --- a/homeassistant/components/powerwall/translations/es.json +++ b/homeassistant/components/powerwall/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "El powerwall ya est\u00e1 configurado", + "cannot_connect": "Fallo en la conexi\u00f3n", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { @@ -13,12 +14,14 @@ "flow_title": "Powerwall de Tesla ({ip_address})", "step": { "confirm_discovery": { - "description": "\u00bfQuieres configurar {name} ({ip_address})?" + "description": "\u00bfQuieres configurar {name} ({ip_address})?", + "title": "Conectar al powerwall" }, "reauth_confim": { "data": { "password": "Contrase\u00f1a" }, + "description": "La contrase\u00f1a suele ser los \u00faltimos 5 caracteres del n\u00famero de serie de Backup Gateway y se puede encontrar en la aplicaci\u00f3n de Tesla o los \u00faltimos 5 caracteres de la contrase\u00f1a que se encuentra dentro de la puerta de Backup Gateway 2.", "title": "Reautorizar la powerwall" }, "user": { diff --git a/homeassistant/components/ps4/translations/es.json b/homeassistant/components/ps4/translations/es.json index a2c6e6fd1f4..4eb7636d0a2 100644 --- a/homeassistant/components/ps4/translations/es.json +++ b/homeassistant/components/ps4/translations/es.json @@ -23,12 +23,18 @@ "ip_address": "Direcci\u00f3n IP", "name": "Nombre", "region": "Regi\u00f3n" + }, + "data_description": { + "code": "Vaya a 'Configuraci\u00f3n' en su consola PlayStation 4. Luego navegue hasta 'Configuraci\u00f3n de conexi\u00f3n de la aplicaci\u00f3n m\u00f3vil' y seleccione 'Agregar dispositivo' para obtener el pin." } }, "mode": { "data": { "ip_address": "Direcci\u00f3n IP (d\u00e9jalo en blanco si usas la detecci\u00f3n autom\u00e1tica).", "mode": "Modo configuraci\u00f3n" + }, + "data_description": { + "ip_address": "D\u00e9jelo en blanco si selecciona la detecci\u00f3n autom\u00e1tica." } } } diff --git a/homeassistant/components/recorder/translations/es.json b/homeassistant/components/recorder/translations/es.json index ef195cd52c9..86bdd0abec8 100644 --- a/homeassistant/components/recorder/translations/es.json +++ b/homeassistant/components/recorder/translations/es.json @@ -2,8 +2,10 @@ "system_health": { "info": { "current_recorder_run": "Hora de inicio de la ejecuci\u00f3n actual", + "database_engine": "Motor de la base de datos", "database_version": "Versi\u00f3n de la base de datos", - "estimated_db_size": "Mida estimada de la base de datos (MiB)" + "estimated_db_size": "Mida estimada de la base de datos (MiB)", + "oldest_recorder_run": "Hora de inicio de ejecuci\u00f3n m\u00e1s antigua" } } } \ No newline at end of file diff --git a/homeassistant/components/rtsp_to_webrtc/translations/es.json b/homeassistant/components/rtsp_to_webrtc/translations/es.json index 13fe65bfc5b..c74f0b6b34d 100644 --- a/homeassistant/components/rtsp_to_webrtc/translations/es.json +++ b/homeassistant/components/rtsp_to_webrtc/translations/es.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "server_failure": "El servidor RTSPtoWebRTC devolvi\u00f3 un error. Consulte los registros para obtener m\u00e1s informaci\u00f3n.", + "server_unreachable": "No se puede comunicar con el servidor RTSPtoWebRTC. Consulte los registros para obtener m\u00e1s informaci\u00f3n.", "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { diff --git a/homeassistant/components/samsungtv/translations/es.json b/homeassistant/components/samsungtv/translations/es.json index 1c2a81cb7c0..29b2a97027d 100644 --- a/homeassistant/components/samsungtv/translations/es.json +++ b/homeassistant/components/samsungtv/translations/es.json @@ -22,6 +22,9 @@ "encrypted_pairing": { "description": "Introduce el PIN que se muestra en {device}." }, + "pairing": { + "description": "\u00bfQuiere configurar {device}? Si nunca conect\u00f3 Home Assistant antes, deber\u00eda ver una ventana emergente en su televisor solicitando autorizaci\u00f3n." + }, "reauth_confirm": { "description": "Despu\u00e9s de enviarlo, acepte la ventana emergente en {device} solicitando autorizaci\u00f3n dentro de los 30 segundos." }, diff --git a/homeassistant/components/season/translations/es.json b/homeassistant/components/season/translations/es.json new file mode 100644 index 00000000000..0d22ef0bc1c --- /dev/null +++ b/homeassistant/components/season/translations/es.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "El servicio ya est\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "type": "Definici\u00f3n del tipo de estaci\u00f3n" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/translations/es.json b/homeassistant/components/sense/translations/es.json index 5621988c60b..3593b08f17c 100644 --- a/homeassistant/components/sense/translations/es.json +++ b/homeassistant/components/sense/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" }, "error": { "cannot_connect": "No se pudo conectar", @@ -13,7 +14,8 @@ "data": { "password": "Contrase\u00f1a" }, - "description": "La integraci\u00f3n Sense debe volver a autenticar la cuenta {email}." + "description": "La integraci\u00f3n Sense debe volver a autenticar la cuenta {email}.", + "title": "Reautenticar la integraci\u00f3n" }, "user": { "data": { @@ -26,7 +28,8 @@ "validation": { "data": { "code": "C\u00f3digo de verificaci\u00f3n" - } + }, + "title": "Detecci\u00f3n de autenticaci\u00f3n multifactor" } } } diff --git a/homeassistant/components/senseme/translations/es.json b/homeassistant/components/senseme/translations/es.json index fefa2c8d70c..a10e4422a82 100644 --- a/homeassistant/components/senseme/translations/es.json +++ b/homeassistant/components/senseme/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "Fallo en la conexi\u00f3n" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/sensibo/translations/es.json b/homeassistant/components/sensibo/translations/es.json index b91e1b63f9b..43d9acc2645 100644 --- a/homeassistant/components/sensibo/translations/es.json +++ b/homeassistant/components/sensibo/translations/es.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "La cuenta ya est\u00e1 configurada" + "already_configured": "La cuenta ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" }, "error": { "cannot_connect": "No se pudo conectar", diff --git a/homeassistant/components/shelly/translations/es.json b/homeassistant/components/shelly/translations/es.json index 25cbb8eb30b..0c0011f7297 100644 --- a/homeassistant/components/shelly/translations/es.json +++ b/homeassistant/components/shelly/translations/es.json @@ -6,6 +6,7 @@ }, "error": { "cannot_connect": "No se pudo conectar", + "firmware_not_fully_provisioned": "El dispositivo no est\u00e1 completamente aprovisionado. P\u00f3ngase en contacto con el servicio de asistencia de Shelly", "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "unknown": "Error inesperado" }, diff --git a/homeassistant/components/simplisafe/translations/es.json b/homeassistant/components/simplisafe/translations/es.json index 44716b87cec..25d8de8bbb3 100644 --- a/homeassistant/components/simplisafe/translations/es.json +++ b/homeassistant/components/simplisafe/translations/es.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso.", + "email_2fa_timed_out": "Se ha agotado el tiempo de espera de la autenticaci\u00f3n de dos factores basada en el correo electr\u00f3nico.", "reauth_successful": "La reautenticaci\u00f3n se realiz\u00f3 correctamente" }, "error": { diff --git a/homeassistant/components/siren/translations/es.json b/homeassistant/components/siren/translations/es.json new file mode 100644 index 00000000000..f3c1468be31 --- /dev/null +++ b/homeassistant/components/siren/translations/es.json @@ -0,0 +1,3 @@ +{ + "title": "Sirena" +} \ No newline at end of file diff --git a/homeassistant/components/sleepiq/translations/es.json b/homeassistant/components/sleepiq/translations/es.json index 57b8f421844..033941b21d2 100644 --- a/homeassistant/components/sleepiq/translations/es.json +++ b/homeassistant/components/sleepiq/translations/es.json @@ -13,6 +13,7 @@ "data": { "password": "Contrase\u00f1a" }, + "description": "La integraci\u00f3n de SleepIQ necesita volver a autenticar su cuenta {username} .", "title": "Reautenticaci\u00f3n de la integraci\u00f3n" }, "user": { diff --git a/homeassistant/components/solax/translations/es.json b/homeassistant/components/solax/translations/es.json index f6658c63353..da4765b897d 100644 --- a/homeassistant/components/solax/translations/es.json +++ b/homeassistant/components/solax/translations/es.json @@ -7,6 +7,8 @@ "step": { "user": { "data": { + "ip_address": "Direcci\u00f3n IP", + "password": "Contrase\u00f1a", "port": "Puerto" } } diff --git a/homeassistant/components/sql/translations/es.json b/homeassistant/components/sql/translations/es.json index 7117115efc7..6811fc498f9 100644 --- a/homeassistant/components/sql/translations/es.json +++ b/homeassistant/components/sql/translations/es.json @@ -4,11 +4,14 @@ "already_configured": "La cuenta ya est\u00e1 configurada" }, "error": { - "db_url_invalid": "URL de la base de datos inv\u00e1lido" + "db_url_invalid": "URL de la base de datos inv\u00e1lido", + "query_invalid": "Consulta SQL no v\u00e1lida" }, "step": { "user": { "data": { + "column": "Columna", + "db_url": "URL de la base de datos", "name": "Nombre", "query": "Selecciona la consulta", "unit_of_measurement": "Unidad de medida", diff --git a/homeassistant/components/steam_online/translations/es.json b/homeassistant/components/steam_online/translations/es.json index 9636fc04a54..26ee994acde 100644 --- a/homeassistant/components/steam_online/translations/es.json +++ b/homeassistant/components/steam_online/translations/es.json @@ -7,10 +7,12 @@ "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "invalid_account": "ID de la cuenta inv\u00e1lida", - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { + "description": "La integraci\u00f3n de Steam debe volver a autenticarse manualmente \n\n Puede encontrar su clave aqu\u00ed: {api_key_url}", "title": "Volver a autenticar la integraci\u00f3n" }, "user": { @@ -23,6 +25,9 @@ } }, "options": { + "error": { + "unauthorized": "Lista de amigos restringida: Por favor, consulte la documentaci\u00f3n sobre c\u00f3mo ver a todos los dem\u00e1s amigos" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/subaru/translations/es.json b/homeassistant/components/subaru/translations/es.json index 170983e8df4..7b3a32c0213 100644 --- a/homeassistant/components/subaru/translations/es.json +++ b/homeassistant/components/subaru/translations/es.json @@ -6,9 +6,12 @@ }, "error": { "bad_pin_format": "El PIN debe tener 4 d\u00edgitos", + "bad_validation_code_format": "El c\u00f3digo de validaci\u00f3n debe tener 6 d\u00edgitos", "cannot_connect": "No se pudo conectar", "incorrect_pin": "PIN incorrecto", - "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida" + "incorrect_validation_code": "C\u00f3digo de validaci\u00f3n incorrecto", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "two_factor_request_failed": "La solicitud del c\u00f3digo 2FA fall\u00f3, int\u00e9ntalo de nuevo" }, "step": { "pin": { diff --git a/homeassistant/components/sun/translations/es.json b/homeassistant/components/sun/translations/es.json index d8ce466236e..68db12462b1 100644 --- a/homeassistant/components/sun/translations/es.json +++ b/homeassistant/components/sun/translations/es.json @@ -1,4 +1,14 @@ { + "config": { + "abort": { + "single_instance_allowed": "Ya configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "step": { + "user": { + "description": "\u00bfQuiere empezar a configurar?" + } + } + }, "state": { "_": { "above_horizon": "Sobre el horizonte", diff --git a/homeassistant/components/switch_as_x/translations/es.json b/homeassistant/components/switch_as_x/translations/es.json index 7e91d3217a4..ad0f9f52bbf 100644 --- a/homeassistant/components/switch_as_x/translations/es.json +++ b/homeassistant/components/switch_as_x/translations/es.json @@ -5,7 +5,8 @@ "data": { "entity_id": "Conmutador", "target_domain": "Nuevo tipo" - } + }, + "description": "Elija un interruptor que desee que aparezca en Home Assistant como luz, cubierta o cualquier otra cosa. El interruptor original se ocultar\u00e1." } } }, diff --git a/homeassistant/components/tankerkoenig/translations/ca.json b/homeassistant/components/tankerkoenig/translations/ca.json index 4935e817ad4..46d523276e7 100644 --- a/homeassistant/components/tankerkoenig/translations/ca.json +++ b/homeassistant/components/tankerkoenig/translations/ca.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada" + "already_configured": "La ubicaci\u00f3 ja est\u00e0 configurada", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", "no_stations": "No s'ha pogut trobar cap estaci\u00f3 a l'abast." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Clau API" + } + }, "select_station": { "data": { "stations": "Estacions" diff --git a/homeassistant/components/tankerkoenig/translations/de.json b/homeassistant/components/tankerkoenig/translations/de.json index 421521a30da..f0ad25857a5 100644 --- a/homeassistant/components/tankerkoenig/translations/de.json +++ b/homeassistant/components/tankerkoenig/translations/de.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Standort ist bereits konfiguriert" + "already_configured": "Standort ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "invalid_auth": "Ung\u00fcltige Authentifizierung", "no_stations": "Konnte keine Station in Reichweite finden." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-Schl\u00fcssel" + } + }, "select_station": { "data": { "stations": "Stationen" diff --git a/homeassistant/components/tankerkoenig/translations/es.json b/homeassistant/components/tankerkoenig/translations/es.json index a9d235710da..bda6c43ce6e 100644 --- a/homeassistant/components/tankerkoenig/translations/es.json +++ b/homeassistant/components/tankerkoenig/translations/es.json @@ -1,22 +1,34 @@ { "config": { "abort": { - "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada" + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" }, "error": { "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", "no_stations": "No se pudo encontrar ninguna estaci\u00f3n al alcance." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Clave API" + } + }, "select_station": { "data": { "stations": "Estaciones" }, + "description": "encontr\u00f3 {stations_count} estaciones en el radio", "title": "Selecciona las estaciones a a\u00f1adir" }, "user": { "data": { - "name": "Nombre de la regi\u00f3n" + "api_key": "Clave API", + "fuel_types": "Tipos de combustible", + "location": "Ubicaci\u00f3n", + "name": "Nombre de la regi\u00f3n", + "radius": "Radio de b\u00fasqueda", + "stations": "Estaciones de servicio adicionales" } } } @@ -25,9 +37,11 @@ "step": { "init": { "data": { + "scan_interval": "Intervalo de actualizaci\u00f3n", "show_on_map": "Muestra las estaciones en el mapa", "stations": "Estaciones" - } + }, + "title": "Opciones de Tankerkoenig" } } } diff --git a/homeassistant/components/tankerkoenig/translations/fr.json b/homeassistant/components/tankerkoenig/translations/fr.json index 865fdae66a1..410150263ab 100644 --- a/homeassistant/components/tankerkoenig/translations/fr.json +++ b/homeassistant/components/tankerkoenig/translations/fr.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'emplacement est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "invalid_auth": "Authentification non valide", "no_stations": "Aucune station-service n'a \u00e9t\u00e9 trouv\u00e9e dans le rayon indiqu\u00e9." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Cl\u00e9 d'API" + } + }, "select_station": { "data": { "stations": "Stations-services" diff --git a/homeassistant/components/tankerkoenig/translations/ja.json b/homeassistant/components/tankerkoenig/translations/ja.json index 8aa88b83f4a..8ee4aae3b88 100644 --- a/homeassistant/components/tankerkoenig/translations/ja.json +++ b/homeassistant/components/tankerkoenig/translations/ja.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + "already_configured": "\u30ed\u30b1\u30fc\u30b7\u30e7\u30f3\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" }, "error": { "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", "no_stations": "\u7bc4\u56f2\u5185\u306b\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API\u30ad\u30fc" + } + }, "select_station": { "data": { "stations": "\u30b9\u30c6\u30fc\u30b7\u30e7\u30f3" diff --git a/homeassistant/components/tankerkoenig/translations/no.json b/homeassistant/components/tankerkoenig/translations/no.json index bbca71d933b..369ac4d3ce4 100644 --- a/homeassistant/components/tankerkoenig/translations/no.json +++ b/homeassistant/components/tankerkoenig/translations/no.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Plasseringen er allerede konfigurert" + "already_configured": "Plasseringen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "invalid_auth": "Ugyldig godkjenning", "no_stations": "Kunne ikke finne noen stasjon innen rekkevidde." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-n\u00f8kkel" + } + }, "select_station": { "data": { "stations": "Stasjoner" diff --git a/homeassistant/components/tankerkoenig/translations/zh-Hant.json b/homeassistant/components/tankerkoenig/translations/zh-Hant.json index 2a0e5b5d5c6..1e1a5d6e15a 100644 --- a/homeassistant/components/tankerkoenig/translations/zh-Hant.json +++ b/homeassistant/components/tankerkoenig/translations/zh-Hant.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u5ea7\u6a19\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", "no_stations": "\u7bc4\u570d\u5167\u627e\u4e0d\u5230\u4efb\u4f55\u52a0\u6cb9\u7ad9\u3002" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u91d1\u9470" + } + }, "select_station": { "data": { "stations": "\u52a0\u6cb9\u7ad9" diff --git a/homeassistant/components/tautulli/translations/es.json b/homeassistant/components/tautulli/translations/es.json index 0d6fd1d3b9e..295683bf358 100644 --- a/homeassistant/components/tautulli/translations/es.json +++ b/homeassistant/components/tautulli/translations/es.json @@ -5,20 +5,25 @@ "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." }, "error": { - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida" + "cannot_connect": "Fallo en la conexi\u00f3n", + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" }, "step": { "reauth_confirm": { "data": { "api_key": "Clave API" }, + "description": "Para encontrar su clave API, abra la p\u00e1gina web de Tautulli y navegue a Configuraci\u00f3n y luego a la interfaz web. La clave API estar\u00e1 en la parte inferior de esa p\u00e1gina.", "title": "Re-autenticaci\u00f3n de Tautulli" }, "user": { "data": { + "api_key": "Clave API", "url": "URL", "verify_ssl": "Verifica el certificat SSL" - } + }, + "description": "Para encontrar su clave de API, abra la p\u00e1gina web de Tautulli y navegue hasta Configuraci\u00f3n y luego hasta Interfaz web. La clave API estar\u00e1 en la parte inferior de esa p\u00e1gina.\n\nEjemplo de la URL: ```http://192.168.0.10:8181`` con 8181 como puerto predeterminado." } } } diff --git a/homeassistant/components/threshold/translations/es.json b/homeassistant/components/threshold/translations/es.json index 0200c840243..585a690108f 100644 --- a/homeassistant/components/threshold/translations/es.json +++ b/homeassistant/components/threshold/translations/es.json @@ -1,22 +1,36 @@ { "config": { + "error": { + "need_lower_upper": "Los l\u00edmites superior e inferior no pueden estar vac\u00edos" + }, "step": { "user": { "data": { "entity_id": "Sensor de entrada", "hysteresis": "Hist\u00e9resis", "lower": "L\u00edmite inferior", + "name": "Nombre", "upper": "L\u00edmite superior" - } + }, + "description": "Cree un sensor binario que se encienda y apague dependiendo del valor de un sensor \n\n Solo l\u00edmite inferior configurado: se enciende cuando el valor del sensor de entrada es menor que el l\u00edmite inferior.\n Solo l\u00edmite superior configurado: se enciende cuando el valor del sensor de entrada es mayor que el l\u00edmite superior.\n Ambos l\u00edmites inferior y superior configurados: se activa cuando el valor del sensor de entrada est\u00e1 en el rango [l\u00edmite inferior ... l\u00edmite superior].", + "title": "A\u00f1adir sensor de umbral" } } }, "options": { + "error": { + "need_lower_upper": "Los l\u00edmites superior e inferior no pueden estar vac\u00edos" + }, "step": { "init": { "data": { - "hysteresis": "Hist\u00e9resis" - } + "entity_id": "Sensor de entrada", + "hysteresis": "Hist\u00e9resis", + "lower": "L\u00edmite inferior", + "name": "Nombre", + "upper": "L\u00edmite superior" + }, + "description": "Solo l\u00edmite inferior configurado: se enciende cuando el valor del sensor de entrada es menor que el l\u00edmite inferior.\n Solo l\u00edmite superior configurado: se enciende cuando el valor del sensor de entrada es mayor que el l\u00edmite superior.\n Ambos l\u00edmites inferior y superior configurados: se activa cuando el valor del sensor de entrada est\u00e1 en el rango [l\u00edmite inferior ... l\u00edmite superior]." } } }, diff --git a/homeassistant/components/tod/translations/es.json b/homeassistant/components/tod/translations/es.json index 302dc6cfdd9..c1ea525e10c 100644 --- a/homeassistant/components/tod/translations/es.json +++ b/homeassistant/components/tod/translations/es.json @@ -4,7 +4,8 @@ "user": { "data": { "after_time": "Tiempo de activaci\u00f3n", - "before_time": "Tiempo de desactivaci\u00f3n" + "before_time": "Tiempo de desactivaci\u00f3n", + "name": "Nombre" }, "description": "Crea un sensor binario que se activa o desactiva en funci\u00f3n de la hora.", "title": "A\u00f1ade sensor tiempo del d\u00eda" @@ -15,7 +16,8 @@ "step": { "init": { "data": { - "after_time": "Tiempo de activaci\u00f3n" + "after_time": "Tiempo de activaci\u00f3n", + "before_time": "Tiempo apagado" } } } diff --git a/homeassistant/components/tomorrowio/translations/es.json b/homeassistant/components/tomorrowio/translations/es.json index e2f36a0949e..bacc07bcdfc 100644 --- a/homeassistant/components/tomorrowio/translations/es.json +++ b/homeassistant/components/tomorrowio/translations/es.json @@ -3,6 +3,7 @@ "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "invalid_api_key": "Clave API inv\u00e1lida", + "rate_limited": "Actualmente la tarifa est\u00e1 limitada, por favor int\u00e9ntelo m\u00e1s tarde.", "unknown": "Error inesperado" }, "step": { @@ -21,7 +22,9 @@ "init": { "data": { "timestep": "Min. entre previsiones de NowCast" - } + }, + "description": "Si elige habilitar la entidad de pron\u00f3stico \"nowcast\", puede configurar el n\u00famero de minutos entre cada pron\u00f3stico. El n\u00famero de pron\u00f3sticos proporcionados depende del n\u00famero de minutos elegidos entre los pron\u00f3sticos.", + "title": "Actualizar las opciones de Tomorrow.io" } } } diff --git a/homeassistant/components/tomorrowio/translations/sensor.es.json b/homeassistant/components/tomorrowio/translations/sensor.es.json index 03820d30265..3967aeba2e2 100644 --- a/homeassistant/components/tomorrowio/translations/sensor.es.json +++ b/homeassistant/components/tomorrowio/translations/sensor.es.json @@ -1,15 +1,26 @@ { "state": { "tomorrowio__health_concern": { + "good": "Bueno", + "hazardous": "Peligroso", + "moderate": "Moderado", "unhealthy": "Poco saludable", "unhealthy_for_sensitive_groups": "No saludable para grupos sensibles", "very_unhealthy": "Nada saludable" }, "tomorrowio__pollen_index": { + "high": "Alto", + "low": "Bajo", "medium": "Medio", + "none": "Ninguna", + "very_high": "Muy alto", "very_low": "Muy bajo" }, "tomorrowio__precipitation_type": { + "freezing_rain": "Lluvia g\u00e9lida", + "ice_pellets": "Perdigones de hielo", + "none": "Ninguna", + "rain": "Lluvia", "snow": "Nieve" } } diff --git a/homeassistant/components/traccar/translations/es.json b/homeassistant/components/traccar/translations/es.json index 851984b0024..d23aa678816 100644 --- a/homeassistant/components/traccar/translations/es.json +++ b/homeassistant/components/traccar/translations/es.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cloud_not_connected": "No est\u00e1 conectado a Home Assistant Cloud.", "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n.", "webhook_not_internet_accessible": "Tu instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes webhook." }, diff --git a/homeassistant/components/trafikverket_ferry/translations/es.json b/homeassistant/components/trafikverket_ferry/translations/es.json index 26532fbce5e..0ab89ac0fa8 100644 --- a/homeassistant/components/trafikverket_ferry/translations/es.json +++ b/homeassistant/components/trafikverket_ferry/translations/es.json @@ -1,10 +1,13 @@ { "config": { "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", "reauth_successful": "Re-autenticaci\u00f3n realizada correctamente" }, "error": { + "cannot_connect": "Fallo en la conexi\u00f3n", "incorrect_api_key": "Clave API inv\u00e1lida para la cuenta seleccionada", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "invalid_route": "No se pudo encontrar la ruta con la informaci\u00f3n proporcionada" }, "step": { @@ -18,7 +21,8 @@ "api_key": "Clave API", "from": "Des del puerto", "time": "Hora", - "to": "Al puerto" + "to": "Al puerto", + "weekday": "D\u00edas entre semana" } } } diff --git a/homeassistant/components/trafikverket_train/translations/es.json b/homeassistant/components/trafikverket_train/translations/es.json index 4ce1da04b02..2aca5e59745 100644 --- a/homeassistant/components/trafikverket_train/translations/es.json +++ b/homeassistant/components/trafikverket_train/translations/es.json @@ -1,15 +1,30 @@ { "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + }, "error": { "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "incorrect_api_key": "Clave API inv\u00e1lida para la cuenta seleccionada", - "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida" + "invalid_auth": "Autenticaci\u00f3n inv\u00e1lida", + "invalid_station": "No se pudo encontrar una estaci\u00f3n con el nombre especificado", + "invalid_time": "Hora proporcionada no v\u00e1lida", + "more_stations": "Se encontraron varias estaciones con el nombre especificado" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Clave API" + } + }, "user": { "data": { + "api_key": "Clave API", "from": "Desde la estaci\u00f3n", - "time": "Hora (opcional)" + "time": "Hora (opcional)", + "to": "A la estaci\u00f3n", + "weekday": "D\u00edas" } } } diff --git a/homeassistant/components/tuya/translations/select.es.json b/homeassistant/components/tuya/translations/select.es.json index e29ccdc381f..b6ab1f8ce1b 100644 --- a/homeassistant/components/tuya/translations/select.es.json +++ b/homeassistant/components/tuya/translations/select.es.json @@ -23,12 +23,17 @@ "morning": "Ma\u00f1ana", "night": "Noche" }, + "tuya__curtain_motor_mode": { + "back": "Volver", + "forward": "Adelante" + }, "tuya__decibel_sensitivity": { "0": "Sensibilidad baja", "1": "Sensibilidad alta" }, "tuya__fan_angle": { "30": "30\u00b0", + "60": "60\u00b0", "90": "90\u00b0" }, "tuya__fingerbot_mode": { @@ -48,8 +53,11 @@ "level_9": "Nivel 9" }, "tuya__humidifier_moodlighting": { + "1": "Estado de \u00e1nimo 1", + "2": "Estado de \u00e1nimo 2", "3": "Estado 3", - "4": "Estado 4" + "4": "Estado 4", + "5": "Estado de \u00e1nimo 5" }, "tuya__humidifier_spray_mode": { "auto": "Autom\u00e1tico", diff --git a/homeassistant/components/ukraine_alarm/translations/es.json b/homeassistant/components/ukraine_alarm/translations/es.json index a9efcd0d536..3e1b8f322bb 100644 --- a/homeassistant/components/ukraine_alarm/translations/es.json +++ b/homeassistant/components/ukraine_alarm/translations/es.json @@ -1,12 +1,20 @@ { "config": { "abort": { + "already_configured": "La ubicaci\u00f3n ya est\u00e1 configurada", "cannot_connect": "Fall\u00f3 la conexi\u00f3n", "max_regions": "Se pueden configurar un m\u00e1ximo de 5 regiones", + "rate_limit": "Demasiadas peticiones", "timeout": "Tiempo m\u00e1ximo de espera para establecer la conexi\u00f3n agotado", "unknown": "Error inesperado" }, "step": { + "community": { + "data": { + "region": "Regi\u00f3n" + }, + "description": "Si desea monitorear no solo el estado y el distrito, elija su comunidad espec\u00edfica" + }, "district": { "data": { "region": "Regi\u00f3n" diff --git a/homeassistant/components/unifiprotect/translations/es.json b/homeassistant/components/unifiprotect/translations/es.json index 9ca1b56cf01..eb52d0222fc 100644 --- a/homeassistant/components/unifiprotect/translations/es.json +++ b/homeassistant/components/unifiprotect/translations/es.json @@ -9,11 +9,15 @@ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", "protect_version": "La versi\u00f3n m\u00ednima requerida es v1.20.0. Actualice UniFi Protect y vuelva a intentarlo." }, + "flow_title": "{name} ({ip_address})", "step": { "discovery_confirm": { "data": { + "password": "Contrase\u00f1a", "username": "Usuario" - } + }, + "description": "\u00bfQuieres configurar {name} ({ip_address})? Necesitar\u00e1 un usuario local creado en su consola UniFi OS para iniciar sesi\u00f3n. Los usuarios de Ubiquiti Cloud no funcionar\u00e1n. Para m\u00e1s informaci\u00f3n: {local_user_documentation_url}", + "title": "UniFi Protect descubierto" }, "reauth_confirm": { "data": { @@ -32,6 +36,7 @@ "username": "Nombre de usuario", "verify_ssl": "Verificar el certificado SSL" }, + "description": "Necesitar\u00e1 un usuario local creado en su consola UniFi OS para iniciar sesi\u00f3n. Los usuarios de Ubiquiti Cloud no funcionar\u00e1n. Para m\u00e1s informaci\u00f3n: {local_user_documentation_url}", "title": "Configuraci\u00f3n de UniFi Protect" } } diff --git a/homeassistant/components/uptime/translations/es.json b/homeassistant/components/uptime/translations/es.json new file mode 100644 index 00000000000..84e840f453f --- /dev/null +++ b/homeassistant/components/uptime/translations/es.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n." + }, + "step": { + "user": { + "description": "\u00bfQuiere empezar a configurar?" + } + } + }, + "title": "Tiempo de funcionamiento" +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/sensor.es.json b/homeassistant/components/uptimerobot/translations/sensor.es.json index 1f037738b42..2adb0ff18c5 100644 --- a/homeassistant/components/uptimerobot/translations/sensor.es.json +++ b/homeassistant/components/uptimerobot/translations/sensor.es.json @@ -1,8 +1,10 @@ { "state": { "uptimerobot__monitor_status": { + "down": "No disponible", "not_checked_yet": "No comprobado", "pause": "En pausa", + "seems_down": "Parece no disponible", "up": "Funcionante" } } diff --git a/homeassistant/components/utility_meter/translations/es.json b/homeassistant/components/utility_meter/translations/es.json index bea05df125d..a38687741d8 100644 --- a/homeassistant/components/utility_meter/translations/es.json +++ b/homeassistant/components/utility_meter/translations/es.json @@ -5,14 +5,20 @@ "data": { "cycle": "Ciclo de reinicio del contador", "delta_values": "Valores delta", + "name": "Nombre", + "net_consumption": "Consumo neto", + "offset": "Compensaci\u00f3n de reinicio del medidor", "source": "Sensor de entrada", "tariffs": "Tarifas soportadas" }, "data_description": { + "delta_values": "Habilitar si los valores de origen son valores delta desde la \u00faltima lectura en lugar de valores absolutos.", "net_consumption": "Act\u00edvalo si es un contador limpio, es decir, puede aumentar y disminuir.", "offset": "Desplaza el d\u00eda de restablecimiento mensual del contador.", "tariffs": "Lista de tarifas admitidas, d\u00e9jala en blanco si utilizas una \u00fanica tarifa." - } + }, + "description": "Cree un sensor que rastree el consumo de varios servicios p\u00fablicos (p. ej., energ\u00eda, gas, agua, calefacci\u00f3n) durante un per\u00edodo de tiempo configurado, generalmente mensual. El sensor del medidor de servicios admite opcionalmente dividir el consumo por tarifas, en ese caso se crea un sensor para cada tarifa, as\u00ed como una entidad de selecci\u00f3n para elegir la tarifa actual.", + "title": "A\u00f1adir medidor de utilidades" } } }, diff --git a/homeassistant/components/vera/translations/es.json b/homeassistant/components/vera/translations/es.json index 0cccacaa88f..8299b2ea6c9 100644 --- a/homeassistant/components/vera/translations/es.json +++ b/homeassistant/components/vera/translations/es.json @@ -9,6 +9,9 @@ "exclude": "Identificadores de dispositivos Vera a excluir de Home Assistant", "lights": "Identificadores de interruptores Vera que deben ser tratados como luces en Home Assistant", "vera_controller_url": "URL del controlador" + }, + "data_description": { + "vera_controller_url": "Deber\u00eda verse as\u00ed: http://192.168.1.161:3480" } } } diff --git a/homeassistant/components/vulcan/translations/es.json b/homeassistant/components/vulcan/translations/es.json index a92a8878730..7538c6411df 100644 --- a/homeassistant/components/vulcan/translations/es.json +++ b/homeassistant/components/vulcan/translations/es.json @@ -1,29 +1,54 @@ { "config": { "abort": { + "all_student_already_configured": "Ya se han a\u00f1adido todos los estudiantes.", "already_configured": "Ya se ha a\u00f1adido a este alumno.", + "no_matching_entries": "No se encontraron entradas que coincidan, use una cuenta diferente o elimine la integraci\u00f3n con el estudiante obsoleto.", "reauth_successful": "Re-autenticaci\u00f3n exitosa" }, "error": { + "cannot_connect": "Error de conexi\u00f3n - compruebe su conexi\u00f3n a Internet", + "expired_credentials": "Credenciales caducadas - cree nuevas en la p\u00e1gina de registro de la aplicaci\u00f3n m\u00f3vil de Vulcan", "expired_token": "Token caducado, genera un nuevo token", + "invalid_pin": "Pin no v\u00e1lido", "invalid_symbol": "S\u00edmbolo inv\u00e1lido", - "invalid_token": "Token inv\u00e1lido" + "invalid_token": "Token inv\u00e1lido", + "unknown": "Se produjo un error desconocido" }, "step": { + "add_next_config_entry": { + "data": { + "use_saved_credentials": "Usar credenciales guardadas" + }, + "description": "A\u00f1adir otro estudiante." + }, "auth": { "data": { - "pin": "PIN" - } + "pin": "PIN", + "region": "S\u00edmbolo", + "token": "Token" + }, + "description": "Acceda a su cuenta de Vulcan a trav\u00e9s de la p\u00e1gina de registro de la aplicaci\u00f3n m\u00f3vil." }, "reauth_confirm": { "data": { - "region": "S\u00edmbolo" - } + "pin": "Pin", + "region": "S\u00edmbolo", + "token": "Token" + }, + "description": "Acceda a su cuenta de Vulcan a trav\u00e9s de la p\u00e1gina de registro de la aplicaci\u00f3n m\u00f3vil." + }, + "select_saved_credentials": { + "data": { + "credentials": "Inicio de sesi\u00f3n" + }, + "description": "Seleccione las credenciales guardadas." }, "select_student": { "data": { "student_name": "Selecciona al alumno" - } + }, + "description": "Seleccione el estudiante, puede a\u00f1adir m\u00e1s estudiantes a\u00f1adiendo de nuevo la integraci\u00f3n." } } } diff --git a/homeassistant/components/webostv/translations/es.json b/homeassistant/components/webostv/translations/es.json index 712de905ddc..db23caa048b 100644 --- a/homeassistant/components/webostv/translations/es.json +++ b/homeassistant/components/webostv/translations/es.json @@ -16,6 +16,7 @@ }, "user": { "data": { + "host": "Anfitri\u00f3n", "name": "Nombre" }, "description": "Encienda la televisi\u00f3n, rellene los siguientes campos y haga clic en enviar", diff --git a/homeassistant/components/whois/translations/es.json b/homeassistant/components/whois/translations/es.json index 0da233e02ad..e2803c251d6 100644 --- a/homeassistant/components/whois/translations/es.json +++ b/homeassistant/components/whois/translations/es.json @@ -4,7 +4,9 @@ "already_configured": "El servicio ya est\u00e1 configurado" }, "error": { + "unexpected_response": "Respuesta inesperada del servidor whois", "unknown_date_format": "Formato de fecha desconocido en la respuesta del servidor whois", + "unknown_tld": "El TLD dado es desconocido o no est\u00e1 disponible para esta integraci\u00f3n", "whois_command_failed": "El comando whois ha fallado: no se pudo obtener la informaci\u00f3n whois" }, "step": { diff --git a/homeassistant/components/yamaha_musiccast/translations/select.pt.json b/homeassistant/components/yamaha_musiccast/translations/select.pt.json new file mode 100644 index 00000000000..059993c8829 --- /dev/null +++ b/homeassistant/components/yamaha_musiccast/translations/select.pt.json @@ -0,0 +1,22 @@ +{ + "state": { + "yamaha_musiccast__dimmer": { + "auto": "Autom\u00e1tico" + }, + "yamaha_musiccast__zone_equalizer_mode": { + "auto": "Autom\u00e1tico", + "bypass": "Desviar", + "manual": "Manual" + }, + "yamaha_musiccast__zone_link_audio_delay": { + "audio_sync_off": "Sincroniza\u00e7\u00e3o de \u00e1udio desligada", + "audio_sync_on": "Sincroniza\u00e7\u00e3o de \u00e1udio ativada", + "balanced": "Equilibrado", + "lip_sync": "Sincroniza\u00e7\u00e3o labial" + }, + "yamaha_musiccast__zone_link_audio_quality": { + "compressed": "Comprimido", + "uncompressed": "Incomprimido" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yolink/translations/es.json b/homeassistant/components/yolink/translations/es.json new file mode 100644 index 00000000000..71df6ac31e3 --- /dev/null +++ b/homeassistant/components/yolink/translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "already_in_progress": "El flujo de configuraci\u00f3n ya est\u00e1 en progreso", + "authorize_url_timeout": "Tiempo de espera para generar la URL de autorizaci\u00f3n.", + "missing_configuration": "El componente no est\u00e1 configurado. Por favor, siga la documentaci\u00f3n.", + "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [compruebe la secci\u00f3n de ayuda]({docs_url})", + "oauth_error": "Se han recibido datos no v\u00e1lidos del token.", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + }, + "create_entry": { + "default": "Autentificado con \u00e9xito" + }, + "step": { + "pick_implementation": { + "title": "Elija el m\u00e9todo de autenticaci\u00f3n" + }, + "reauth_confirm": { + "description": "La integraci\u00f3n de yolink necesita volver a autenticar su cuenta", + "title": "Integraci\u00f3n de la reautenticaci\u00f3n" + } + } + } +} \ No newline at end of file From c6e56c26b33b282470e07913349a0ab91f38a6d6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Jun 2022 19:12:00 -1000 Subject: [PATCH 174/947] Fix logbook not setting up with an recorder filter that has empty fields (#72869) --- homeassistant/components/recorder/filters.py | 2 +- tests/components/logbook/test_init.py | 38 +++++++++++++++++++- tests/components/recorder/test_filters.py | 20 +++++++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 0ceb013d8c5..3077f7f57f3 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -36,7 +36,7 @@ def extract_include_exclude_filter_conf(conf: ConfigType) -> dict[str, Any]: """ return { filter_type: { - matcher: set(conf.get(filter_type, {}).get(matcher, [])) + matcher: set(conf.get(filter_type, {}).get(matcher) or []) for matcher in FITLER_MATCHERS } for filter_type in FILTER_TYPES diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 2903f29f5dc..d33bbd5b8ac 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -11,7 +11,7 @@ from unittest.mock import Mock, patch import pytest import voluptuous as vol -from homeassistant.components import logbook +from homeassistant.components import logbook, recorder from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED from homeassistant.components.logbook.models import LazyEventPartialState @@ -2796,3 +2796,39 @@ async def test_get_events_with_context_state(hass, hass_ws_client, recorder_mock assert results[3]["context_state"] == "off" assert results[3]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474" assert "context_event_type" not in results[3] + + +async def test_logbook_with_empty_config(hass, recorder_mock): + """Test we handle a empty configuration.""" + assert await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: {}, + recorder.DOMAIN: {}, + }, + ) + await hass.async_block_till_done() + + +async def test_logbook_with_non_iterable_entity_filter(hass, recorder_mock): + """Test we handle a non-iterable entity filter.""" + assert await async_setup_component( + hass, + logbook.DOMAIN, + { + logbook.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: ["light.additional_excluded"], + } + }, + recorder.DOMAIN: { + CONF_EXCLUDE: { + CONF_ENTITIES: None, + CONF_DOMAINS: None, + CONF_ENTITY_GLOBS: None, + } + }, + }, + ) + await hass.async_block_till_done() diff --git a/tests/components/recorder/test_filters.py b/tests/components/recorder/test_filters.py index fa80df6e345..5c0afa10f9d 100644 --- a/tests/components/recorder/test_filters.py +++ b/tests/components/recorder/test_filters.py @@ -12,6 +12,13 @@ from homeassistant.helpers.entityfilter import ( CONF_INCLUDE, ) +EMPTY_INCLUDE_FILTER = { + CONF_INCLUDE: { + CONF_DOMAINS: None, + CONF_ENTITIES: None, + CONF_ENTITY_GLOBS: None, + } +} SIMPLE_INCLUDE_FILTER = { CONF_INCLUDE: { CONF_DOMAINS: ["homeassistant"], @@ -87,6 +94,19 @@ def test_extract_include_exclude_filter_conf(): assert SIMPLE_INCLUDE_EXCLUDE_FILTER[CONF_EXCLUDE][CONF_ENTITIES] != { "cover.altered" } + empty_include_filter = extract_include_exclude_filter_conf(EMPTY_INCLUDE_FILTER) + assert empty_include_filter == { + CONF_EXCLUDE: { + CONF_DOMAINS: set(), + CONF_ENTITIES: set(), + CONF_ENTITY_GLOBS: set(), + }, + CONF_INCLUDE: { + CONF_DOMAINS: set(), + CONF_ENTITIES: set(), + CONF_ENTITY_GLOBS: set(), + }, + } def test_merge_include_exclude_filters(): From d368b9e24f279d21bafc201c1a8a1b74a049a48e Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 2 Jun 2022 00:12:38 -0500 Subject: [PATCH 175/947] Remove announce workaround for Sonos (#72854) --- homeassistant/components/sonos/media_player.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index cd129d82843..f331f980bb4 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -25,7 +25,6 @@ from homeassistant.components.media_player import ( ) from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, - ATTR_MEDIA_ANNOUNCE, ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_ALBUM, MEDIA_TYPE_ARTIST, @@ -544,9 +543,6 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): """ # Use 'replace' as the default enqueue option enqueue = kwargs.get(ATTR_MEDIA_ENQUEUE, MediaPlayerEnqueue.REPLACE) - if kwargs.get(ATTR_MEDIA_ANNOUNCE): - # Temporary workaround until announce support is added - enqueue = MediaPlayerEnqueue.PLAY if spotify.is_spotify_media_type(media_type): media_type = spotify.resolve_spotify_media_type(media_type) From f79e5e002bc5c4a691682c5894f0e649ab943d33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 1 Jun 2022 19:13:09 -1000 Subject: [PATCH 176/947] Ensure recorder shuts down when its startup future is canceled out from under it (#72866) --- homeassistant/components/recorder/core.py | 14 +++++++++++--- tests/components/recorder/test_init.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7df4cf57e56..7a096a9c404 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Iterable +from concurrent.futures import CancelledError import contextlib from datetime import datetime, timedelta import logging @@ -518,9 +519,16 @@ class Recorder(threading.Thread): def _wait_startup_or_shutdown(self) -> object | None: """Wait for startup or shutdown before starting.""" - return asyncio.run_coroutine_threadsafe( - self._async_wait_for_started(), self.hass.loop - ).result() + try: + return asyncio.run_coroutine_threadsafe( + self._async_wait_for_started(), self.hass.loop + ).result() + except CancelledError as ex: + _LOGGER.warning( + "Recorder startup was externally canceled before it could complete: %s", + ex, + ) + return SHUTDOWN_TASK def run(self) -> None: """Start processing events to save.""" diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 41c4428ee5e..87dbce3ba3b 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -118,6 +118,26 @@ async def test_shutdown_before_startup_finishes( assert run_info.end is not None +async def test_canceled_before_startup_finishes( + hass: HomeAssistant, + async_setup_recorder_instance: SetupRecorderInstanceT, + caplog: pytest.LogCaptureFixture, +): + """Test recorder shuts down when its startup future is canceled out from under it.""" + hass.state = CoreState.not_running + await async_setup_recorder_instance(hass) + instance = get_instance(hass) + await instance.async_db_ready + instance._hass_started.cancel() + with patch.object(instance, "engine"): + await hass.async_block_till_done() + await hass.async_add_executor_job(instance.join) + assert ( + "Recorder startup was externally canceled before it could complete" + in caplog.text + ) + + async def test_shutdown_closes_connections(hass, recorder_mock): """Test shutdown closes connections.""" From 999b3a4f7b6ee9410dde5a72aa97b39e10f6e555 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Jun 2022 07:48:59 +0200 Subject: [PATCH 177/947] Adjust astroid import in pylint plugin (#72841) * import nodes from astroid * Update remaining pylint plugins Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- pylint/plugins/hass_constructor.py | 8 +++-- pylint/plugins/hass_enforce_type_hints.py | 44 +++++++++++------------ pylint/plugins/hass_imports.py | 10 +++--- pylint/plugins/hass_logger.py | 12 ++++--- 4 files changed, 39 insertions(+), 35 deletions(-) diff --git a/pylint/plugins/hass_constructor.py b/pylint/plugins/hass_constructor.py index f0f23ef4c95..525dcfed2e2 100644 --- a/pylint/plugins/hass_constructor.py +++ b/pylint/plugins/hass_constructor.py @@ -1,5 +1,7 @@ """Plugin for constructor definitions.""" -from astroid import Const, FunctionDef +from __future__ import annotations + +from astroid import nodes from pylint.checkers import BaseChecker from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter @@ -22,7 +24,7 @@ class HassConstructorFormatChecker(BaseChecker): # type: ignore[misc] } options = () - def visit_functiondef(self, node: FunctionDef) -> None: + def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Called when a FunctionDef node is visited.""" if not node.is_method() or node.name != "__init__": return @@ -43,7 +45,7 @@ class HassConstructorFormatChecker(BaseChecker): # type: ignore[misc] return # Check that return type is specified and it is "None". - if not isinstance(node.returns, Const) or node.returns.value is not None: + if not isinstance(node.returns, nodes.Const) or node.returns.value is not None: self.add_message("hass-constructor-return", node=node) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 31b396c3196..05a52faab17 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import re -import astroid +from astroid import nodes from pylint.checkers import BaseChecker from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter @@ -437,7 +437,7 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { def _is_valid_type( - expected_type: list[str] | str | None | object, node: astroid.NodeNG + expected_type: list[str] | str | None | object, node: nodes.NodeNG ) -> bool: """Check the argument node against the expected type.""" if expected_type is UNDEFINED: @@ -451,18 +451,18 @@ def _is_valid_type( # Const occurs when the type is None if expected_type is None or expected_type == "None": - return isinstance(node, astroid.Const) and node.value is None + return isinstance(node, nodes.Const) and node.value is None assert isinstance(expected_type, str) # Const occurs when the type is an Ellipsis if expected_type == "...": - return isinstance(node, astroid.Const) and node.value == Ellipsis + return isinstance(node, nodes.Const) and node.value == Ellipsis # Special case for `xxx | yyy` if match := _TYPE_HINT_MATCHERS["a_or_b"].match(expected_type): return ( - isinstance(node, astroid.BinOp) + isinstance(node, nodes.BinOp) and _is_valid_type(match.group(1), node.left) and _is_valid_type(match.group(2), node.right) ) @@ -470,11 +470,11 @@ def _is_valid_type( # Special case for xxx[yyy[zzz, aaa]]` if match := _TYPE_HINT_MATCHERS["x_of_y_of_z_comma_a"].match(expected_type): return ( - isinstance(node, astroid.Subscript) + isinstance(node, nodes.Subscript) and _is_valid_type(match.group(1), node.value) - and isinstance(subnode := node.slice, astroid.Subscript) + and isinstance(subnode := node.slice, nodes.Subscript) and _is_valid_type(match.group(2), subnode.value) - and isinstance(subnode.slice, astroid.Tuple) + and isinstance(subnode.slice, nodes.Tuple) and _is_valid_type(match.group(3), subnode.slice.elts[0]) and _is_valid_type(match.group(4), subnode.slice.elts[1]) ) @@ -482,9 +482,9 @@ def _is_valid_type( # Special case for xxx[yyy, zzz]` if match := _TYPE_HINT_MATCHERS["x_of_y_comma_z"].match(expected_type): return ( - isinstance(node, astroid.Subscript) + isinstance(node, nodes.Subscript) and _is_valid_type(match.group(1), node.value) - and isinstance(node.slice, astroid.Tuple) + and isinstance(node.slice, nodes.Tuple) and _is_valid_type(match.group(2), node.slice.elts[0]) and _is_valid_type(match.group(3), node.slice.elts[1]) ) @@ -492,22 +492,22 @@ def _is_valid_type( # Special case for xxx[yyy]` if match := _TYPE_HINT_MATCHERS["x_of_y"].match(expected_type): return ( - isinstance(node, astroid.Subscript) + isinstance(node, nodes.Subscript) and _is_valid_type(match.group(1), node.value) and _is_valid_type(match.group(2), node.slice) ) # Name occurs when a namespace is not used, eg. "HomeAssistant" - if isinstance(node, astroid.Name) and node.name == expected_type: + if isinstance(node, nodes.Name) and node.name == expected_type: return True # Attribute occurs when a namespace is used, eg. "core.HomeAssistant" - return isinstance(node, astroid.Attribute) and node.attrname == expected_type + return isinstance(node, nodes.Attribute) and node.attrname == expected_type -def _get_all_annotations(node: astroid.FunctionDef) -> list[astroid.NodeNG | None]: +def _get_all_annotations(node: nodes.FunctionDef) -> list[nodes.NodeNG | None]: args = node.args - annotations: list[astroid.NodeNG | None] = ( + annotations: list[nodes.NodeNG | None] = ( args.posonlyargs_annotations + args.annotations + args.kwonlyargs_annotations ) if args.vararg is not None: @@ -518,7 +518,7 @@ def _get_all_annotations(node: astroid.FunctionDef) -> list[astroid.NodeNG | Non def _has_valid_annotations( - annotations: list[astroid.NodeNG | None], + annotations: list[nodes.NodeNG | None], ) -> bool: for annotation in annotations: if annotation is not None: @@ -563,7 +563,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] self._function_matchers: list[TypeHintMatch] = [] self._class_matchers: list[ClassTypeHintMatch] = [] - def visit_module(self, node: astroid.Module) -> None: + def visit_module(self, node: nodes.Module) -> None: """Called when a Module node is visited.""" self._function_matchers = [] self._class_matchers = [] @@ -580,16 +580,16 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] if class_matches := _CLASS_MATCH.get(module_platform): self._class_matchers = class_matches - def visit_classdef(self, node: astroid.ClassDef) -> None: + def visit_classdef(self, node: nodes.ClassDef) -> None: """Called when a ClassDef node is visited.""" - ancestor: astroid.ClassDef + ancestor: nodes.ClassDef for ancestor in node.ancestors(): for class_matches in self._class_matchers: if ancestor.name == class_matches.base_class: self._visit_class_functions(node, class_matches.matches) def _visit_class_functions( - self, node: astroid.ClassDef, matches: list[TypeHintMatch] + self, node: nodes.ClassDef, matches: list[TypeHintMatch] ) -> None: for match in matches: for function_node in node.mymethods(): @@ -597,7 +597,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] if match.function_name == function_name: self._check_function(function_node, match) - def visit_functiondef(self, node: astroid.FunctionDef) -> None: + def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Called when a FunctionDef node is visited.""" for match in self._function_matchers: if node.name != match.function_name or node.is_method(): @@ -606,7 +606,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] visit_asyncfunctiondef = visit_functiondef - def _check_function(self, node: astroid.FunctionDef, match: TypeHintMatch) -> None: + def _check_function(self, node: nodes.FunctionDef, match: TypeHintMatch) -> None: # Check that at least one argument is annotated. annotations = _get_all_annotations(node) if node.returns is None and not _has_valid_annotations(annotations): diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index c6f6c25c7b6..a8b3fa8fc76 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass import re -from astroid import Import, ImportFrom, Module +from astroid import nodes from pylint.checkers import BaseChecker from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter @@ -14,7 +14,7 @@ from pylint.lint import PyLinter class ObsoleteImportMatch: """Class for pattern matching.""" - constant: re.Pattern + constant: re.Pattern[str] reason: str @@ -255,7 +255,7 @@ class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] super().__init__(linter) self.current_package: str | None = None - def visit_module(self, node: Module) -> None: + def visit_module(self, node: nodes.Module) -> None: """Called when a Module node is visited.""" if node.package: self.current_package = node.name @@ -263,13 +263,13 @@ class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] # Strip name of the current module self.current_package = node.name[: node.name.rfind(".")] - def visit_import(self, node: Import) -> None: + def visit_import(self, node: nodes.Import) -> None: """Called when a Import node is visited.""" for module, _alias in node.names: if module.startswith(f"{self.current_package}."): self.add_message("hass-relative-import", node=node) - def visit_importfrom(self, node: ImportFrom) -> None: + def visit_importfrom(self, node: nodes.ImportFrom) -> None: """Called when a ImportFrom node is visited.""" if node.level is not None: return diff --git a/pylint/plugins/hass_logger.py b/pylint/plugins/hass_logger.py index 0ca57b8da19..125c927ec42 100644 --- a/pylint/plugins/hass_logger.py +++ b/pylint/plugins/hass_logger.py @@ -1,5 +1,7 @@ """Plugin for logger invocations.""" -import astroid +from __future__ import annotations + +from astroid import nodes from pylint.checkers import BaseChecker from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter @@ -29,10 +31,10 @@ class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] } options = () - def visit_call(self, node: astroid.Call) -> None: + def visit_call(self, node: nodes.Call) -> None: """Called when a Call node is visited.""" - if not isinstance(node.func, astroid.Attribute) or not isinstance( - node.func.expr, astroid.Name + if not isinstance(node.func, nodes.Attribute) or not isinstance( + node.func.expr, nodes.Name ): return @@ -44,7 +46,7 @@ class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] first_arg = node.args[0] - if not isinstance(first_arg, astroid.Const) or not first_arg.value: + if not isinstance(first_arg, nodes.Const) or not first_arg.value: return log_message = first_arg.value From c2fdac20146e58cebf02259b946f11e1d6ed3ab9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Jun 2022 09:06:22 +0200 Subject: [PATCH 178/947] Allow non-async functions in device automation (#72147) * Remove async requirement for get_capabilities_func * Add comment * Remove async requirement for get_automations_func * Update homeassistant/components/device_automation/__init__.py Co-authored-by: Erik Montnemery * Update homeassistant/components/device_automation/__init__.py Co-authored-by: Erik Montnemery * Add Exception to type hint Co-authored-by: Erik Montnemery --- .../components/device_automation/__init__.py | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 61fd93354fe..99629f3dd23 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -189,22 +189,42 @@ def _async_set_entity_device_automation_metadata( automation["metadata"]["secondary"] = bool(entry.entity_category or entry.hidden_by) +async def _async_get_automation_for_device( + hass: HomeAssistant, + platform: DeviceAutomationPlatformType, + function_name: str, + device_id: str, +) -> list[dict[str, Any]]: + """List device automations.""" + automations = getattr(platform, function_name)(hass, device_id) + if asyncio.iscoroutine(automations): + # Using a coroutine to get device automations is deprecated + # enable warning when core is fully migrated + # then remove in Home Assistant Core xxxx.xx + return await automations # type: ignore[no-any-return] + return automations # type: ignore[no-any-return] + + async def _async_get_device_automations_from_domain( - hass, domain, automation_type, device_ids, return_exceptions -): + hass: HomeAssistant, + domain: str, + automation_type: DeviceAutomationType, + device_ids: Iterable[str], + return_exceptions: bool, +) -> list[list[dict[str, Any]] | Exception]: """List device automations.""" try: platform = await async_get_device_automation_platform( hass, domain, automation_type ) except InvalidDeviceAutomationConfig: - return {} + return [] function_name = automation_type.value.get_automations_func - return await asyncio.gather( + return await asyncio.gather( # type: ignore[no-any-return] *( - getattr(platform, function_name)(hass, device_id) + _async_get_automation_for_device(hass, platform, function_name, device_id) for device_id in device_ids ), return_exceptions=return_exceptions, @@ -290,7 +310,12 @@ async def _async_get_device_automation_capabilities( return {} try: - capabilities = await getattr(platform, function_name)(hass, automation) + capabilities = getattr(platform, function_name)(hass, automation) + if asyncio.iscoroutine(capabilities): + # Using a coroutine to get device automation capabitilites is deprecated + # enable warning when core is fully migrated + # then remove in Home Assistant Core xxxx.xx + capabilities = await capabilities except InvalidDeviceAutomationConfig: return {} From 6ccaf33bdfebefa47680cc51c412ac9e7e44570d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 2 Jun 2022 03:16:00 -0400 Subject: [PATCH 179/947] Attempt to fix flaky tomorrowio test (#72890) * Fix flaky tomorrowio test * reset mock outside context manager * add to hass outside of context manager --- tests/components/tomorrowio/test_init.py | 56 +++++++++++++++--------- 1 file changed, 35 insertions(+), 21 deletions(-) diff --git a/tests/components/tomorrowio/test_init.py b/tests/components/tomorrowio/test_init.py index 27372094092..2c0a882ff5a 100644 --- a/tests/components/tomorrowio/test_init.py +++ b/tests/components/tomorrowio/test_init.py @@ -66,26 +66,31 @@ async def test_update_intervals( version=1, ) config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 1 + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=now): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + tomorrowio_config_entry_update.reset_mock() # Before the update interval, no updates yet - async_fire_time_changed(hass, now + timedelta(minutes=30)) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 0 + future = now + timedelta(minutes=30) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 - # On the update interval, we get a new update - async_fire_time_changed(hass, now + timedelta(minutes=32)) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 1 tomorrowio_config_entry_update.reset_mock() - with patch( - "homeassistant.helpers.update_coordinator.utcnow", - return_value=now + timedelta(minutes=32), - ): + # On the update interval, we get a new update + future = now + timedelta(minutes=32) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, now + timedelta(minutes=32)) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 1 + + tomorrowio_config_entry_update.reset_mock() + # Adding a second config entry should cause the update interval to double config_entry_2 = MockConfigEntry( domain=DOMAIN, @@ -101,17 +106,26 @@ async def test_update_intervals( # We should get an immediate call once the new config entry is setup for a # partial update assert len(tomorrowio_config_entry_update.call_args_list) == 1 - tomorrowio_config_entry_update.reset_mock() + + tomorrowio_config_entry_update.reset_mock() # We should get no new calls on our old interval - async_fire_time_changed(hass, now + timedelta(minutes=64)) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 0 + future = now + timedelta(minutes=64) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 0 + + tomorrowio_config_entry_update.reset_mock() # We should get two calls on our new interval, one for each entry - async_fire_time_changed(hass, now + timedelta(minutes=96)) - await hass.async_block_till_done() - assert len(tomorrowio_config_entry_update.call_args_list) == 2 + future = now + timedelta(minutes=96) + with patch("homeassistant.helpers.update_coordinator.utcnow", return_value=future): + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert len(tomorrowio_config_entry_update.call_args_list) == 2 + + tomorrowio_config_entry_update.reset_mock() async def test_climacell_migration_logic( From 62a5854e40cb554fecb1eec897d7bcb4c94628fe Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 2 Jun 2022 13:58:04 +0200 Subject: [PATCH 180/947] Fix bare except (#72906) --- homeassistant/components/feedreader/__init__.py | 4 ++-- homeassistant/components/qnap/sensor.py | 2 +- tests/components/emulated_hue/test_upnp.py | 2 +- tests/components/system_log/test_init.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index a4cd546aa16..11a3d4b0498 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -199,7 +199,7 @@ class StoredData: with self._lock, open(self._data_file, "rb") as myfile: self._data = pickle.load(myfile) or {} self._cache_outdated = False - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.error( "Error loading data from pickled file %s", self._data_file ) @@ -221,6 +221,6 @@ class StoredData: ) try: pickle.dump(self._data, myfile) - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.error("Error saving pickled data to %s", self._data_file) self._cache_outdated = True diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 1e2bf5b6892..7366dc5dc41 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -306,7 +306,7 @@ class QNAPStatsAPI: self.data["smart_drive_health"] = self._api.get_smart_disk_health() self.data["volumes"] = self._api.get_volumes() self.data["bandwidth"] = self._api.get_bandwidth() - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.exception("Failed to fetch QNAP stats from the NAS") diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index f392cfaf90d..ce7f013963c 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -162,7 +162,7 @@ async def test_description_xml(hass, hue_client): root = ET.fromstring(await result.text()) ns = {"s": "urn:schemas-upnp-org:device-1-0"} assert root.find("./s:device/s:serialNumber", ns).text == "001788FFFE23BFC2" - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except pytest.fail("description.xml is not valid XML!") diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 8b9284a4b32..121c29d2eed 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -53,7 +53,7 @@ async def get_error_log(hass_ws_client): def _generate_and_log_exception(exception, log): try: raise Exception(exception) - except: # noqa: E722 pylint: disable=bare-except + except Exception: # pylint: disable=broad-except _LOGGER.exception(log) From 219200b3403e7750fafe838541ffb46548e9152a Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 2 Jun 2022 13:59:29 +0200 Subject: [PATCH 181/947] Fix test_hass_stop in devolo Home Network (#72833) Fix test_hass_stop --- tests/components/devolo_home_network/test_init.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 66d32e8974d..4f0c5b3fb58 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -58,4 +58,5 @@ async def test_hass_stop(hass: HomeAssistant): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) - assert async_disconnect.assert_called_once + await hass.async_block_till_done() + async_disconnect.assert_called_once() From cd590c79e2a67f953a3631d783aa466915fc9ad2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 02:01:06 -1000 Subject: [PATCH 182/947] Fix migration of MySQL data when InnoDB is not being used (#72893) Fixes #72883 --- homeassistant/components/recorder/migration.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index bc636d34b10..cc5af684566 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -715,14 +715,13 @@ def _apply_update( # noqa: C901 if engine.dialect.name == SupportedDialect.MYSQL: # Ensure the row format is dynamic or the index # unique will be too large - with session_scope(session=session_maker()) as session: - connection = session.connection() - # This is safe to run multiple times and fast since the table is small - connection.execute( - text( - "ALTER TABLE statistics_meta ENGINE=InnoDB, ROW_FORMAT=DYNAMIC" + with contextlib.suppress(SQLAlchemyError): + with session_scope(session=session_maker()) as session: + connection = session.connection() + # This is safe to run multiple times and fast since the table is small + connection.execute( + text("ALTER TABLE statistics_meta ROW_FORMAT=DYNAMIC") ) - ) try: _create_index( session_maker, "statistics_meta", "ix_statistics_meta_statistic_id" From 14f47c7450fbdfb1beec6059a495b9a1f970b5a0 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Thu, 2 Jun 2022 08:06:59 -0400 Subject: [PATCH 183/947] Bump aiopyarr to 2022.6.0 (#72870) --- homeassistant/components/sonarr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json index 6a9b00d2041..6b34b077888 100644 --- a/homeassistant/components/sonarr/manifest.json +++ b/homeassistant/components/sonarr/manifest.json @@ -3,7 +3,7 @@ "name": "Sonarr", "documentation": "https://www.home-assistant.io/integrations/sonarr", "codeowners": ["@ctalkington"], - "requirements": ["aiopyarr==22.2.2"], + "requirements": ["aiopyarr==22.6.0"], "config_flow": true, "quality_scale": "silver", "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index e50da9b1758..250551caa47 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -220,7 +220,7 @@ aiopvapi==1.6.19 aiopvpc==3.0.0 # homeassistant.components.sonarr -aiopyarr==22.2.2 +aiopyarr==22.6.0 # homeassistant.components.qnap_qsw aioqsw==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d3fefd79973..5e9aa909f3d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -189,7 +189,7 @@ aiopvapi==1.6.19 aiopvpc==3.0.0 # homeassistant.components.sonarr -aiopyarr==22.2.2 +aiopyarr==22.6.0 # homeassistant.components.qnap_qsw aioqsw==0.1.0 From 756988fe200d625bfda8ccb628220323884faa6c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Jun 2022 14:17:09 +0200 Subject: [PATCH 184/947] Use Mapping for async_step_reauth (f-o) (#72764) --- homeassistant/components/fritz/config_flow.py | 3 ++- homeassistant/components/fritzbox/config_flow.py | 3 ++- homeassistant/components/hyperion/config_flow.py | 3 ++- homeassistant/components/isy994/config_flow.py | 3 ++- homeassistant/components/laundrify/config_flow.py | 5 ++--- homeassistant/components/meater/config_flow.py | 5 ++++- homeassistant/components/nam/config_flow.py | 3 ++- homeassistant/components/nanoleaf/config_flow.py | 3 ++- homeassistant/components/notion/config_flow.py | 3 ++- homeassistant/components/overkiz/config_flow.py | 7 +++---- 10 files changed, 23 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index 3e6961f585d..afb1708cac1 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the FRITZ!Box Tools integration.""" from __future__ import annotations +from collections.abc import Mapping import ipaddress import logging import socket @@ -230,7 +231,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_create_entry() - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle flow upon an API authentication error.""" self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) self._host = data[CONF_HOST] diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index bf290cb28f7..d183c4a8d5e 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -1,6 +1,7 @@ """Config flow for AVM FRITZ!SmartHome.""" from __future__ import annotations +from collections.abc import Mapping import ipaddress from typing import Any from urllib.parse import urlparse @@ -175,7 +176,7 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry is not None diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 4d6253cb161..8b8f8b62c47 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from contextlib import suppress import logging from typing import Any @@ -142,7 +143,7 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): async def async_step_reauth( self, - config_data: dict[str, Any], + config_data: Mapping[str, Any], ) -> FlowResult: """Handle a reauthentication flow.""" self._data = dict(config_data) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index 34d4738db68..ff14c9bfc33 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Universal Devices ISY994 integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any from urllib.parse import urlparse, urlunparse @@ -254,7 +255,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() async def async_step_reauth( - self, user_input: dict[str, Any] | None = None + self, data: Mapping[str, Any] ) -> data_entry_flow.FlowResult: """Handle reauth.""" self._existing_entry = await self.async_set_unique_id(self.context["unique_id"]) diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index d8230863d7c..c091324d9a7 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -1,6 +1,7 @@ """Config flow for laundrify integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -76,9 +77,7 @@ class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): step_id="init", data_schema=CONFIG_SCHEMA, errors=errors ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index 07dbd4bd4a5..8d5e459fbce 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Meater.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from meater import AuthenticationError, MeaterApi, ServiceUnavailableError import voluptuous as vol @@ -42,7 +45,7 @@ class MeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._try_connect_meater("user", None, username, password) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._data_schema = REAUTH_SCHEMA self._username = data[CONF_USERNAME] diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 1727ddff162..df41eb7c5f1 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging from typing import Any @@ -164,7 +165,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" if entry := self.hass.config_entries.async_get_entry(self.context["entry_id"]): self.entry = entry diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index ed63754697a..eb8bbb1ec66 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Nanoleaf integration.""" from __future__ import annotations +from collections.abc import Mapping import logging import os from typing import Any, Final, cast @@ -77,7 +78,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_link() - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle Nanoleaf reauth flow if token is invalid.""" self.reauth_entry = cast( config_entries.ConfigEntry, diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index c9e59107c1b..56093067711 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Notion integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from aionotion import async_get_client @@ -73,7 +74,7 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._username, data=data) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._username = config[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index 479e212e317..c70a551f4c7 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Overkiz (by Somfy) integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any, cast from aiohttp import ClientError @@ -154,9 +155,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._config_entry = cast( ConfigEntry, @@ -170,4 +169,4 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._default_user = self._config_entry.data[CONF_USERNAME] self._default_hub = self._config_entry.data[CONF_HUB] - return await self.async_step_user(user_input) + return await self.async_step_user(dict(data)) From 52561ce0769ddcf1e8688c8909692b66495e524b Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 2 Jun 2022 14:24:46 +0200 Subject: [PATCH 185/947] Update MQTT tests to use the config entry setup (#72373) * New testframework and tests for fan platform * Merge test_common_new to test_common * Add alarm_control_panel * Add binary_sensor * Add button * Add camera * Add climate * Add config_flow * Add cover * Add device_tracker_disovery * Add device_trigger * Add diagnostics * Add discovery * Add humidifier * Add init * Add lecacy_vacuum * Add light_json * Add light_template * Add light * Add lock * Add number * Add scene * Add select * Add sensor * Add siren * Add state_vacuum * Add subscription * Add switch * Add tag * Add trigger * Add missed tests * Add another missed test * Add device_tracker * Remove commented out code * Correct tests according comments * Improve mqtt_mock_entry and recover tests * Split fixtures with and without yaml setup * Update fixtures manual_mqtt * Update fixtures mqtt_json * Fix test tasmota * Update fixture mqtt_room * Revert fixture changes, improve test * re-add test --- .../manual_mqtt/test_alarm_control_panel.py | 117 ++++-- .../mqtt/test_alarm_control_panel.py | 248 +++++++---- tests/components/mqtt/test_binary_sensor.py | 231 +++++++---- tests/components/mqtt/test_button.py | 146 ++++--- tests/components/mqtt/test_camera.py | 133 +++--- tests/components/mqtt/test_climate.py | 296 +++++++++----- tests/components/mqtt/test_common.py | 145 +++++-- tests/components/mqtt/test_config_flow.py | 11 +- tests/components/mqtt/test_cover.py | 385 ++++++++++++------ tests/components/mqtt/test_device_tracker.py | 34 +- .../mqtt/test_device_tracker_discovery.py | 58 ++- tests/components/mqtt/test_device_trigger.py | 102 +++-- tests/components/mqtt/test_diagnostics.py | 10 +- tests/components/mqtt/test_discovery.py | 111 +++-- tests/components/mqtt/test_fan.py | 214 +++++++--- tests/components/mqtt/test_humidifier.py | 202 ++++++--- tests/components/mqtt/test_init.py | 280 +++++++++---- tests/components/mqtt/test_legacy_vacuum.py | 204 ++++++---- tests/components/mqtt/test_light.py | 290 +++++++++---- tests/components/mqtt/test_light_json.py | 247 +++++++---- tests/components/mqtt/test_light_template.py | 260 +++++++----- tests/components/mqtt/test_lock.py | 169 +++++--- tests/components/mqtt/test_number.py | 171 +++++--- tests/components/mqtt/test_scene.py | 70 +++- tests/components/mqtt/test_select.py | 170 +++++--- tests/components/mqtt/test_sensor.py | 280 ++++++++----- tests/components/mqtt/test_siren.py | 181 +++++--- tests/components/mqtt/test_state_vacuum.py | 153 ++++--- tests/components/mqtt/test_subscription.py | 19 +- tests/components/mqtt/test_switch.py | 174 +++++--- tests/components/mqtt/test_tag.py | 72 +++- tests/components/mqtt/test_trigger.py | 11 +- .../mqtt_json/test_device_tracker.py | 2 +- tests/components/mqtt_room/test_sensor.py | 2 +- tests/components/tasmota/test_discovery.py | 6 +- tests/conftest.py | 100 ++++- 36 files changed, 3612 insertions(+), 1692 deletions(-) diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py index 3572077bd8e..4296f76d741 100644 --- a/tests/components/manual_mqtt/test_alarm_control_panel.py +++ b/tests/components/manual_mqtt/test_alarm_control_panel.py @@ -26,9 +26,9 @@ from tests.components.alarm_control_panel import common CODE = "HELLO_CODE" -async def test_fail_setup_without_state_topic(hass, mqtt_mock): +async def test_fail_setup_without_state_topic(hass, mqtt_mock_entry_with_yaml_config): """Test for failing with no state topic.""" - with assert_setup_component(0) as config: + with assert_setup_component(0, alarm_control_panel.DOMAIN) as config: assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -42,9 +42,9 @@ async def test_fail_setup_without_state_topic(hass, mqtt_mock): assert not config[alarm_control_panel.DOMAIN] -async def test_fail_setup_without_command_topic(hass, mqtt_mock): +async def test_fail_setup_without_command_topic(hass, mqtt_mock_entry_with_yaml_config): """Test failing with no command topic.""" - with assert_setup_component(0): + with assert_setup_component(0, alarm_control_panel.DOMAIN): assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -57,7 +57,7 @@ async def test_fail_setup_without_command_topic(hass, mqtt_mock): ) -async def test_arm_home_no_pending(hass, mqtt_mock): +async def test_arm_home_no_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -86,7 +86,9 @@ async def test_arm_home_no_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME -async def test_arm_home_no_pending_when_code_not_req(hass, mqtt_mock): +async def test_arm_home_no_pending_when_code_not_req( + hass, mqtt_mock_entry_with_yaml_config +): """Test arm home method.""" assert await async_setup_component( hass, @@ -116,7 +118,7 @@ async def test_arm_home_no_pending_when_code_not_req(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME -async def test_arm_home_with_pending(hass, mqtt_mock): +async def test_arm_home_with_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -158,7 +160,7 @@ async def test_arm_home_with_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME -async def test_arm_home_with_invalid_code(hass, mqtt_mock): +async def test_arm_home_with_invalid_code(hass, mqtt_mock_entry_with_yaml_config): """Attempt to arm home without a valid code.""" assert await async_setup_component( hass, @@ -187,7 +189,7 @@ async def test_arm_home_with_invalid_code(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_arm_away_no_pending(hass, mqtt_mock): +async def test_arm_away_no_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -216,7 +218,9 @@ async def test_arm_away_no_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY -async def test_arm_away_no_pending_when_code_not_req(hass, mqtt_mock): +async def test_arm_away_no_pending_when_code_not_req( + hass, mqtt_mock_entry_with_yaml_config +): """Test arm home method.""" assert await async_setup_component( hass, @@ -246,7 +250,7 @@ async def test_arm_away_no_pending_when_code_not_req(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY -async def test_arm_home_with_template_code(hass, mqtt_mock): +async def test_arm_home_with_template_code(hass, mqtt_mock_entry_with_yaml_config): """Attempt to arm with a template-based code.""" assert await async_setup_component( hass, @@ -276,7 +280,7 @@ async def test_arm_home_with_template_code(hass, mqtt_mock): assert state.state == STATE_ALARM_ARMED_HOME -async def test_arm_away_with_pending(hass, mqtt_mock): +async def test_arm_away_with_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -318,7 +322,7 @@ async def test_arm_away_with_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY -async def test_arm_away_with_invalid_code(hass, mqtt_mock): +async def test_arm_away_with_invalid_code(hass, mqtt_mock_entry_with_yaml_config): """Attempt to arm away without a valid code.""" assert await async_setup_component( hass, @@ -347,7 +351,7 @@ async def test_arm_away_with_invalid_code(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_arm_night_no_pending(hass, mqtt_mock): +async def test_arm_night_no_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm night method.""" assert await async_setup_component( hass, @@ -376,7 +380,9 @@ async def test_arm_night_no_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT -async def test_arm_night_no_pending_when_code_not_req(hass, mqtt_mock): +async def test_arm_night_no_pending_when_code_not_req( + hass, mqtt_mock_entry_with_yaml_config +): """Test arm night method.""" assert await async_setup_component( hass, @@ -406,7 +412,7 @@ async def test_arm_night_no_pending_when_code_not_req(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT -async def test_arm_night_with_pending(hass, mqtt_mock): +async def test_arm_night_with_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm night method.""" assert await async_setup_component( hass, @@ -454,7 +460,7 @@ async def test_arm_night_with_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT -async def test_arm_night_with_invalid_code(hass, mqtt_mock): +async def test_arm_night_with_invalid_code(hass, mqtt_mock_entry_with_yaml_config): """Attempt to arm night without a valid code.""" assert await async_setup_component( hass, @@ -483,7 +489,7 @@ async def test_arm_night_with_invalid_code(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_trigger_no_pending(hass, mqtt_mock): +async def test_trigger_no_pending(hass, mqtt_mock_entry_with_yaml_config): """Test triggering when no pending submitted method.""" assert await async_setup_component( hass, @@ -521,7 +527,7 @@ async def test_trigger_no_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED -async def test_trigger_with_delay(hass, mqtt_mock): +async def test_trigger_with_delay(hass, mqtt_mock_entry_with_yaml_config): """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( hass, @@ -569,7 +575,7 @@ async def test_trigger_with_delay(hass, mqtt_mock): assert state.state == STATE_ALARM_TRIGGERED -async def test_trigger_zero_trigger_time(hass, mqtt_mock): +async def test_trigger_zero_trigger_time(hass, mqtt_mock_entry_with_yaml_config): """Test disabled trigger.""" assert await async_setup_component( hass, @@ -598,7 +604,9 @@ async def test_trigger_zero_trigger_time(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_trigger_zero_trigger_time_with_pending(hass, mqtt_mock): +async def test_trigger_zero_trigger_time_with_pending( + hass, mqtt_mock_entry_with_yaml_config +): """Test disabled trigger.""" assert await async_setup_component( hass, @@ -627,7 +635,7 @@ async def test_trigger_zero_trigger_time_with_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_trigger_with_pending(hass, mqtt_mock): +async def test_trigger_with_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -679,7 +687,9 @@ async def test_trigger_with_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_trigger_with_disarm_after_trigger(hass, mqtt_mock): +async def test_trigger_with_disarm_after_trigger( + hass, mqtt_mock_entry_with_yaml_config +): """Test disarm after trigger.""" assert await async_setup_component( hass, @@ -718,7 +728,9 @@ async def test_trigger_with_disarm_after_trigger(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_trigger_with_zero_specific_trigger_time(hass, mqtt_mock): +async def test_trigger_with_zero_specific_trigger_time( + hass, mqtt_mock_entry_with_yaml_config +): """Test trigger method.""" assert await async_setup_component( hass, @@ -748,7 +760,9 @@ async def test_trigger_with_zero_specific_trigger_time(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_trigger_with_unused_zero_specific_trigger_time(hass, mqtt_mock): +async def test_trigger_with_unused_zero_specific_trigger_time( + hass, mqtt_mock_entry_with_yaml_config +): """Test disarm after trigger.""" assert await async_setup_component( hass, @@ -788,7 +802,9 @@ async def test_trigger_with_unused_zero_specific_trigger_time(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_trigger_with_specific_trigger_time(hass, mqtt_mock): +async def test_trigger_with_specific_trigger_time( + hass, mqtt_mock_entry_with_yaml_config +): """Test disarm after trigger.""" assert await async_setup_component( hass, @@ -827,7 +843,9 @@ async def test_trigger_with_specific_trigger_time(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_back_to_back_trigger_with_no_disarm_after_trigger(hass, mqtt_mock): +async def test_back_to_back_trigger_with_no_disarm_after_trigger( + hass, mqtt_mock_entry_with_yaml_config +): """Test no disarm after back to back trigger.""" assert await async_setup_component( hass, @@ -886,7 +904,7 @@ async def test_back_to_back_trigger_with_no_disarm_after_trigger(hass, mqtt_mock assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY -async def test_disarm_while_pending_trigger(hass, mqtt_mock): +async def test_disarm_while_pending_trigger(hass, mqtt_mock_entry_with_yaml_config): """Test disarming while pending state.""" assert await async_setup_component( hass, @@ -929,7 +947,9 @@ async def test_disarm_while_pending_trigger(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_disarm_during_trigger_with_invalid_code(hass, mqtt_mock): +async def test_disarm_during_trigger_with_invalid_code( + hass, mqtt_mock_entry_with_yaml_config +): """Test disarming while code is invalid.""" assert await async_setup_component( hass, @@ -973,7 +993,9 @@ async def test_disarm_during_trigger_with_invalid_code(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_TRIGGERED -async def test_trigger_with_unused_specific_delay(hass, mqtt_mock): +async def test_trigger_with_unused_specific_delay( + hass, mqtt_mock_entry_with_yaml_config +): """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( hass, @@ -1022,7 +1044,7 @@ async def test_trigger_with_unused_specific_delay(hass, mqtt_mock): assert state.state == STATE_ALARM_TRIGGERED -async def test_trigger_with_specific_delay(hass, mqtt_mock): +async def test_trigger_with_specific_delay(hass, mqtt_mock_entry_with_yaml_config): """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( hass, @@ -1071,7 +1093,7 @@ async def test_trigger_with_specific_delay(hass, mqtt_mock): assert state.state == STATE_ALARM_TRIGGERED -async def test_trigger_with_pending_and_delay(hass, mqtt_mock): +async def test_trigger_with_pending_and_delay(hass, mqtt_mock_entry_with_yaml_config): """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( hass, @@ -1132,7 +1154,9 @@ async def test_trigger_with_pending_and_delay(hass, mqtt_mock): assert state.state == STATE_ALARM_TRIGGERED -async def test_trigger_with_pending_and_specific_delay(hass, mqtt_mock): +async def test_trigger_with_pending_and_specific_delay( + hass, mqtt_mock_entry_with_yaml_config +): """Test trigger method and switch from pending to triggered.""" assert await async_setup_component( hass, @@ -1194,7 +1218,7 @@ async def test_trigger_with_pending_and_specific_delay(hass, mqtt_mock): assert state.state == STATE_ALARM_TRIGGERED -async def test_armed_home_with_specific_pending(hass, mqtt_mock): +async def test_armed_home_with_specific_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -1230,7 +1254,7 @@ async def test_armed_home_with_specific_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME -async def test_armed_away_with_specific_pending(hass, mqtt_mock): +async def test_armed_away_with_specific_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -1266,7 +1290,9 @@ async def test_armed_away_with_specific_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY -async def test_armed_night_with_specific_pending(hass, mqtt_mock): +async def test_armed_night_with_specific_pending( + hass, mqtt_mock_entry_with_yaml_config +): """Test arm home method.""" assert await async_setup_component( hass, @@ -1302,7 +1328,7 @@ async def test_armed_night_with_specific_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT -async def test_trigger_with_specific_pending(hass, mqtt_mock): +async def test_trigger_with_specific_pending(hass, mqtt_mock_entry_with_yaml_config): """Test arm home method.""" assert await async_setup_component( hass, @@ -1350,7 +1376,7 @@ async def test_trigger_with_specific_pending(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_arm_away_after_disabled_disarmed(hass, mqtt_mock): +async def test_arm_away_after_disabled_disarmed(hass, mqtt_mock_entry_with_yaml_config): """Test pending state with and without zero trigger time.""" assert await async_setup_component( hass, @@ -1417,7 +1443,7 @@ async def test_arm_away_after_disabled_disarmed(hass, mqtt_mock): assert state.state == STATE_ALARM_TRIGGERED -async def test_disarm_with_template_code(hass, mqtt_mock): +async def test_disarm_with_template_code(hass, mqtt_mock_entry_with_yaml_config): """Attempt to disarm with a valid or invalid template-based code.""" assert await async_setup_component( hass, @@ -1459,7 +1485,7 @@ async def test_disarm_with_template_code(hass, mqtt_mock): assert state.state == STATE_ALARM_DISARMED -async def test_arm_home_via_command_topic(hass, mqtt_mock): +async def test_arm_home_via_command_topic(hass, mqtt_mock_entry_with_yaml_config): """Test arming home via command topic.""" assert await async_setup_component( hass, @@ -1498,7 +1524,7 @@ async def test_arm_home_via_command_topic(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_HOME -async def test_arm_away_via_command_topic(hass, mqtt_mock): +async def test_arm_away_via_command_topic(hass, mqtt_mock_entry_with_yaml_config): """Test arming away via command topic.""" assert await async_setup_component( hass, @@ -1537,7 +1563,7 @@ async def test_arm_away_via_command_topic(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY -async def test_arm_night_via_command_topic(hass, mqtt_mock): +async def test_arm_night_via_command_topic(hass, mqtt_mock_entry_with_yaml_config): """Test arming night via command topic.""" assert await async_setup_component( hass, @@ -1576,7 +1602,7 @@ async def test_arm_night_via_command_topic(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_NIGHT -async def test_disarm_pending_via_command_topic(hass, mqtt_mock): +async def test_disarm_pending_via_command_topic(hass, mqtt_mock_entry_with_yaml_config): """Test disarming pending alarm via command topic.""" assert await async_setup_component( hass, @@ -1610,7 +1636,9 @@ async def test_disarm_pending_via_command_topic(hass, mqtt_mock): assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED -async def test_state_changes_are_published_to_mqtt(hass, mqtt_mock): +async def test_state_changes_are_published_to_mqtt( + hass, mqtt_mock_entry_with_yaml_config +): """Test publishing of MQTT messages when state changes.""" assert await async_setup_component( hass, @@ -1630,6 +1658,7 @@ async def test_state_changes_are_published_to_mqtt(hass, mqtt_mock): # Component should send disarmed alarm state on startup await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() mqtt_mock.async_publish.assert_called_once_with( "alarm/state", STATE_ALARM_DISARMED, 0, True ) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index f4d76d5474c..2b013ddf8dd 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -112,9 +112,9 @@ DEFAULT_CONFIG_REMOTE_CODE_TEXT = { } -async def test_fail_setup_without_state_topic(hass, mqtt_mock): +async def test_fail_setup_without_state_topic(hass, mqtt_mock_entry_no_yaml_config): """Test for failing with no state topic.""" - with assert_setup_component(0) as config: + with assert_setup_component(0, alarm_control_panel.DOMAIN) as config: assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -125,12 +125,14 @@ async def test_fail_setup_without_state_topic(hass, mqtt_mock): } }, ) + await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert not config[alarm_control_panel.DOMAIN] -async def test_fail_setup_without_command_topic(hass, mqtt_mock): +async def test_fail_setup_without_command_topic(hass, mqtt_mock_entry_no_yaml_config): """Test failing with no command topic.""" - with assert_setup_component(0): + with assert_setup_component(0, alarm_control_panel.DOMAIN) as config: assert await async_setup_component( hass, alarm_control_panel.DOMAIN, @@ -141,9 +143,12 @@ async def test_fail_setup_without_command_topic(hass, mqtt_mock): } }, ) + await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() + assert not config[alarm_control_panel.DOMAIN] -async def test_update_state_via_state_topic(hass, mqtt_mock): +async def test_update_state_via_state_topic(hass, mqtt_mock_entry_with_yaml_config): """Test updating with via state topic.""" assert await async_setup_component( hass, @@ -151,6 +156,7 @@ async def test_update_state_via_state_topic(hass, mqtt_mock): DEFAULT_CONFIG, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() entity_id = "alarm_control_panel.test" @@ -172,7 +178,9 @@ async def test_update_state_via_state_topic(hass, mqtt_mock): assert hass.states.get(entity_id).state == state -async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock): +async def test_ignore_update_state_if_unknown_via_state_topic( + hass, mqtt_mock_entry_with_yaml_config +): """Test ignoring updates via state topic.""" assert await async_setup_component( hass, @@ -180,6 +188,7 @@ async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock): DEFAULT_CONFIG, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() entity_id = "alarm_control_panel.test" @@ -201,7 +210,9 @@ async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock): (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) -async def test_publish_mqtt_no_code(hass, mqtt_mock, service, payload): +async def test_publish_mqtt_no_code( + hass, mqtt_mock_entry_with_yaml_config, service, payload +): """Test publishing of MQTT messages when no code is configured.""" assert await async_setup_component( hass, @@ -209,6 +220,7 @@ async def test_publish_mqtt_no_code(hass, mqtt_mock, service, payload): DEFAULT_CONFIG, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( alarm_control_panel.DOMAIN, @@ -232,7 +244,9 @@ async def test_publish_mqtt_no_code(hass, mqtt_mock, service, payload): (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) -async def test_publish_mqtt_with_code(hass, mqtt_mock, service, payload): +async def test_publish_mqtt_with_code( + hass, mqtt_mock_entry_with_yaml_config, service, payload +): """Test publishing of MQTT messages when code is configured.""" assert await async_setup_component( hass, @@ -240,6 +254,7 @@ async def test_publish_mqtt_with_code(hass, mqtt_mock, service, payload): DEFAULT_CONFIG_CODE, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -282,7 +297,9 @@ async def test_publish_mqtt_with_code(hass, mqtt_mock, service, payload): (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) -async def test_publish_mqtt_with_remote_code(hass, mqtt_mock, service, payload): +async def test_publish_mqtt_with_remote_code( + hass, mqtt_mock_entry_with_yaml_config, service, payload +): """Test publishing of MQTT messages when remode code is configured.""" assert await async_setup_component( hass, @@ -290,6 +307,7 @@ async def test_publish_mqtt_with_remote_code(hass, mqtt_mock, service, payload): DEFAULT_CONFIG_REMOTE_CODE, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -323,7 +341,9 @@ async def test_publish_mqtt_with_remote_code(hass, mqtt_mock, service, payload): (SERVICE_ALARM_TRIGGER, "TRIGGER"), ], ) -async def test_publish_mqtt_with_remote_code_text(hass, mqtt_mock, service, payload): +async def test_publish_mqtt_with_remote_code_text( + hass, mqtt_mock_entry_with_yaml_config, service, payload +): """Test publishing of MQTT messages when remote text code is configured.""" assert await async_setup_component( hass, @@ -331,6 +351,7 @@ async def test_publish_mqtt_with_remote_code_text(hass, mqtt_mock, service, payl DEFAULT_CONFIG_REMOTE_CODE_TEXT, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() call_count = mqtt_mock.async_publish.call_count # No code provided, should not publish @@ -365,7 +386,7 @@ async def test_publish_mqtt_with_remote_code_text(hass, mqtt_mock, service, payl ], ) async def test_publish_mqtt_with_code_required_false( - hass, mqtt_mock, service, payload, disable_code + hass, mqtt_mock_entry_with_yaml_config, service, payload, disable_code ): """Test publishing of MQTT messages when code is configured. @@ -380,6 +401,7 @@ async def test_publish_mqtt_with_code_required_false( config, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() # No code provided, should publish await hass.services.async_call( @@ -412,7 +434,9 @@ async def test_publish_mqtt_with_code_required_false( mqtt_mock.reset_mock() -async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): +async def test_disarm_publishes_mqtt_with_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test publishing of MQTT messages while disarmed. When command_template set to output json @@ -428,6 +452,7 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): config, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_alarm_disarm(hass, "0123") mqtt_mock.async_publish.assert_called_once_with( @@ -435,7 +460,9 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): ) -async def test_update_state_via_state_topic_template(hass, mqtt_mock): +async def test_update_state_via_state_topic_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test updating with template_value via state topic.""" assert await async_setup_component( hass, @@ -456,6 +483,7 @@ async def test_update_state_via_state_topic_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("alarm_control_panel.test") assert state.state == STATE_UNKNOWN @@ -466,13 +494,14 @@ async def test_update_state_via_state_topic_template(hass, mqtt_mock): assert state.state == STATE_ALARM_ARMED_AWAY -async def test_attributes_code_number(hass, mqtt_mock): +async def test_attributes_code_number(hass, mqtt_mock_entry_with_yaml_config): """Test attributes which are not supported by the vacuum.""" config = copy.deepcopy(DEFAULT_CONFIG) config[alarm_control_panel.DOMAIN]["code"] = CODE_NUMBER assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -481,13 +510,14 @@ async def test_attributes_code_number(hass, mqtt_mock): ) -async def test_attributes_remote_code_number(hass, mqtt_mock): +async def test_attributes_remote_code_number(hass, mqtt_mock_entry_with_yaml_config): """Test attributes which are not supported by the vacuum.""" config = copy.deepcopy(DEFAULT_CONFIG_REMOTE_CODE) config[alarm_control_panel.DOMAIN]["code"] = "REMOTE_CODE" assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -496,13 +526,14 @@ async def test_attributes_remote_code_number(hass, mqtt_mock): ) -async def test_attributes_code_text(hass, mqtt_mock): +async def test_attributes_code_text(hass, mqtt_mock_entry_with_yaml_config): """Test attributes which are not supported by the vacuum.""" config = copy.deepcopy(DEFAULT_CONFIG) config[alarm_control_panel.DOMAIN]["code"] = CODE_TEXT assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("alarm_control_panel.test") assert ( @@ -511,81 +542,121 @@ async def test_attributes_code_text(hass, mqtt_mock): ) -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE + hass, + mqtt_mock_entry_with_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE + hass, + mqtt_mock_entry_with_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE + hass, + mqtt_mock_entry_with_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE + hass, + mqtt_mock_entry_with_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG_CODE, ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, MQTT_ALARM_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one alarm per unique_id.""" config = { alarm_control_panel.DOMAIN: [ @@ -605,18 +676,22 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, alarm_control_panel.DOMAIN, config) - - -async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): - """Test removal of discovered alarm_control_panel.""" - data = json.dumps(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) - await help_test_discovery_removal( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, alarm_control_panel.DOMAIN, config ) -async def test_discovery_update_alarm_topic_and_template(hass, mqtt_mock, caplog): +async def test_discovery_removal_alarm(hass, mqtt_mock_entry_no_yaml_config, caplog): + """Test removal of discovered alarm_control_panel.""" + data = json.dumps(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, data + ) + + +async def test_discovery_update_alarm_topic_and_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered alarm_control_panel.""" config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) @@ -639,7 +714,7 @@ async def test_discovery_update_alarm_topic_and_template(hass, mqtt_mock, caplog await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, config1, @@ -649,7 +724,9 @@ async def test_discovery_update_alarm_topic_and_template(hass, mqtt_mock, caplog ) -async def test_discovery_update_alarm_template(hass, mqtt_mock, caplog): +async def test_discovery_update_alarm_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered alarm_control_panel.""" config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) @@ -670,7 +747,7 @@ async def test_discovery_update_alarm_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, alarm_control_panel.DOMAIN, config1, @@ -680,7 +757,9 @@ async def test_discovery_update_alarm_template(hass, mqtt_mock, caplog): ) -async def test_discovery_update_unchanged_alarm(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_alarm( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered alarm_control_panel.""" config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) config1["name"] = "Beer" @@ -690,12 +769,17 @@ async def test_discovery_update_unchanged_alarm(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.alarm_control_panel.MqttAlarm.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + alarm_control_panel.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = ( @@ -704,7 +788,12 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2 + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + alarm_control_panel.DOMAIN, + data1, + data2, ) @@ -715,11 +804,13 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ("state_topic", "disarmed"), ], ) -async def test_encoding_subscribable_topics(hass, mqtt_mock, caplog, topic, value): +async def test_encoding_subscribable_topics( + hass, mqtt_mock_entry_with_yaml_config, caplog, topic, value +): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG[alarm_control_panel.DOMAIN], @@ -728,53 +819,62 @@ async def test_encoding_subscribable_topics(hass, mqtt_mock, caplog, topic, valu ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT alarm control panel device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT alarm control panel device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + alarm_control_panel.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, alarm_control_panel.SERVICE_ALARM_DISARM, @@ -807,7 +907,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -823,7 +923,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -837,11 +937,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = alarm_control_panel.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index e4a48b07940..37bb783d354 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -62,7 +62,9 @@ DEFAULT_CONFIG = { } -async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, caplog): +async def test_setting_sensor_value_expires_availability_topic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the expiration of the value.""" assert await async_setup_component( hass, @@ -79,6 +81,7 @@ async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE @@ -89,10 +92,12 @@ async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, mqtt_mock, caplog) + await expires_helper(hass) -async def test_setting_sensor_value_expires(hass, mqtt_mock, caplog): +async def test_setting_sensor_value_expires( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the expiration of the value.""" assert await async_setup_component( hass, @@ -108,15 +113,16 @@ async def test_setting_sensor_value_expires(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, mqtt_mock, caplog) + await expires_helper(hass) -async def expires_helper(hass, mqtt_mock, caplog): +async def expires_helper(hass): """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) @@ -168,9 +174,10 @@ async def expires_helper(hass, mqtt_mock, caplog): async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_no_yaml_config, caplog ): """Test that binary_sensor with expire_after set behaves correctly on discovery and discovery update.""" + await mqtt_mock_entry_no_yaml_config() config = { "name": "Test", "state_topic": "test-topic", @@ -247,7 +254,9 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): +async def test_setting_sensor_value_via_mqtt_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of the value via MQTT.""" assert await async_setup_component( hass, @@ -263,6 +272,7 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") @@ -281,7 +291,9 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_invalid_sensor_value_via_mqtt_message(hass, mqtt_mock, caplog): +async def test_invalid_sensor_value_via_mqtt_message( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the setting of the value via MQTT.""" assert await async_setup_component( hass, @@ -297,6 +309,7 @@ async def test_invalid_sensor_value_via_mqtt_message(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") @@ -319,7 +332,9 @@ async def test_invalid_sensor_value_via_mqtt_message(hass, mqtt_mock, caplog): assert "No matching payload found for entity" in caplog.text -async def test_setting_sensor_value_via_mqtt_message_and_template(hass, mqtt_mock): +async def test_setting_sensor_value_via_mqtt_message_and_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of the value via MQTT.""" assert await async_setup_component( hass, @@ -337,6 +352,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template(hass, mqtt_moc }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -351,7 +367,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template(hass, mqtt_moc async def test_setting_sensor_value_via_mqtt_message_and_template2( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test the setting of the value via MQTT.""" assert await async_setup_component( @@ -369,6 +385,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -388,7 +405,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template2( async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_encoding( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test processing a raw value via MQTT.""" assert await async_setup_component( @@ -407,6 +424,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -421,7 +439,7 @@ async def test_setting_sensor_value_via_mqtt_message_and_template_and_raw_state_ async def test_setting_sensor_value_via_mqtt_message_empty_template( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test the setting of the value via MQTT.""" assert await async_setup_component( @@ -439,6 +457,7 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNKNOWN @@ -453,7 +472,7 @@ async def test_setting_sensor_value_via_mqtt_message_empty_template( assert state.state == STATE_ON -async def test_valid_device_class(hass, mqtt_mock): +async def test_valid_device_class(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of a valid sensor class.""" assert await async_setup_component( hass, @@ -468,12 +487,13 @@ async def test_valid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("binary_sensor.test") assert state.attributes.get("device_class") == "motion" -async def test_invalid_device_class(hass, mqtt_mock): +async def test_invalid_device_class(hass, mqtt_mock_entry_no_yaml_config): """Test the setting of an invalid sensor class.""" assert await async_setup_component( hass, @@ -488,40 +508,43 @@ async def test_invalid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("binary_sensor.test") assert state is None -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_force_update_disabled(hass, mqtt_mock): +async def test_force_update_disabled(hass, mqtt_mock_entry_with_yaml_config): """Test force update option.""" assert await async_setup_component( hass, @@ -537,6 +560,7 @@ async def test_force_update_disabled(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() events = [] @@ -556,7 +580,7 @@ async def test_force_update_disabled(hass, mqtt_mock): assert len(events) == 1 -async def test_force_update_enabled(hass, mqtt_mock): +async def test_force_update_enabled(hass, mqtt_mock_entry_with_yaml_config): """Test force update option.""" assert await async_setup_component( hass, @@ -573,6 +597,7 @@ async def test_force_update_enabled(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() events = [] @@ -592,7 +617,7 @@ async def test_force_update_enabled(hass, mqtt_mock): assert len(events) == 2 -async def test_off_delay(hass, mqtt_mock): +async def test_off_delay(hass, mqtt_mock_entry_with_yaml_config): """Test off_delay option.""" assert await async_setup_component( hass, @@ -610,6 +635,7 @@ async def test_off_delay(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() events = [] @@ -639,42 +665,60 @@ async def test_off_delay(hass, mqtt_mock): assert len(events) == 3 -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + binary_sensor.DOMAIN, + DEFAULT_CONFIG, ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + binary_sensor.DOMAIN, + DEFAULT_CONFIG, ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + binary_sensor.DOMAIN, + DEFAULT_CONFIG, ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one sensor per unique_id.""" config = { binary_sensor.DOMAIN: [ @@ -692,18 +736,24 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, binary_sensor.DOMAIN, config) - - -async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): - """Test removal of discovered binary_sensor.""" - data = json.dumps(DEFAULT_CONFIG[binary_sensor.DOMAIN]) - await help_test_discovery_removal( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, config ) -async def test_discovery_update_binary_sensor_topic_template(hass, mqtt_mock, caplog): +async def test_discovery_removal_binary_sensor( + hass, mqtt_mock_entry_no_yaml_config, caplog +): + """Test removal of discovered binary_sensor.""" + data = json.dumps(DEFAULT_CONFIG[binary_sensor.DOMAIN]) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, data + ) + + +async def test_discovery_update_binary_sensor_topic_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered binary_sensor.""" config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) @@ -728,7 +778,7 @@ async def test_discovery_update_binary_sensor_topic_template(hass, mqtt_mock, ca await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, config1, @@ -738,7 +788,9 @@ async def test_discovery_update_binary_sensor_topic_template(hass, mqtt_mock, ca ) -async def test_discovery_update_binary_sensor_template(hass, mqtt_mock, caplog): +async def test_discovery_update_binary_sensor_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered binary_sensor.""" config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) @@ -761,7 +813,7 @@ async def test_discovery_update_binary_sensor_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, binary_sensor.DOMAIN, config1, @@ -785,12 +837,18 @@ async def test_discovery_update_binary_sensor_template(hass, mqtt_mock, caplog): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG[binary_sensor.DOMAIN], @@ -801,7 +859,9 @@ async def test_encoding_subscribable_topics( ) -async def test_discovery_update_unchanged_binary_sensor(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_binary_sensor( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered binary_sensor.""" config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) config1["name"] = "Beer" @@ -811,74 +871,90 @@ async def test_discovery_update_unchanged_binary_sensor(hass, mqtt_mock, caplog) "homeassistant.components.mqtt.binary_sensor.MqttBinarySensor.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + binary_sensor.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "off_delay": -1 }' data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2 + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + binary_sensor.DOMAIN, + data1, + data2, ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT binary sensor device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT binary sensor device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, binary_sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG, None + hass, + mqtt_mock_entry_no_yaml_config, + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + None, ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = binary_sensor.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -893,7 +969,15 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): [("ON", "on", "OFF", "off"), ("OFF", "off", "ON", "on")], ) async def test_cleanup_triggers_and_restoring_state( - hass, mqtt_mock, caplog, tmp_path, freezer, payload1, state1, payload2, state2 + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + tmp_path, + freezer, + payload1, + state1, + payload2, + state2, ): """Test cleanup old triggers at reloading and restoring the state.""" domain = binary_sensor.DOMAIN @@ -914,6 +998,8 @@ async def test_cleanup_triggers_and_restoring_state( {binary_sensor.DOMAIN: [config1, config2]}, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + async_fire_mqtt_message(hass, "test-topic1", payload1) state = hass.states.get("binary_sensor.test1") assert state.state == state1 @@ -951,7 +1037,7 @@ async def test_cleanup_triggers_and_restoring_state( async def test_skip_restoring_state_with_over_due_expire_trigger( - hass, mqtt_mock, caplog, freezer + hass, mqtt_mock_entry_with_yaml_config, caplog, freezer ): """Test restoring a state with over due expire timer.""" @@ -973,6 +1059,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ), assert_setup_component(1, domain): assert await async_setup_component(hass, domain, {domain: config3}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert "Skip state recovery after reload for binary_sensor.test3" in caplog.text diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 941f08e541c..35deccf2bfe 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -42,7 +42,7 @@ DEFAULT_CONFIG = { @pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") -async def test_sending_mqtt_commands(hass, mqtt_mock): +async def test_sending_mqtt_commands(hass, mqtt_mock_entry_with_yaml_config): """Test the sending MQTT commands.""" assert await async_setup_component( hass, @@ -59,6 +59,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("button.test_button") assert state.state == STATE_UNKNOWN @@ -79,7 +80,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): assert state.state == "2021-11-08T13:31:44+00:00" -async def test_command_template(hass, mqtt_mock): +async def test_command_template(hass, mqtt_mock_entry_with_yaml_config): """Test the sending of MQTT commands through a command template.""" assert await async_setup_component( hass, @@ -95,6 +96,7 @@ async def test_command_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("button.test") assert state.state == STATE_UNKNOWN @@ -113,21 +115,23 @@ async def test_command_template(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" config = { button.DOMAIN: { @@ -139,11 +143,17 @@ async def test_default_availability_payload(hass, mqtt_mock): } await help_test_default_availability_payload( - hass, mqtt_mock, button.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + button.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" config = { button.DOMAIN: { @@ -155,53 +165,67 @@ async def test_custom_availability_payload(hass, mqtt_mock): } await help_test_custom_availability_payload( - hass, mqtt_mock, button.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + button.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG, None + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG, None ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, button.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, button.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one button per unique_id.""" config = { button.DOMAIN: [ @@ -219,16 +243,20 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, button.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, button.DOMAIN, config + ) -async def test_discovery_removal_button(hass, mqtt_mock, caplog): +async def test_discovery_removal_button(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered button.""" data = '{ "name": "test", "command_topic": "test_topic" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, button.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, data + ) -async def test_discovery_update_button(hass, mqtt_mock, caplog): +async def test_discovery_update_button(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered button.""" config1 = copy.deepcopy(DEFAULT_CONFIG[button.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[button.DOMAIN]) @@ -237,7 +265,7 @@ async def test_discovery_update_button(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, config1, @@ -245,7 +273,9 @@ async def test_discovery_update_button(hass, mqtt_mock, caplog): ) -async def test_discovery_update_unchanged_button(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_button( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered button.""" data1 = ( '{ "name": "Beer",' @@ -256,60 +286,65 @@ async def test_discovery_update_unchanged_button(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.button.MqttButton.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, button.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + button.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, button.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, button.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT button device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT button device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, button.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, button.DOMAIN, DEFAULT_CONFIG, button.SERVICE_PRESS, @@ -318,7 +353,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) -async def test_invalid_device_class(hass, mqtt_mock): +async def test_invalid_device_class(hass, mqtt_mock_entry_no_yaml_config): """Test device_class option with invalid value.""" assert await async_setup_component( hass, @@ -333,12 +368,13 @@ async def test_invalid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("button.test") assert state is None -async def test_valid_device_class(hass, mqtt_mock): +async def test_valid_device_class(hass, mqtt_mock_entry_with_yaml_config): """Test device_class option with valid values.""" assert await async_setup_component( hass, @@ -366,6 +402,7 @@ async def test_valid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("button.test_1") assert state.attributes["device_class"] == button.ButtonDeviceClass.UPDATE @@ -382,7 +419,14 @@ async def test_valid_device_class(hass, mqtt_mock): ], ) async def test_publishing_with_custom_encoding( - hass, mqtt_mock, caplog, service, topic, parameters, payload, template + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + service, + topic, + parameters, + payload, + template, ): """Test publishing MQTT payload with different encoding.""" domain = button.DOMAIN @@ -390,7 +434,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -402,11 +446,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = button.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 204103152a7..54d829ce9f9 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -46,7 +46,9 @@ DEFAULT_CONFIG = { } -async def test_run_camera_setup(hass, hass_client_no_auth, mqtt_mock): +async def test_run_camera_setup( + hass, hass_client_no_auth, mqtt_mock_entry_with_yaml_config +): """Test that it fetches the given payload.""" topic = "test/camera" await async_setup_component( @@ -55,6 +57,7 @@ async def test_run_camera_setup(hass, hass_client_no_auth, mqtt_mock): {"camera": {"platform": "mqtt", "topic": topic, "name": "Test Camera"}}, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -67,7 +70,9 @@ async def test_run_camera_setup(hass, hass_client_no_auth, mqtt_mock): assert body == "beer" -async def test_run_camera_b64_encoded(hass, hass_client_no_auth, mqtt_mock): +async def test_run_camera_b64_encoded( + hass, hass_client_no_auth, mqtt_mock_entry_with_yaml_config +): """Test that it fetches the given encoded payload.""" topic = "test/camera" await async_setup_component( @@ -83,6 +88,7 @@ async def test_run_camera_b64_encoded(hass, hass_client_no_auth, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() url = hass.states.get("camera.test_camera").attributes["entity_picture"] @@ -95,77 +101,91 @@ async def test_run_camera_b64_encoded(hass, hass_client_no_auth, mqtt_mock): assert body == "grass" -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG, MQTT_CAMERA_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + camera.DOMAIN, + DEFAULT_CONFIG, + MQTT_CAMERA_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one camera per unique_id.""" config = { camera.DOMAIN: [ @@ -183,94 +203,109 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, camera.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, camera.DOMAIN, config + ) -async def test_discovery_removal_camera(hass, mqtt_mock, caplog): +async def test_discovery_removal_camera(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered camera.""" data = json.dumps(DEFAULT_CONFIG[camera.DOMAIN]) - await help_test_discovery_removal(hass, mqtt_mock, caplog, camera.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, data + ) -async def test_discovery_update_camera(hass, mqtt_mock, caplog): +async def test_discovery_update_camera(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered camera.""" config1 = {"name": "Beer", "topic": "test_topic"} config2 = {"name": "Milk", "topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock, caplog, camera.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_camera(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_camera( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered camera.""" data1 = '{ "name": "Beer", "topic": "test_topic"}' with patch( "homeassistant.components.mqtt.camera.MqttCamera.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, camera.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + camera.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk", "topic": "test_topic"}' await help_test_discovery_broken( - hass, mqtt_mock, caplog, camera.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, camera.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT camera device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT camera device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG, ["test_topic"] + hass, + mqtt_mock_entry_with_yaml_config, + camera.DOMAIN, + DEFAULT_CONFIG, + ["test_topic"], ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, camera.DOMAIN, DEFAULT_CONFIG, None, @@ -279,11 +314,13 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = camera.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 98af86248e4..77843cee777 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -106,10 +106,11 @@ DEFAULT_LEGACY_CONFIG = { } -async def test_setup_params(hass, mqtt_mock): +async def test_setup_params(hass, mqtt_mock_entry_with_yaml_config): """Test the initial parameters.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -120,12 +121,15 @@ async def test_setup_params(hass, mqtt_mock): assert state.attributes.get("max_temp") == DEFAULT_MAX_TEMP -async def test_preset_none_in_preset_modes(hass, mqtt_mock, caplog): +async def test_preset_none_in_preset_modes( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test the preset mode payload reset configuration.""" config = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) config["preset_modes"].append("none") assert await async_setup_component(hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert "Invalid config for [climate.mqtt]: not a valid value" in caplog.text state = hass.states.get(ENTITY_CLIMATE) assert state is None @@ -145,21 +149,23 @@ async def test_preset_none_in_preset_modes(hass, mqtt_mock, caplog): ], ) async def test_preset_modes_deprecation_guard( - hass, mqtt_mock, caplog, parameter, config_value + hass, mqtt_mock_entry_no_yaml_config, caplog, parameter, config_value ): """Test the configuration for invalid legacy parameters.""" config = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) config[parameter] = config_value assert await async_setup_component(hass, CLIMATE_DOMAIN, {CLIMATE_DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state is None -async def test_supported_features(hass, mqtt_mock): +async def test_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test the supported_features.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) support = ( @@ -174,10 +180,11 @@ async def test_supported_features(hass, mqtt_mock): assert state.attributes.get("supported_features") == support -async def test_get_hvac_modes(hass, mqtt_mock): +async def test_get_hvac_modes(hass, mqtt_mock_entry_with_yaml_config): """Test that the operation list returns the correct modes.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) modes = state.attributes.get("hvac_modes") @@ -191,13 +198,16 @@ async def test_get_hvac_modes(hass, mqtt_mock): ] == modes -async def test_set_operation_bad_attr_and_state(hass, mqtt_mock, caplog): +async def test_set_operation_bad_attr_and_state( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test setting operation mode without required attribute. Also check the state. """ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -210,10 +220,11 @@ async def test_set_operation_bad_attr_and_state(hass, mqtt_mock, caplog): assert state.state == "off" -async def test_set_operation(hass, mqtt_mock): +async def test_set_operation(hass, mqtt_mock_entry_with_yaml_config): """Test setting of new operation mode.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -224,12 +235,13 @@ async def test_set_operation(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("mode-topic", "cool", 0, False) -async def test_set_operation_pessimistic(hass, mqtt_mock): +async def test_set_operation_pessimistic(hass, mqtt_mock_entry_with_yaml_config): """Test setting operation mode in pessimistic mode.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["mode_state_topic"] = "mode-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "unknown" @@ -247,12 +259,13 @@ async def test_set_operation_pessimistic(hass, mqtt_mock): assert state.state == "cool" -async def test_set_operation_with_power_command(hass, mqtt_mock): +async def test_set_operation_with_power_command(hass, mqtt_mock_entry_with_yaml_config): """Test setting of new operation mode with power command enabled.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["power_command_topic"] = "power-command" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.state == "off" @@ -273,10 +286,11 @@ async def test_set_operation_with_power_command(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_set_fan_mode_bad_attr(hass, mqtt_mock, caplog): +async def test_set_fan_mode_bad_attr(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test setting fan mode without required attribute.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -289,12 +303,13 @@ async def test_set_fan_mode_bad_attr(hass, mqtt_mock, caplog): assert state.attributes.get("fan_mode") == "low" -async def test_set_fan_mode_pessimistic(hass, mqtt_mock): +async def test_set_fan_mode_pessimistic(hass, mqtt_mock_entry_with_yaml_config): """Test setting of new fan mode in pessimistic mode.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["fan_mode_state_topic"] = "fan-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") is None @@ -312,10 +327,11 @@ async def test_set_fan_mode_pessimistic(hass, mqtt_mock): assert state.attributes.get("fan_mode") == "high" -async def test_set_fan_mode(hass, mqtt_mock): +async def test_set_fan_mode(hass, mqtt_mock_entry_with_yaml_config): """Test setting of new fan mode.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("fan_mode") == "low" @@ -335,13 +351,14 @@ async def test_set_fan_mode(hass, mqtt_mock): ], ) async def test_set_fan_mode_send_if_off( - hass, mqtt_mock, send_if_off, assert_async_publish + hass, mqtt_mock_entry_with_yaml_config, send_if_off, assert_async_publish ): """Test setting of fan mode if the hvac is off.""" config = copy.deepcopy(DEFAULT_CONFIG) config[CLIMATE_DOMAIN].update(send_if_off) assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() assert hass.states.get(ENTITY_CLIMATE) is not None # Turn on HVAC @@ -362,10 +379,11 @@ async def test_set_fan_mode_send_if_off( mqtt_mock.async_publish.assert_has_calls(assert_async_publish) -async def test_set_swing_mode_bad_attr(hass, mqtt_mock, caplog): +async def test_set_swing_mode_bad_attr(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test setting swing mode without required attribute.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -378,12 +396,13 @@ async def test_set_swing_mode_bad_attr(hass, mqtt_mock, caplog): assert state.attributes.get("swing_mode") == "off" -async def test_set_swing_pessimistic(hass, mqtt_mock): +async def test_set_swing_pessimistic(hass, mqtt_mock_entry_with_yaml_config): """Test setting swing mode in pessimistic mode.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["swing_mode_state_topic"] = "swing-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") is None @@ -401,10 +420,11 @@ async def test_set_swing_pessimistic(hass, mqtt_mock): assert state.attributes.get("swing_mode") == "on" -async def test_set_swing(hass, mqtt_mock): +async def test_set_swing(hass, mqtt_mock_entry_with_yaml_config): """Test setting of new swing mode.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("swing_mode") == "off" @@ -424,13 +444,14 @@ async def test_set_swing(hass, mqtt_mock): ], ) async def test_set_swing_mode_send_if_off( - hass, mqtt_mock, send_if_off, assert_async_publish + hass, mqtt_mock_entry_with_yaml_config, send_if_off, assert_async_publish ): """Test setting of swing mode if the hvac is off.""" config = copy.deepcopy(DEFAULT_CONFIG) config[CLIMATE_DOMAIN].update(send_if_off) assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() assert hass.states.get(ENTITY_CLIMATE) is not None # Turn on HVAC @@ -451,10 +472,11 @@ async def test_set_swing_mode_send_if_off( mqtt_mock.async_publish.assert_has_calls(assert_async_publish) -async def test_set_target_temperature(hass, mqtt_mock): +async def test_set_target_temperature(hass, mqtt_mock_entry_with_yaml_config): """Test setting the target temperature.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") == 21 @@ -497,13 +519,14 @@ async def test_set_target_temperature(hass, mqtt_mock): ], ) async def test_set_target_temperature_send_if_off( - hass, mqtt_mock, send_if_off, assert_async_publish + hass, mqtt_mock_entry_with_yaml_config, send_if_off, assert_async_publish ): """Test setting of target temperature if the hvac is off.""" config = copy.deepcopy(DEFAULT_CONFIG) config[CLIMATE_DOMAIN].update(send_if_off) assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() assert hass.states.get(ENTITY_CLIMATE) is not None # Turn on HVAC @@ -526,12 +549,15 @@ async def test_set_target_temperature_send_if_off( mqtt_mock.async_publish.assert_has_calls(assert_async_publish) -async def test_set_target_temperature_pessimistic(hass, mqtt_mock): +async def test_set_target_temperature_pessimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test setting the target temperature.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["temperature_state_topic"] = "temperature-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("temperature") is None @@ -549,10 +575,11 @@ async def test_set_target_temperature_pessimistic(hass, mqtt_mock): assert state.attributes.get("temperature") == 1701 -async def test_set_target_temperature_low_high(hass, mqtt_mock): +async def test_set_target_temperature_low_high(hass, mqtt_mock_entry_with_yaml_config): """Test setting the low/high target temperature.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_set_temperature( hass, target_temp_low=20, target_temp_high=23, entity_id=ENTITY_CLIMATE @@ -564,13 +591,16 @@ async def test_set_target_temperature_low_high(hass, mqtt_mock): mqtt_mock.async_publish.assert_any_call("temperature-high-topic", "23.0", 0, False) -async def test_set_target_temperature_low_highpessimistic(hass, mqtt_mock): +async def test_set_target_temperature_low_highpessimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test setting the low/high target temperature.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["temperature_low_state_topic"] = "temperature-low-state" config["climate"]["temperature_high_state_topic"] = "temperature-high-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("target_temp_low") is None @@ -601,24 +631,26 @@ async def test_set_target_temperature_low_highpessimistic(hass, mqtt_mock): assert state.attributes.get("target_temp_high") == 1703 -async def test_receive_mqtt_temperature(hass, mqtt_mock): +async def test_receive_mqtt_temperature(hass, mqtt_mock_entry_with_yaml_config): """Test getting the current temperature via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["current_temperature_topic"] = "current_temperature" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "47") state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("current_temperature") == 47 -async def test_handle_action_received(hass, mqtt_mock): +async def test_handle_action_received(hass, mqtt_mock_entry_with_yaml_config): """Test getting the action received via MQTT.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["action_topic"] = "action" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() # Cycle through valid modes and also check for wrong input such as "None" (str(None)) async_fire_mqtt_message(hass, "action", "None") @@ -635,11 +667,14 @@ async def test_handle_action_received(hass, mqtt_mock): assert hvac_action == action -async def test_set_preset_mode_optimistic(hass, mqtt_mock, caplog): +async def test_set_preset_mode_optimistic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test setting of the preset mode.""" config = copy.deepcopy(DEFAULT_CONFIG) assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -680,12 +715,15 @@ async def test_set_preset_mode_optimistic(hass, mqtt_mock, caplog): assert "'invalid' is not a valid preset mode" in caplog.text -async def test_set_preset_mode_pessimistic(hass, mqtt_mock, caplog): +async def test_set_preset_mode_pessimistic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test setting of the preset mode.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["preset_mode_state_topic"] = "preset-mode-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -725,12 +763,13 @@ async def test_set_preset_mode_pessimistic(hass, mqtt_mock, caplog): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_away_mode_pessimistic(hass, mqtt_mock): +async def test_set_away_mode_pessimistic(hass, mqtt_mock_entry_with_yaml_config): """Test setting of the away mode.""" config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["away_mode_state_topic"] = "away-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -753,7 +792,7 @@ async def test_set_away_mode_pessimistic(hass, mqtt_mock): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_away_mode(hass, mqtt_mock): +async def test_set_away_mode(hass, mqtt_mock_entry_with_yaml_config): """Test setting of the away mode.""" config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["payload_on"] = "AN" @@ -761,6 +800,7 @@ async def test_set_away_mode(hass, mqtt_mock): assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -795,12 +835,13 @@ async def test_set_away_mode(hass, mqtt_mock): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_hold_pessimistic(hass, mqtt_mock): +async def test_set_hold_pessimistic(hass, mqtt_mock_entry_with_yaml_config): """Test setting the hold mode in pessimistic mode.""" config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["hold_state_topic"] = "hold-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("hold_mode") is None @@ -819,10 +860,11 @@ async def test_set_hold_pessimistic(hass, mqtt_mock): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_hold(hass, mqtt_mock): +async def test_set_hold(hass, mqtt_mock_entry_with_yaml_config): """Test setting the hold mode.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_LEGACY_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -851,10 +893,11 @@ async def test_set_hold(hass, mqtt_mock): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_preset_away(hass, mqtt_mock): +async def test_set_preset_away(hass, mqtt_mock_entry_with_yaml_config): """Test setting the hold mode and away mode.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_LEGACY_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == PRESET_NONE @@ -885,13 +928,14 @@ async def test_set_preset_away(hass, mqtt_mock): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_preset_away_pessimistic(hass, mqtt_mock): +async def test_set_preset_away_pessimistic(hass, mqtt_mock_entry_with_yaml_config): """Test setting the hold mode and away mode in pessimistic mode.""" config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["hold_state_topic"] = "hold-state" config["climate"]["away_mode_state_topic"] = "away-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == PRESET_NONE @@ -936,10 +980,11 @@ async def test_set_preset_away_pessimistic(hass, mqtt_mock): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_preset_mode_twice(hass, mqtt_mock): +async def test_set_preset_mode_twice(hass, mqtt_mock_entry_with_yaml_config): """Test setting of the same mode twice only publishes once.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_LEGACY_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("preset_mode") == "none" @@ -952,12 +997,13 @@ async def test_set_preset_mode_twice(hass, mqtt_mock): assert state.attributes.get("preset_mode") == "hold-on" -async def test_set_aux_pessimistic(hass, mqtt_mock): +async def test_set_aux_pessimistic(hass, mqtt_mock_entry_with_yaml_config): """Test setting of the aux heating in pessimistic mode.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["aux_state_topic"] = "aux-state" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -979,10 +1025,11 @@ async def test_set_aux_pessimistic(hass, mqtt_mock): assert state.attributes.get("aux_heat") == "off" -async def test_set_aux(hass, mqtt_mock): +async def test_set_aux(hass, mqtt_mock_entry_with_yaml_config): """Test setting of the aux heating.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) assert state.attributes.get("aux_heat") == "off" @@ -998,35 +1045,39 @@ async def test_set_aux(hass, mqtt_mock): assert state.attributes.get("aux_heat") == "off" -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_get_target_temperature_low_high_with_templates(hass, mqtt_mock, caplog): +async def test_get_target_temperature_low_high_with_templates( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test getting temperature high/low with templates.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["temperature_low_state_topic"] = "temperature-state" @@ -1036,6 +1087,7 @@ async def test_get_target_temperature_low_high_with_templates(hass, mqtt_mock, c assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) @@ -1060,7 +1112,7 @@ async def test_get_target_temperature_low_high_with_templates(hass, mqtt_mock, c assert state.attributes.get("target_temp_high") == 1032 -async def test_get_with_templates(hass, mqtt_mock, caplog): +async def test_get_with_templates(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test getting various attributes with templates.""" config = copy.deepcopy(DEFAULT_CONFIG) # By default, just unquote the JSON-strings @@ -1081,6 +1133,7 @@ async def test_get_with_templates(hass, mqtt_mock, caplog): config["climate"]["preset_mode_state_topic"] = "current-preset-mode" assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() # Operation Mode state = hass.states.get(ENTITY_CLIMATE) @@ -1159,7 +1212,9 @@ async def test_get_with_templates(hass, mqtt_mock, caplog): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_get_with_hold_and_away_mode_and_templates(hass, mqtt_mock, caplog): +async def test_get_with_hold_and_away_mode_and_templates( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test getting various for hold and away mode attributes with templates.""" config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) config["climate"]["mode_state_topic"] = "mode-state" @@ -1172,6 +1227,7 @@ async def test_get_with_hold_and_away_mode_and_templates(hass, mqtt_mock, caplog assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() # Operation Mode state = hass.states.get(ENTITY_CLIMATE) @@ -1206,7 +1262,7 @@ async def test_get_with_hold_and_away_mode_and_templates(hass, mqtt_mock, caplog assert state.attributes.get("preset_mode") == "somemode" -async def test_set_and_templates(hass, mqtt_mock, caplog): +async def test_set_and_templates(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test setting various attributes with templates.""" config = copy.deepcopy(DEFAULT_CONFIG) # Create simple templates @@ -1220,6 +1276,7 @@ async def test_set_and_templates(hass, mqtt_mock, caplog): assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() # Fan Mode await common.async_set_fan_mode(hass, "high", ENTITY_CLIMATE) @@ -1284,7 +1341,9 @@ async def test_set_and_templates(hass, mqtt_mock, caplog): # AWAY and HOLD mode topics and templates are deprecated, support will be removed with release 2022.9 -async def test_set_with_away_and_hold_modes_and_templates(hass, mqtt_mock, caplog): +async def test_set_with_away_and_hold_modes_and_templates( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test setting various attributes on hold and away mode with templates.""" config = copy.deepcopy(DEFAULT_LEGACY_CONFIG) # Create simple templates @@ -1292,6 +1351,7 @@ async def test_set_with_away_and_hold_modes_and_templates(hass, mqtt_mock, caplo assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() # Hold Mode await common.async_set_preset_mode(hass, PRESET_ECO, ENTITY_CLIMATE) @@ -1303,13 +1363,14 @@ async def test_set_with_away_and_hold_modes_and_templates(hass, mqtt_mock, caplo assert state.attributes.get("preset_mode") == PRESET_ECO -async def test_min_temp_custom(hass, mqtt_mock): +async def test_min_temp_custom(hass, mqtt_mock_entry_with_yaml_config): """Test a custom min temp.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["min_temp"] = 26 assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) min_temp = state.attributes.get("min_temp") @@ -1318,13 +1379,14 @@ async def test_min_temp_custom(hass, mqtt_mock): assert state.attributes.get("min_temp") == 26 -async def test_max_temp_custom(hass, mqtt_mock): +async def test_max_temp_custom(hass, mqtt_mock_entry_with_yaml_config): """Test a custom max temp.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["max_temp"] = 60 assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) max_temp = state.attributes.get("max_temp") @@ -1333,13 +1395,14 @@ async def test_max_temp_custom(hass, mqtt_mock): assert max_temp == 60 -async def test_temp_step_custom(hass, mqtt_mock): +async def test_temp_step_custom(hass, mqtt_mock_entry_with_yaml_config): """Test a custom temp step.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["temp_step"] = 0.01 assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(ENTITY_CLIMATE) temp_step = state.attributes.get("target_temp_step") @@ -1348,7 +1411,7 @@ async def test_temp_step_custom(hass, mqtt_mock): assert temp_step == 0.01 -async def test_temperature_unit(hass, mqtt_mock): +async def test_temperature_unit(hass, mqtt_mock_entry_with_yaml_config): """Test that setting temperature unit converts temperature values.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["temperature_unit"] = "F" @@ -1356,6 +1419,7 @@ async def test_temperature_unit(hass, mqtt_mock): assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "current_temperature", "77") @@ -1363,49 +1427,61 @@ async def test_temperature_unit(hass, mqtt_mock): assert state.attributes.get("current_temperature") == 25 -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG, MQTT_CLIMATE_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + CLIMATE_DOMAIN, + DEFAULT_CONFIG, + MQTT_CLIMATE_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one climate per unique_id.""" config = { CLIMATE_DOMAIN: [ @@ -1425,7 +1501,9 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, CLIMATE_DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, CLIMATE_DOMAIN, config + ) @pytest.mark.parametrize( @@ -1449,7 +1527,13 @@ async def test_unique_id(hass, mqtt_mock): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) @@ -1460,7 +1544,7 @@ async def test_encoding_subscribable_topics( del config["preset_mode_command_topic"] await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, CLIMATE_DOMAIN, config, @@ -1471,71 +1555,80 @@ async def test_encoding_subscribable_topics( ) -async def test_discovery_removal_climate(hass, mqtt_mock, caplog): +async def test_discovery_removal_climate(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered climate.""" data = json.dumps(DEFAULT_CONFIG[CLIMATE_DOMAIN]) - await help_test_discovery_removal(hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, CLIMATE_DOMAIN, data + ) -async def test_discovery_update_climate(hass, mqtt_mock, caplog): +async def test_discovery_update_climate(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered climate.""" config1 = {"name": "Beer"} config2 = {"name": "Milk"} await help_test_discovery_update( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, CLIMATE_DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_climate(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_climate( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered climate.""" data1 = '{ "name": "Beer" }' with patch( "homeassistant.components.mqtt.climate.MqttClimate.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + CLIMATE_DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer", "power_command_topic": "test_topic#" }' data2 = '{ "name": "Milk", "power_command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, CLIMATE_DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT climate device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT climate device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" config = { CLIMATE_DOMAIN: { @@ -1546,18 +1639,22 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock): } } await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, CLIMATE_DOMAIN, config, ["test-topic", "avty-topic"] + hass, + mqtt_mock_entry_with_yaml_config, + CLIMATE_DOMAIN, + config, + ["test-topic", "avty-topic"], ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, CLIMATE_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" config = { CLIMATE_DOMAIN: { @@ -1569,7 +1666,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): } await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, CLIMATE_DOMAIN, config, climate.SERVICE_TURN_ON, @@ -1579,10 +1676,11 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) -async def test_precision_default(hass, mqtt_mock): +async def test_precision_default(hass, mqtt_mock_entry_with_yaml_config): """Test that setting precision to tenths works as intended.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1592,12 +1690,13 @@ async def test_precision_default(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_precision_halves(hass, mqtt_mock): +async def test_precision_halves(hass, mqtt_mock_entry_with_yaml_config): """Test that setting precision to halves works as intended.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["precision"] = 0.5 assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1607,12 +1706,13 @@ async def test_precision_halves(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_precision_whole(hass, mqtt_mock): +async def test_precision_whole(hass, mqtt_mock_entry_with_yaml_config): """Test that setting precision to whole works as intended.""" config = copy.deepcopy(DEFAULT_CONFIG) config["climate"]["precision"] = 1.0 assert await async_setup_component(hass, CLIMATE_DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_set_temperature( hass, temperature=23.67, entity_id=ENTITY_CLIMATE @@ -1721,7 +1821,7 @@ async def test_precision_whole(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -1738,7 +1838,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -1750,11 +1850,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = CLIMATE_DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index b5bb5732617..50cf7beb0e0 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -46,10 +46,13 @@ DEFAULT_CONFIG_DEVICE_INFO_MAC = { _SENTINEL = object() -async def help_test_availability_when_connection_lost(hass, mqtt_mock, domain, config): +async def help_test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config, domain, config +): """Test availability after MQTT disconnection.""" assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state.state != STATE_UNAVAILABLE @@ -62,11 +65,14 @@ async def help_test_availability_when_connection_lost(hass, mqtt_mock, domain, c assert state.state == STATE_UNAVAILABLE -async def help_test_availability_without_topic(hass, mqtt_mock, domain, config): +async def help_test_availability_without_topic( + hass, mqtt_mock_entry_with_yaml_config, domain, config +): """Test availability without defined availability topic.""" assert "availability_topic" not in config[domain] assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state.state != STATE_UNAVAILABLE @@ -74,7 +80,7 @@ async def help_test_availability_without_topic(hass, mqtt_mock, domain, config): async def help_test_default_availability_payload( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, domain, config, no_assumed_state=False, @@ -94,6 +100,7 @@ async def help_test_default_availability_payload( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state.state == STATE_UNAVAILABLE @@ -124,7 +131,7 @@ async def help_test_default_availability_payload( async def help_test_default_availability_list_payload( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, domain, config, no_assumed_state=False, @@ -147,6 +154,7 @@ async def help_test_default_availability_list_payload( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state.state == STATE_UNAVAILABLE @@ -189,7 +197,7 @@ async def help_test_default_availability_list_payload( async def help_test_default_availability_list_payload_all( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, domain, config, no_assumed_state=False, @@ -213,6 +221,7 @@ async def help_test_default_availability_list_payload_all( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state.state == STATE_UNAVAILABLE @@ -256,7 +265,7 @@ async def help_test_default_availability_list_payload_all( async def help_test_default_availability_list_payload_any( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, domain, config, no_assumed_state=False, @@ -280,6 +289,7 @@ async def help_test_default_availability_list_payload_any( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state.state == STATE_UNAVAILABLE @@ -318,7 +328,7 @@ async def help_test_default_availability_list_payload_any( async def help_test_default_availability_list_single( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -342,6 +352,7 @@ async def help_test_default_availability_list_single( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state is None @@ -353,7 +364,7 @@ async def help_test_default_availability_list_single( async def help_test_custom_availability_payload( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, domain, config, no_assumed_state=False, @@ -375,6 +386,7 @@ async def help_test_custom_availability_payload( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state.state == STATE_UNAVAILABLE @@ -405,7 +417,7 @@ async def help_test_custom_availability_payload( async def help_test_discovery_update_availability( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, domain, config, no_assumed_state=False, @@ -416,6 +428,7 @@ async def help_test_discovery_update_availability( This is a test helper for the MQTTAvailability mixin. """ + await mqtt_mock_entry_no_yaml_config() # Add availability settings to config config1 = copy.deepcopy(config) config1[domain]["availability_topic"] = "availability-topic1" @@ -484,7 +497,7 @@ async def help_test_discovery_update_availability( async def help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, domain, config + hass, mqtt_mock_entry_with_yaml_config, domain, config ): """Test the setting of attribute via MQTT with JSON payload. @@ -499,6 +512,7 @@ async def help_test_setting_attribute_via_mqtt_json_message( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') state = hass.states.get(f"{domain}.test") @@ -507,12 +521,13 @@ async def help_test_setting_attribute_via_mqtt_json_message( async def help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, domain, config, extra_blocked_attributes + hass, mqtt_mock_entry_no_yaml_config, domain, config, extra_blocked_attributes ): """Test the setting of blocked attribute via MQTT with JSON payload. This is a test helper for the MqttAttributes mixin. """ + await mqtt_mock_entry_no_yaml_config() extra_blocked_attributes = extra_blocked_attributes or [] # Add JSON attributes settings to config @@ -534,7 +549,9 @@ async def help_test_setting_blocked_attribute_via_mqtt_json_message( assert state.attributes.get(attr) != val -async def help_test_setting_attribute_with_template(hass, mqtt_mock, domain, config): +async def help_test_setting_attribute_with_template( + hass, mqtt_mock_entry_with_yaml_config, domain, config +): """Test the setting of attribute via MQTT with JSON payload. This is a test helper for the MqttAttributes mixin. @@ -549,6 +566,7 @@ async def help_test_setting_attribute_with_template(hass, mqtt_mock, domain, con config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message( hass, "attr-topic", json.dumps({"Timer1": {"Arm": 0, "Time": "22:18"}}) @@ -560,7 +578,7 @@ async def help_test_setting_attribute_with_template(hass, mqtt_mock, domain, con async def help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, domain, config + hass, mqtt_mock_entry_with_yaml_config, caplog, domain, config ): """Test attributes get extracted from a JSON result. @@ -575,6 +593,7 @@ async def help_test_update_with_json_attrs_not_dict( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') state = hass.states.get(f"{domain}.test") @@ -584,7 +603,7 @@ async def help_test_update_with_json_attrs_not_dict( async def help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, domain, config + hass, mqtt_mock_entry_with_yaml_config, caplog, domain, config ): """Test JSON validation of attributes. @@ -599,6 +618,7 @@ async def help_test_update_with_json_attrs_bad_JSON( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") @@ -607,11 +627,14 @@ async def help_test_update_with_json_attrs_bad_JSON( assert "Erroneous JSON: This is not JSON" in caplog.text -async def help_test_discovery_update_attr(hass, mqtt_mock, caplog, domain, config): +async def help_test_discovery_update_attr( + hass, mqtt_mock_entry_no_yaml_config, caplog, domain, config +): """Test update of discovered MQTTAttributes. This is a test helper for the MqttAttributes mixin. """ + await mqtt_mock_entry_no_yaml_config() # Add JSON attributes settings to config config1 = copy.deepcopy(config) config1[domain]["json_attributes_topic"] = "attr-topic1" @@ -641,18 +664,22 @@ async def help_test_discovery_update_attr(hass, mqtt_mock, caplog, domain, confi assert state.attributes.get("val") == "75" -async def help_test_unique_id(hass, mqtt_mock, domain, config): +async def help_test_unique_id(hass, mqtt_mock_entry_with_yaml_config, domain, config): """Test unique id option only creates one entity per unique_id.""" assert await async_setup_component(hass, domain, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert len(hass.states.async_entity_ids(domain)) == 1 -async def help_test_discovery_removal(hass, mqtt_mock, caplog, domain, data): +async def help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, domain, data +): """Test removal of discovered component. This is a test helper for the MqttDiscoveryUpdate mixin. """ + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() @@ -669,7 +696,7 @@ async def help_test_discovery_removal(hass, mqtt_mock, caplog, domain, data): async def help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, domain, discovery_config1, @@ -681,6 +708,7 @@ async def help_test_discovery_update( This is a test helper for the MqttDiscoveryUpdate mixin. """ + await mqtt_mock_entry_no_yaml_config() # Add some future configuration to the configurations config1 = copy.deepcopy(discovery_config1) config1["some_future_option_1"] = "future_option_1" @@ -730,12 +758,13 @@ async def help_test_discovery_update( async def help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, domain, data1, discovery_update + hass, mqtt_mock_entry_no_yaml_config, caplog, domain, data1, discovery_update ): """Test update of discovered component without changes. This is a test helper for the MqttDiscoveryUpdate mixin. """ + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) await hass.async_block_till_done() @@ -749,8 +778,11 @@ async def help_test_discovery_update_unchanged( assert not discovery_update.called -async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, data2): +async def help_test_discovery_broken( + hass, mqtt_mock_entry_no_yaml_config, caplog, domain, data1, data2 +): """Test handling of bad discovery message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) await hass.async_block_till_done() @@ -769,7 +801,7 @@ async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, dat async def help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -849,6 +881,7 @@ async def help_test_encoding_subscribable_topics( hass, domain, {domain: [config1, config2, config3]} ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() expected_result = attribute_value or value @@ -899,11 +932,14 @@ async def help_test_encoding_subscribable_topics( pass -async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, config): +async def help_test_entity_device_info_with_identifier( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test device registry integration. This is a test helper for the MqttDiscoveryUpdate mixin. """ + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -926,11 +962,14 @@ async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, assert device.configuration_url == "http://example.com" -async def help_test_entity_device_info_with_connection(hass, mqtt_mock, domain, config): +async def help_test_entity_device_info_with_connection( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test device registry integration. This is a test helper for the MqttDiscoveryUpdate mixin. """ + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_MAC) @@ -955,8 +994,11 @@ async def help_test_entity_device_info_with_connection(hass, mqtt_mock, domain, assert device.configuration_url == "http://example.com" -async def help_test_entity_device_info_remove(hass, mqtt_mock, domain, config): +async def help_test_entity_device_info_remove( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test device registry remove.""" + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -981,11 +1023,14 @@ async def help_test_entity_device_info_remove(hass, mqtt_mock, domain, config): assert not ent_registry.async_get_entity_id(domain, mqtt.DOMAIN, "veryunique") -async def help_test_entity_device_info_update(hass, mqtt_mock, domain, config): +async def help_test_entity_device_info_update( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test device registry update. This is a test helper for the MqttDiscoveryUpdate mixin. """ + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1012,7 +1057,7 @@ async def help_test_entity_device_info_update(hass, mqtt_mock, domain, config): async def help_test_entity_id_update_subscriptions( - hass, mqtt_mock, domain, config, topics=None + hass, mqtt_mock_entry_with_yaml_config, domain, config, topics=None ): """Test MQTT subscriptions are managed when entity_id is updated.""" # Add unique_id to config @@ -1026,16 +1071,18 @@ async def help_test_entity_id_update_subscriptions( topics = ["avty-topic", "test-topic"] assert len(topics) > 0 registry = mock_registry(hass, {}) + assert await async_setup_component( hass, domain, config, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get(f"{domain}.test") assert state is not None - assert mqtt_mock.async_subscribe.call_count == len(topics) + assert mqtt_mock.async_subscribe.call_count == len(topics) + 3 for topic in topics: mqtt_mock.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) mqtt_mock.async_subscribe.reset_mock() @@ -1053,10 +1100,11 @@ async def help_test_entity_id_update_subscriptions( async def help_test_entity_id_update_discovery_update( - hass, mqtt_mock, domain, config, topic=None + hass, mqtt_mock_entry_no_yaml_config, domain, config, topic=None ): """Test MQTT discovery update after entity_id is updated.""" # Add unique_id to config + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(config) config[domain]["unique_id"] = "TOTALLY_UNIQUE" @@ -1093,11 +1141,14 @@ async def help_test_entity_id_update_discovery_update( assert state.state != STATE_UNAVAILABLE -async def help_test_entity_debug_info(hass, mqtt_mock, domain, config): +async def help_test_entity_debug_info( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test debug_info. This is a test helper for MQTT debug_info. """ + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1127,11 +1178,14 @@ async def help_test_entity_debug_info(hass, mqtt_mock, domain, config): assert len(debug_info_data["triggers"]) == 0 -async def help_test_entity_debug_info_max_messages(hass, mqtt_mock, domain, config): +async def help_test_entity_debug_info_max_messages( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test debug_info message overflow. This is a test helper for MQTT debug_info. """ + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1181,7 +1235,7 @@ async def help_test_entity_debug_info_max_messages(hass, mqtt_mock, domain, conf async def help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, domain, config, service, @@ -1196,6 +1250,7 @@ async def help_test_entity_debug_info_message( This is a test helper for MQTT debug_info. """ # Add device settings to config + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) config["unique_id"] = "veryunique" @@ -1290,11 +1345,14 @@ async def help_test_entity_debug_info_message( assert debug_info_data["entities"][0]["transmitted"] == expected_transmissions -async def help_test_entity_debug_info_remove(hass, mqtt_mock, domain, config): +async def help_test_entity_debug_info_remove( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test debug_info. This is a test helper for MQTT debug_info. """ + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1333,11 +1391,14 @@ async def help_test_entity_debug_info_remove(hass, mqtt_mock, domain, config): assert entity_id not in hass.data[debug_info.DATA_MQTT_DEBUG_INFO]["entities"] -async def help_test_entity_debug_info_update_entity_id(hass, mqtt_mock, domain, config): +async def help_test_entity_debug_info_update_entity_id( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test debug_info. This is a test helper for MQTT debug_info. """ + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1389,8 +1450,11 @@ async def help_test_entity_debug_info_update_entity_id(hass, mqtt_mock, domain, ) -async def help_test_entity_disabled_by_default(hass, mqtt_mock, domain, config): +async def help_test_entity_disabled_by_default( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test device registry remove.""" + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1425,8 +1489,11 @@ async def help_test_entity_disabled_by_default(hass, mqtt_mock, domain, config): assert not dev_registry.async_get_device({("mqtt", "helloworld")}) -async def help_test_entity_category(hass, mqtt_mock, domain, config): +async def help_test_entity_category( + hass, mqtt_mock_entry_no_yaml_config, domain, config +): """Test device registry remove.""" + await mqtt_mock_entry_no_yaml_config() # Add device settings to config config = copy.deepcopy(config[domain]) config["device"] = copy.deepcopy(DEFAULT_CONFIG_DEVICE_INFO_ID) @@ -1468,7 +1535,7 @@ async def help_test_entity_category(hass, mqtt_mock, domain, config): async def help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -1519,6 +1586,7 @@ async def help_test_publishing_with_custom_encoding( {domain: setup_config}, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() # 1) test with default encoding await hass.services.async_call( @@ -1602,7 +1670,9 @@ async def help_test_reload_with_config(hass, caplog, tmp_path, domain, config): assert "" in caplog.text -async def help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config): +async def help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config +): """Test reloading an MQTT platform.""" # Create and test an old config of 2 entities based on the config supplied old_config_1 = copy.deepcopy(config) @@ -1614,6 +1684,7 @@ async def help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config hass, domain, {domain: [old_config_1, old_config_2]} ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index 565fa7fda53..6fe781335f0 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -264,8 +264,9 @@ async def test_hassio_confirm(hass, mock_try_connection_success, mock_finish_set assert len(mock_finish_setup.mock_calls) == 1 -async def test_option_flow(hass, mqtt_mock, mock_try_connection): +async def test_option_flow(hass, mqtt_mock_entry_no_yaml_config, mock_try_connection): """Test config flow options.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] config_entry.data = { @@ -336,8 +337,11 @@ async def test_option_flow(hass, mqtt_mock, mock_try_connection): assert mqtt_mock.async_connect.call_count == 1 -async def test_disable_birth_will(hass, mqtt_mock, mock_try_connection): +async def test_disable_birth_will( + hass, mqtt_mock_entry_no_yaml_config, mock_try_connection +): """Test disabling birth and will.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() mock_try_connection.return_value = True config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] config_entry.data = { @@ -417,9 +421,10 @@ def get_suggested(schema, key): async def test_option_flow_default_suggested_values( - hass, mqtt_mock, mock_try_connection_success + hass, mqtt_mock_entry_no_yaml_config, mock_try_connection_success ): """Test config flow options has default/suggested values.""" + await mqtt_mock_entry_no_yaml_config() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] config_entry.data = { mqtt.CONF_BROKER: "test-broker", diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index e130b820c1b..5796c12f3cf 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -83,7 +83,7 @@ DEFAULT_CONFIG = { } -async def test_state_via_state_topic(hass, mqtt_mock): +async def test_state_via_state_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -102,6 +102,7 @@ async def test_state_via_state_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -118,7 +119,9 @@ async def test_state_via_state_topic(hass, mqtt_mock): assert state.state == STATE_OPEN -async def test_opening_and_closing_state_via_custom_state_payload(hass, mqtt_mock): +async def test_opening_and_closing_state_via_custom_state_payload( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling opening and closing state via a custom payload.""" assert await async_setup_component( hass, @@ -139,6 +142,7 @@ async def test_opening_and_closing_state_via_custom_state_payload(hass, mqtt_moc }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -160,7 +164,9 @@ async def test_opening_and_closing_state_via_custom_state_payload(hass, mqtt_moc assert state.state == STATE_CLOSED -async def test_open_closed_state_from_position_optimistic(hass, mqtt_mock): +async def test_open_closed_state_from_position_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the state after setting the position using optimistic mode.""" assert await async_setup_component( hass, @@ -180,6 +186,7 @@ async def test_open_closed_state_from_position_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -207,7 +214,7 @@ async def test_open_closed_state_from_position_optimistic(hass, mqtt_mock): assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_position_via_position_topic(hass, mqtt_mock): +async def test_position_via_position_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -228,6 +235,7 @@ async def test_position_via_position_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -244,7 +252,7 @@ async def test_position_via_position_topic(hass, mqtt_mock): assert state.state == STATE_OPEN -async def test_state_via_template(hass, mqtt_mock): +async def test_state_via_template(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -266,6 +274,7 @@ async def test_state_via_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -281,7 +290,7 @@ async def test_state_via_template(hass, mqtt_mock): assert state.state == STATE_CLOSED -async def test_state_via_template_and_entity_id(hass, mqtt_mock): +async def test_state_via_template_and_entity_id(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -303,6 +312,7 @@ async def test_state_via_template_and_entity_id(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -320,7 +330,9 @@ async def test_state_via_template_and_entity_id(hass, mqtt_mock): assert state.state == STATE_CLOSED -async def test_state_via_template_with_json_value(hass, mqtt_mock, caplog): +async def test_state_via_template_with_json_value( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the controlling state via topic with JSON value.""" assert await async_setup_component( hass, @@ -337,6 +349,7 @@ async def test_state_via_template_with_json_value(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -359,7 +372,9 @@ async def test_state_via_template_with_json_value(hass, mqtt_mock, caplog): ) in caplog.text -async def test_position_via_template_and_entity_id(hass, mqtt_mock): +async def test_position_via_template_and_entity_id( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -381,6 +396,7 @@ async def test_position_via_template_and_entity_id(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -411,7 +427,9 @@ async def test_position_via_template_and_entity_id(hass, mqtt_mock): ({"tilt_command_topic": "abc", "tilt_status_topic": "abc"}, False), ], ) -async def test_optimistic_flag(hass, mqtt_mock, config, assumed_state): +async def test_optimistic_flag( + hass, mqtt_mock_entry_with_yaml_config, config, assumed_state +): """Test assumed_state is set correctly.""" assert await async_setup_component( hass, @@ -419,6 +437,7 @@ async def test_optimistic_flag(hass, mqtt_mock, config, assumed_state): {cover.DOMAIN: {**config, "platform": "mqtt", "name": "test", "qos": 0}}, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -428,7 +447,7 @@ async def test_optimistic_flag(hass, mqtt_mock, config, assumed_state): assert ATTR_ASSUMED_STATE not in state.attributes -async def test_optimistic_state_change(hass, mqtt_mock): +async def test_optimistic_state_change(hass, mqtt_mock_entry_with_yaml_config): """Test changing state optimistically.""" assert await async_setup_component( hass, @@ -443,6 +462,7 @@ async def test_optimistic_state_change(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -484,7 +504,9 @@ async def test_optimistic_state_change(hass, mqtt_mock): assert state.state == STATE_CLOSED -async def test_optimistic_state_change_with_position(hass, mqtt_mock): +async def test_optimistic_state_change_with_position( + hass, mqtt_mock_entry_with_yaml_config +): """Test changing state optimistically.""" assert await async_setup_component( hass, @@ -501,6 +523,7 @@ async def test_optimistic_state_change_with_position(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -547,7 +570,7 @@ async def test_optimistic_state_change_with_position(hass, mqtt_mock): assert state.attributes.get(ATTR_CURRENT_POSITION) == 0 -async def test_send_open_cover_command(hass, mqtt_mock): +async def test_send_open_cover_command(hass, mqtt_mock_entry_with_yaml_config): """Test the sending of open_cover.""" assert await async_setup_component( hass, @@ -563,6 +586,7 @@ async def test_send_open_cover_command(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -576,7 +600,7 @@ async def test_send_open_cover_command(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_send_close_cover_command(hass, mqtt_mock): +async def test_send_close_cover_command(hass, mqtt_mock_entry_with_yaml_config): """Test the sending of close_cover.""" assert await async_setup_component( hass, @@ -592,6 +616,7 @@ async def test_send_close_cover_command(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -605,7 +630,7 @@ async def test_send_close_cover_command(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_send_stop__cover_command(hass, mqtt_mock): +async def test_send_stop__cover_command(hass, mqtt_mock_entry_with_yaml_config): """Test the sending of stop_cover.""" assert await async_setup_component( hass, @@ -621,6 +646,7 @@ async def test_send_stop__cover_command(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -634,7 +660,7 @@ async def test_send_stop__cover_command(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_current_cover_position(hass, mqtt_mock): +async def test_current_cover_position(hass, mqtt_mock_entry_with_yaml_config): """Test the current cover position.""" assert await async_setup_component( hass, @@ -654,6 +680,7 @@ async def test_current_cover_position(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -685,7 +712,7 @@ async def test_current_cover_position(hass, mqtt_mock): assert current_cover_position == 100 -async def test_current_cover_position_inverted(hass, mqtt_mock): +async def test_current_cover_position_inverted(hass, mqtt_mock_entry_with_yaml_config): """Test the current cover position.""" assert await async_setup_component( hass, @@ -705,6 +732,7 @@ async def test_current_cover_position_inverted(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -747,7 +775,7 @@ async def test_current_cover_position_inverted(hass, mqtt_mock): assert hass.states.get("cover.test").state == STATE_CLOSED -async def test_optimistic_position(hass, mqtt_mock): +async def test_optimistic_position(hass, mqtt_mock_entry_no_yaml_config): """Test optimistic position is not supported.""" assert await async_setup_component( hass, @@ -762,12 +790,13 @@ async def test_optimistic_position(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state is None -async def test_position_update(hass, mqtt_mock): +async def test_position_update(hass, mqtt_mock_entry_with_yaml_config): """Test cover position update from received MQTT message.""" assert await async_setup_component( hass, @@ -788,6 +817,7 @@ async def test_position_update(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_POSITION not in state_attributes_dict @@ -809,7 +839,7 @@ async def test_position_update(hass, mqtt_mock): [("{{position-1}}", 43, "42"), ("{{100-62}}", 100, "38")], ) async def test_set_position_templated( - hass, mqtt_mock, pos_template, pos_call, pos_message + hass, mqtt_mock_entry_with_yaml_config, pos_template, pos_call, pos_message ): """Test setting cover position via template.""" assert await async_setup_component( @@ -832,6 +862,7 @@ async def test_set_position_templated( }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -845,7 +876,9 @@ async def test_set_position_templated( ) -async def test_set_position_templated_and_attributes(hass, mqtt_mock): +async def test_set_position_templated_and_attributes( + hass, mqtt_mock_entry_with_yaml_config +): """Test setting cover position via template and using entities attributes.""" assert await async_setup_component( hass, @@ -876,6 +909,7 @@ async def test_set_position_templated_and_attributes(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -887,7 +921,7 @@ async def test_set_position_templated_and_attributes(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("set-position-topic", "5", 0, False) -async def test_set_tilt_templated(hass, mqtt_mock): +async def test_set_tilt_templated(hass, mqtt_mock_entry_with_yaml_config): """Test setting cover tilt position via template.""" assert await async_setup_component( hass, @@ -911,6 +945,7 @@ async def test_set_tilt_templated(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -924,7 +959,9 @@ async def test_set_tilt_templated(hass, mqtt_mock): ) -async def test_set_tilt_templated_and_attributes(hass, mqtt_mock): +async def test_set_tilt_templated_and_attributes( + hass, mqtt_mock_entry_with_yaml_config +): """Test setting cover tilt position via template and using entities attributes.""" assert await async_setup_component( hass, @@ -952,6 +989,7 @@ async def test_set_tilt_templated_and_attributes(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1010,7 +1048,7 @@ async def test_set_tilt_templated_and_attributes(hass, mqtt_mock): ) -async def test_set_position_untemplated(hass, mqtt_mock): +async def test_set_position_untemplated(hass, mqtt_mock_entry_with_yaml_config): """Test setting cover position via template.""" assert await async_setup_component( hass, @@ -1029,6 +1067,7 @@ async def test_set_position_untemplated(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1040,7 +1079,9 @@ async def test_set_position_untemplated(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_set_position_untemplated_custom_percentage_range(hass, mqtt_mock): +async def test_set_position_untemplated_custom_percentage_range( + hass, mqtt_mock_entry_with_yaml_config +): """Test setting cover position via template.""" assert await async_setup_component( hass, @@ -1061,6 +1102,7 @@ async def test_set_position_untemplated_custom_percentage_range(hass, mqtt_mock) }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1072,7 +1114,7 @@ async def test_set_position_untemplated_custom_percentage_range(hass, mqtt_mock) mqtt_mock.async_publish.assert_called_once_with("position-topic", "62", 0, False) -async def test_no_command_topic(hass, mqtt_mock): +async def test_no_command_topic(hass, mqtt_mock_entry_with_yaml_config): """Test with no command topic.""" assert await async_setup_component( hass, @@ -1091,11 +1133,12 @@ async def test_no_command_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 240 -async def test_no_payload_close(hass, mqtt_mock): +async def test_no_payload_close(hass, mqtt_mock_entry_with_yaml_config): """Test with no close payload.""" assert await async_setup_component( hass, @@ -1113,11 +1156,12 @@ async def test_no_payload_close(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 9 -async def test_no_payload_open(hass, mqtt_mock): +async def test_no_payload_open(hass, mqtt_mock_entry_with_yaml_config): """Test with no open payload.""" assert await async_setup_component( hass, @@ -1135,11 +1179,12 @@ async def test_no_payload_open(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 10 -async def test_no_payload_stop(hass, mqtt_mock): +async def test_no_payload_stop(hass, mqtt_mock_entry_with_yaml_config): """Test with no stop payload.""" assert await async_setup_component( hass, @@ -1157,11 +1202,12 @@ async def test_no_payload_stop(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 3 -async def test_with_command_topic_and_tilt(hass, mqtt_mock): +async def test_with_command_topic_and_tilt(hass, mqtt_mock_entry_with_yaml_config): """Test with command topic and tilt config.""" assert await async_setup_component( hass, @@ -1181,11 +1227,12 @@ async def test_with_command_topic_and_tilt(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert hass.states.get("cover.test").attributes["supported_features"] == 251 -async def test_tilt_defaults(hass, mqtt_mock): +async def test_tilt_defaults(hass, mqtt_mock_entry_with_yaml_config): """Test the defaults.""" assert await async_setup_component( hass, @@ -1206,6 +1253,7 @@ async def test_tilt_defaults(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes assert ATTR_CURRENT_TILT_POSITION in state_attributes_dict @@ -1216,7 +1264,7 @@ async def test_tilt_defaults(hass, mqtt_mock): assert current_cover_position == STATE_UNKNOWN -async def test_tilt_via_invocation_defaults(hass, mqtt_mock): +async def test_tilt_via_invocation_defaults(hass, mqtt_mock_entry_with_yaml_config): """Test tilt defaults on close/open.""" assert await async_setup_component( hass, @@ -1237,6 +1285,7 @@ async def test_tilt_via_invocation_defaults(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1298,7 +1347,7 @@ async def test_tilt_via_invocation_defaults(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("tilt-command-topic", "0", 0, False) -async def test_tilt_given_value(hass, mqtt_mock): +async def test_tilt_given_value(hass, mqtt_mock_entry_with_yaml_config): """Test tilting to a given value.""" assert await async_setup_component( hass, @@ -1321,6 +1370,7 @@ async def test_tilt_given_value(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1386,7 +1436,7 @@ async def test_tilt_given_value(hass, mqtt_mock): ) -async def test_tilt_given_value_optimistic(hass, mqtt_mock): +async def test_tilt_given_value_optimistic(hass, mqtt_mock_entry_with_yaml_config): """Test tilting to a given value.""" assert await async_setup_component( hass, @@ -1410,6 +1460,7 @@ async def test_tilt_given_value_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1462,7 +1513,7 @@ async def test_tilt_given_value_optimistic(hass, mqtt_mock): ) -async def test_tilt_given_value_altered_range(hass, mqtt_mock): +async def test_tilt_given_value_altered_range(hass, mqtt_mock_entry_with_yaml_config): """Test tilting to a given value.""" assert await async_setup_component( hass, @@ -1488,6 +1539,7 @@ async def test_tilt_given_value_altered_range(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1538,7 +1590,7 @@ async def test_tilt_given_value_altered_range(hass, mqtt_mock): ) -async def test_tilt_via_topic(hass, mqtt_mock): +async def test_tilt_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test tilt by updating status via MQTT.""" assert await async_setup_component( hass, @@ -1559,6 +1611,7 @@ async def test_tilt_via_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1575,7 +1628,7 @@ async def test_tilt_via_topic(hass, mqtt_mock): assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template(hass, mqtt_mock): +async def test_tilt_via_topic_template(hass, mqtt_mock_entry_with_yaml_config): """Test tilt by updating status via MQTT and template.""" assert await async_setup_component( hass, @@ -1599,6 +1652,7 @@ async def test_tilt_via_topic_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -1615,7 +1669,9 @@ async def test_tilt_via_topic_template(hass, mqtt_mock): assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_json_value(hass, mqtt_mock, caplog): +async def test_tilt_via_topic_template_json_value( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test tilt by updating status via MQTT and template with JSON value.""" assert await async_setup_component( hass, @@ -1639,6 +1695,7 @@ async def test_tilt_via_topic_template_json_value(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", '{"Var1": 9, "Var2": 30}') @@ -1661,7 +1718,7 @@ async def test_tilt_via_topic_template_json_value(hass, mqtt_mock, caplog): ) in caplog.text -async def test_tilt_via_topic_altered_range(hass, mqtt_mock): +async def test_tilt_via_topic_altered_range(hass, mqtt_mock_entry_with_yaml_config): """Test tilt status via MQTT with altered tilt range.""" assert await async_setup_component( hass, @@ -1684,6 +1741,7 @@ async def test_tilt_via_topic_altered_range(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1707,7 +1765,9 @@ async def test_tilt_via_topic_altered_range(hass, mqtt_mock): assert current_cover_tilt_position == 50 -async def test_tilt_status_out_of_range_warning(hass, caplog, mqtt_mock): +async def test_tilt_status_out_of_range_warning( + hass, caplog, mqtt_mock_entry_with_yaml_config +): """Test tilt status via MQTT tilt out of range warning message.""" assert await async_setup_component( hass, @@ -1730,6 +1790,7 @@ async def test_tilt_status_out_of_range_warning(hass, caplog, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "60") @@ -1738,7 +1799,9 @@ async def test_tilt_status_out_of_range_warning(hass, caplog, mqtt_mock): ) in caplog.text -async def test_tilt_status_not_numeric_warning(hass, caplog, mqtt_mock): +async def test_tilt_status_not_numeric_warning( + hass, caplog, mqtt_mock_entry_with_yaml_config +): """Test tilt status via MQTT tilt not numeric warning message.""" assert await async_setup_component( hass, @@ -1761,13 +1824,16 @@ async def test_tilt_status_not_numeric_warning(hass, caplog, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "abc") assert ("Payload 'abc' is not numeric") in caplog.text -async def test_tilt_via_topic_altered_range_inverted(hass, mqtt_mock): +async def test_tilt_via_topic_altered_range_inverted( + hass, mqtt_mock_entry_with_yaml_config +): """Test tilt status via MQTT with altered tilt range and inverted tilt position.""" assert await async_setup_component( hass, @@ -1790,6 +1856,7 @@ async def test_tilt_via_topic_altered_range_inverted(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "0") @@ -1813,7 +1880,9 @@ async def test_tilt_via_topic_altered_range_inverted(hass, mqtt_mock): assert current_cover_tilt_position == 50 -async def test_tilt_via_topic_template_altered_range(hass, mqtt_mock): +async def test_tilt_via_topic_template_altered_range( + hass, mqtt_mock_entry_with_yaml_config +): """Test tilt status via MQTT and template with altered tilt range.""" assert await async_setup_component( hass, @@ -1839,6 +1908,7 @@ async def test_tilt_via_topic_template_altered_range(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "tilt-status-topic", "99") @@ -1862,7 +1932,7 @@ async def test_tilt_via_topic_template_altered_range(hass, mqtt_mock): assert current_cover_tilt_position == 50 -async def test_tilt_position(hass, mqtt_mock): +async def test_tilt_position(hass, mqtt_mock_entry_with_yaml_config): """Test tilt via method invocation.""" assert await async_setup_component( hass, @@ -1883,6 +1953,7 @@ async def test_tilt_position(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1896,7 +1967,7 @@ async def test_tilt_position(hass, mqtt_mock): ) -async def test_tilt_position_templated(hass, mqtt_mock): +async def test_tilt_position_templated(hass, mqtt_mock_entry_with_yaml_config): """Test tilt position via template.""" assert await async_setup_component( hass, @@ -1918,6 +1989,7 @@ async def test_tilt_position_templated(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1931,7 +2003,7 @@ async def test_tilt_position_templated(hass, mqtt_mock): ) -async def test_tilt_position_altered_range(hass, mqtt_mock): +async def test_tilt_position_altered_range(hass, mqtt_mock_entry_with_yaml_config): """Test tilt via method invocation with altered range.""" assert await async_setup_component( hass, @@ -1956,6 +2028,7 @@ async def test_tilt_position_altered_range(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( cover.DOMAIN, @@ -1969,7 +2042,7 @@ async def test_tilt_position_altered_range(hass, mqtt_mock): ) -async def test_find_percentage_in_range_defaults(hass, mqtt_mock): +async def test_find_percentage_in_range_defaults(hass): """Test find percentage in range with default range.""" mqtt_cover = MqttCover( hass, @@ -2012,7 +2085,7 @@ async def test_find_percentage_in_range_defaults(hass, mqtt_mock): assert mqtt_cover.find_percentage_in_range(44, "cover") == 44 -async def test_find_percentage_in_range_altered(hass, mqtt_mock): +async def test_find_percentage_in_range_altered(hass): """Test find percentage in range with altered range.""" mqtt_cover = MqttCover( hass, @@ -2055,7 +2128,7 @@ async def test_find_percentage_in_range_altered(hass, mqtt_mock): assert mqtt_cover.find_percentage_in_range(120, "cover") == 40 -async def test_find_percentage_in_range_defaults_inverted(hass, mqtt_mock): +async def test_find_percentage_in_range_defaults_inverted(hass): """Test find percentage in range with default range but inverted.""" mqtt_cover = MqttCover( hass, @@ -2098,7 +2171,7 @@ async def test_find_percentage_in_range_defaults_inverted(hass, mqtt_mock): assert mqtt_cover.find_percentage_in_range(44, "cover") == 56 -async def test_find_percentage_in_range_altered_inverted(hass, mqtt_mock): +async def test_find_percentage_in_range_altered_inverted(hass): """Test find percentage in range with altered range and inverted.""" mqtt_cover = MqttCover( hass, @@ -2141,7 +2214,7 @@ async def test_find_percentage_in_range_altered_inverted(hass, mqtt_mock): assert mqtt_cover.find_percentage_in_range(120, "cover") == 60 -async def test_find_in_range_defaults(hass, mqtt_mock): +async def test_find_in_range_defaults(hass): """Test find in range with default range.""" mqtt_cover = MqttCover( hass, @@ -2184,7 +2257,7 @@ async def test_find_in_range_defaults(hass, mqtt_mock): assert mqtt_cover.find_in_range_from_percent(44, "cover") == 44 -async def test_find_in_range_altered(hass, mqtt_mock): +async def test_find_in_range_altered(hass): """Test find in range with altered range.""" mqtt_cover = MqttCover( hass, @@ -2227,7 +2300,7 @@ async def test_find_in_range_altered(hass, mqtt_mock): assert mqtt_cover.find_in_range_from_percent(40, "cover") == 120 -async def test_find_in_range_defaults_inverted(hass, mqtt_mock): +async def test_find_in_range_defaults_inverted(hass): """Test find in range with default range but inverted.""" mqtt_cover = MqttCover( hass, @@ -2270,7 +2343,7 @@ async def test_find_in_range_defaults_inverted(hass, mqtt_mock): assert mqtt_cover.find_in_range_from_percent(56, "cover") == 44 -async def test_find_in_range_altered_inverted(hass, mqtt_mock): +async def test_find_in_range_altered_inverted(hass): """Test find in range with altered range and inverted.""" mqtt_cover = MqttCover( hass, @@ -2313,35 +2386,37 @@ async def test_find_in_range_altered_inverted(hass, mqtt_mock): assert mqtt_cover.find_in_range_from_percent(60, "cover") == 120 -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_valid_device_class(hass, mqtt_mock): +async def test_valid_device_class(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of a valid device class.""" assert await async_setup_component( hass, @@ -2356,12 +2431,13 @@ async def test_valid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.attributes.get("device_class") == "garage" -async def test_invalid_device_class(hass, mqtt_mock): +async def test_invalid_device_class(hass, mqtt_mock_entry_no_yaml_config): """Test the setting of an invalid device class.""" assert await async_setup_component( hass, @@ -2376,54 +2452,67 @@ async def test_invalid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("cover.test") assert state is None -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG, MQTT_COVER_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + cover.DOMAIN, + DEFAULT_CONFIG, + MQTT_COVER_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique_id option only creates one cover per id.""" config = { cover.DOMAIN: [ @@ -2441,92 +2530,103 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, cover.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, config + ) -async def test_discovery_removal_cover(hass, mqtt_mock, caplog): +async def test_discovery_removal_cover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered cover.""" data = '{ "name": "test", "command_topic": "test_topic" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, cover.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, data + ) -async def test_discovery_update_cover(hass, mqtt_mock, caplog): +async def test_discovery_update_cover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered cover.""" config1 = {"name": "Beer", "command_topic": "test_topic"} config2 = {"name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock, caplog, cover.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_cover(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_cover( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered cover.""" data1 = '{ "name": "Beer", "command_topic": "test_topic" }' with patch( "homeassistant.components.mqtt.cover.MqttCover.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, cover.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + cover.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer", "command_topic": "test_topic#" }' data2 = '{ "name": "Milk", "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, cover.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT cover device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT cover device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, cover.DOMAIN, DEFAULT_CONFIG, SERVICE_OPEN_COVER, @@ -2535,7 +2635,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): async def test_state_and_position_topics_state_not_set_via_position_topic( - hass, mqtt_mock + hass, mqtt_mock_entry_with_yaml_config ): """Test state is not set via position topic when both state and position topics are set.""" assert await async_setup_component( @@ -2557,6 +2657,7 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2593,7 +2694,9 @@ async def test_state_and_position_topics_state_not_set_via_position_topic( assert state.state == STATE_CLOSED -async def test_set_state_via_position_using_stopped_state(hass, mqtt_mock): +async def test_set_state_via_position_using_stopped_state( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling state via position topic using stopped state.""" assert await async_setup_component( hass, @@ -2615,6 +2718,7 @@ async def test_set_state_via_position_using_stopped_state(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("cover.test") assert state.state == STATE_UNKNOWN @@ -2646,7 +2750,9 @@ async def test_set_state_via_position_using_stopped_state(hass, mqtt_mock): assert state.state == STATE_OPEN -async def test_position_via_position_topic_template(hass, mqtt_mock): +async def test_position_via_position_topic_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test position by updating status via position template.""" assert await async_setup_component( hass, @@ -2664,6 +2770,7 @@ async def test_position_via_position_topic_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "99") @@ -2680,7 +2787,9 @@ async def test_position_via_position_topic_template(hass, mqtt_mock): assert current_cover_position_position == 50 -async def test_position_via_position_topic_template_json_value(hass, mqtt_mock, caplog): +async def test_position_via_position_topic_template_json_value( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test position by updating status via position template with a JSON value.""" assert await async_setup_component( hass, @@ -2698,6 +2807,7 @@ async def test_position_via_position_topic_template_json_value(hass, mqtt_mock, }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", '{"Var1": 9, "Var2": 60}') @@ -2720,7 +2830,7 @@ async def test_position_via_position_topic_template_json_value(hass, mqtt_mock, ) in caplog.text -async def test_position_template_with_entity_id(hass, mqtt_mock): +async def test_position_template_with_entity_id(hass, mqtt_mock_entry_with_yaml_config): """Test position by updating status via position template.""" assert await async_setup_component( hass, @@ -2743,6 +2853,7 @@ async def test_position_template_with_entity_id(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "10") @@ -2759,7 +2870,9 @@ async def test_position_template_with_entity_id(hass, mqtt_mock): assert current_cover_position_position == 20 -async def test_position_via_position_topic_template_return_json(hass, mqtt_mock): +async def test_position_via_position_topic_template_return_json( + hass, mqtt_mock_entry_with_yaml_config +): """Test position by updating status via position template and returning json.""" assert await async_setup_component( hass, @@ -2777,6 +2890,7 @@ async def test_position_via_position_topic_template_return_json(hass, mqtt_mock) }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -2787,7 +2901,7 @@ async def test_position_via_position_topic_template_return_json(hass, mqtt_mock) async def test_position_via_position_topic_template_return_json_warning( - hass, caplog, mqtt_mock + hass, caplog, mqtt_mock_entry_with_yaml_config ): """Test position by updating status via position template returning json without position attribute.""" assert await async_setup_component( @@ -2806,6 +2920,7 @@ async def test_position_via_position_topic_template_return_json_warning( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -2816,7 +2931,7 @@ async def test_position_via_position_topic_template_return_json_warning( async def test_position_and_tilt_via_position_topic_template_return_json( - hass, mqtt_mock + hass, mqtt_mock_entry_with_yaml_config ): """Test position and tilt by updating the position via position template.""" assert await async_setup_component( @@ -2836,6 +2951,7 @@ async def test_position_and_tilt_via_position_topic_template_return_json( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -2857,7 +2973,9 @@ async def test_position_and_tilt_via_position_topic_template_return_json( assert current_cover_position == 99 and current_tilt_position == 49 -async def test_position_via_position_topic_template_all_variables(hass, mqtt_mock): +async def test_position_via_position_topic_template_all_variables( + hass, mqtt_mock_entry_with_yaml_config +): """Test position by updating status via position template.""" assert await async_setup_component( hass, @@ -2886,6 +3004,7 @@ async def test_position_via_position_topic_template_all_variables(hass, mqtt_moc }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "0") @@ -2901,7 +3020,9 @@ async def test_position_via_position_topic_template_all_variables(hass, mqtt_moc assert current_cover_position == 100 -async def test_set_state_via_stopped_state_no_position_topic(hass, mqtt_mock): +async def test_set_state_via_stopped_state_no_position_topic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling state via stopped state when no position topic.""" assert await async_setup_component( hass, @@ -2923,6 +3044,7 @@ async def test_set_state_via_stopped_state_no_position_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "state-topic", "OPEN") @@ -2951,7 +3073,7 @@ async def test_set_state_via_stopped_state_no_position_topic(hass, mqtt_mock): async def test_position_via_position_topic_template_return_invalid_json( - hass, caplog, mqtt_mock + hass, caplog, mqtt_mock_entry_with_yaml_config ): """Test position by updating status via position template and returning invalid json.""" assert await async_setup_component( @@ -2970,6 +3092,7 @@ async def test_position_via_position_topic_template_return_invalid_json( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "get-position-topic", "55") @@ -2977,7 +3100,7 @@ async def test_position_via_position_topic_template_return_invalid_json( async def test_set_position_topic_without_get_position_topic_error( - hass, caplog, mqtt_mock + hass, caplog, mqtt_mock_entry_no_yaml_config ): """Test error when set_position_topic is used without position_topic.""" assert await async_setup_component( @@ -2994,13 +3117,16 @@ async def test_set_position_topic_without_get_position_topic_error( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TOPIC}' must be set together with '{CONF_GET_POSITION_TOPIC}'." ) in caplog.text -async def test_value_template_without_state_topic_error(hass, caplog, mqtt_mock): +async def test_value_template_without_state_topic_error( + hass, caplog, mqtt_mock_entry_no_yaml_config +): """Test error when value_template is used and state_topic is missing.""" assert await async_setup_component( hass, @@ -3015,13 +3141,16 @@ async def test_value_template_without_state_topic_error(hass, caplog, mqtt_mock) }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_VALUE_TEMPLATE}' must be set together with '{CONF_STATE_TOPIC}'." ) in caplog.text -async def test_position_template_without_position_topic_error(hass, caplog, mqtt_mock): +async def test_position_template_without_position_topic_error( + hass, caplog, mqtt_mock_entry_no_yaml_config +): """Test error when position_template is used and position_topic is missing.""" assert await async_setup_component( hass, @@ -3036,6 +3165,7 @@ async def test_position_template_without_position_topic_error(hass, caplog, mqtt }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_GET_POSITION_TEMPLATE}' must be set together with '{CONF_GET_POSITION_TOPIC}'." @@ -3044,7 +3174,7 @@ async def test_position_template_without_position_topic_error(hass, caplog, mqtt async def test_set_position_template_without_set_position_topic( - hass, caplog, mqtt_mock + hass, caplog, mqtt_mock_entry_no_yaml_config ): """Test error when set_position_template is used and set_position_topic is missing.""" assert await async_setup_component( @@ -3060,6 +3190,7 @@ async def test_set_position_template_without_set_position_topic( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_SET_POSITION_TEMPLATE}' must be set together with '{CONF_SET_POSITION_TOPIC}'." @@ -3068,7 +3199,7 @@ async def test_set_position_template_without_set_position_topic( async def test_tilt_command_template_without_tilt_command_topic( - hass, caplog, mqtt_mock + hass, caplog, mqtt_mock_entry_no_yaml_config ): """Test error when tilt_command_template is used and tilt_command_topic is missing.""" assert await async_setup_component( @@ -3084,6 +3215,7 @@ async def test_tilt_command_template_without_tilt_command_topic( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_COMMAND_TEMPLATE}' must be set together with '{CONF_TILT_COMMAND_TOPIC}'." @@ -3092,7 +3224,7 @@ async def test_tilt_command_template_without_tilt_command_topic( async def test_tilt_status_template_without_tilt_status_topic_topic( - hass, caplog, mqtt_mock + hass, caplog, mqtt_mock_entry_no_yaml_config ): """Test error when tilt_status_template is used and tilt_status_topic is missing.""" assert await async_setup_component( @@ -3108,6 +3240,7 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert ( f"'{CONF_TILT_STATUS_TEMPLATE}' must be set together with '{CONF_TILT_STATUS_TOPIC}'." @@ -3143,7 +3276,7 @@ async def test_tilt_status_template_without_tilt_status_topic_topic( ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -3158,7 +3291,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -3170,11 +3303,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = cover.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -3194,12 +3329,18 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, cover.DOMAIN, DEFAULT_CONFIG[cover.DOMAIN], diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 020fbad6166..34042105af2 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -11,7 +11,9 @@ from tests.common import async_fire_mqtt_message # Deprecated in HA Core 2022.6 -async def test_legacy_ensure_device_tracker_platform_validation(hass, mqtt_mock): +async def test_legacy_ensure_device_tracker_platform_validation( + hass, mqtt_mock_entry_with_yaml_config +): """Test if platform validation was done.""" async def mock_setup_scanner(hass, config, see, discovery_info=None): @@ -29,12 +31,17 @@ async def test_legacy_ensure_device_tracker_platform_validation(hass, mqtt_mock) assert await async_setup_component( hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}} ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert mock_sp.call_count == 1 # Deprecated in HA Core 2022.6 -async def test_legacy_new_message(hass, mock_device_tracker_conf, mqtt_mock): +async def test_legacy_new_message( + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config +): """Test new message.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" @@ -51,9 +58,10 @@ async def test_legacy_new_message(hass, mock_device_tracker_conf, mqtt_mock): # Deprecated in HA Core 2022.6 async def test_legacy_single_level_wildcard_topic( - hass, mock_device_tracker_conf, mqtt_mock + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config ): """Test single level wildcard topic.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/+/paulus" @@ -73,9 +81,10 @@ async def test_legacy_single_level_wildcard_topic( # Deprecated in HA Core 2022.6 async def test_legacy_multi_level_wildcard_topic( - hass, mock_device_tracker_conf, mqtt_mock + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config ): """Test multi level wildcard topic.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/#" @@ -95,9 +104,10 @@ async def test_legacy_multi_level_wildcard_topic( # Deprecated in HA Core 2022.6 async def test_legacy_single_level_wildcard_topic_not_matching( - hass, mock_device_tracker_conf, mqtt_mock + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config ): """Test not matching single level wildcard topic.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/+/paulus" @@ -117,9 +127,10 @@ async def test_legacy_single_level_wildcard_topic_not_matching( # Deprecated in HA Core 2022.6 async def test_legacy_multi_level_wildcard_topic_not_matching( - hass, mock_device_tracker_conf, mqtt_mock + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config ): """Test not matching multi level wildcard topic.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/#" @@ -139,9 +150,10 @@ async def test_legacy_multi_level_wildcard_topic_not_matching( # Deprecated in HA Core 2022.6 async def test_legacy_matching_custom_payload_for_home_and_not_home( - hass, mock_device_tracker_conf, mqtt_mock + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config ): """Test custom payload_home sets state to home and custom payload_not_home sets state to not_home.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" @@ -172,9 +184,10 @@ async def test_legacy_matching_custom_payload_for_home_and_not_home( # Deprecated in HA Core 2022.6 async def test_legacy_not_matching_custom_payload_for_home_and_not_home( - hass, mock_device_tracker_conf, mqtt_mock + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config ): """Test not matching payload does not set state to home or not_home.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" @@ -202,8 +215,11 @@ async def test_legacy_not_matching_custom_payload_for_home_and_not_home( # Deprecated in HA Core 2022.6 -async def test_legacy_matching_source_type(hass, mock_device_tracker_conf, mqtt_mock): +async def test_legacy_matching_source_type( + hass, mock_device_tracker_conf, mqtt_mock_entry_no_yaml_config +): """Test setting source type.""" + await mqtt_mock_entry_no_yaml_config() dev_id = "paulus" entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" diff --git a/tests/components/mqtt/test_device_tracker_discovery.py b/tests/components/mqtt/test_device_tracker_discovery.py index f8ee94b58f9..31853ad1dee 100644 --- a/tests/components/mqtt/test_device_tracker_discovery.py +++ b/tests/components/mqtt/test_device_tracker_discovery.py @@ -33,8 +33,9 @@ def entity_reg(hass): return mock_registry(hass) -async def test_discover_device_tracker(hass, mqtt_mock, caplog): +async def test_discover_device_tracker(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovering an MQTT device tracker component.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -50,8 +51,9 @@ async def test_discover_device_tracker(hass, mqtt_mock, caplog): @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -74,8 +76,11 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): assert state.name == "Beer" -async def test_non_duplicate_device_tracker_discovery(hass, mqtt_mock, caplog): +async def test_non_duplicate_device_tracker_discovery( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test for a non duplicate component.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -97,8 +102,9 @@ async def test_non_duplicate_device_tracker_discovery(hass, mqtt_mock, caplog): assert "Component has already been discovered: device_tracker bla" in caplog.text -async def test_device_tracker_removal(hass, mqtt_mock, caplog): +async def test_device_tracker_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of component through empty discovery message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -114,8 +120,9 @@ async def test_device_tracker_removal(hass, mqtt_mock, caplog): assert state is None -async def test_device_tracker_rediscover(hass, mqtt_mock, caplog): +async def test_device_tracker_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test rediscover of removed component.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -140,8 +147,11 @@ async def test_device_tracker_rediscover(hass, mqtt_mock, caplog): assert state is not None -async def test_duplicate_device_tracker_removal(hass, mqtt_mock, caplog): +async def test_duplicate_device_tracker_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test for a non duplicate component.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -160,8 +170,11 @@ async def test_duplicate_device_tracker_removal(hass, mqtt_mock, caplog): ) -async def test_device_tracker_discovery_update(hass, mqtt_mock, caplog): +async def test_device_tracker_discovery_update( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test for a discovery update event.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -186,10 +199,12 @@ async def test_device_tracker_discovery_update(hass, mqtt_mock, caplog): async def test_cleanup_device_tracker( - hass, hass_ws_client, device_reg, entity_reg, mqtt_mock + hass, hass_ws_client, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): """Test discovered device is cleaned up when removed from registry.""" assert await async_setup_component(hass, "config", {}) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() ws_client = await hass_ws_client(hass) async_fire_mqtt_message( @@ -242,8 +257,11 @@ async def test_cleanup_device_tracker( ) -async def test_setting_device_tracker_value_via_mqtt_message(hass, mqtt_mock, caplog): +async def test_setting_device_tracker_value_via_mqtt_message( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -266,9 +284,10 @@ async def test_setting_device_tracker_value_via_mqtt_message(hass, mqtt_mock, ca async def test_setting_device_tracker_value_via_mqtt_message_and_template( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_no_yaml_config, caplog ): """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -290,9 +309,10 @@ async def test_setting_device_tracker_value_via_mqtt_message_and_template( async def test_setting_device_tracker_value_via_mqtt_message_and_template2( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_no_yaml_config, caplog ): """Test the setting of the value via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -317,9 +337,10 @@ async def test_setting_device_tracker_value_via_mqtt_message_and_template2( async def test_setting_device_tracker_location_via_mqtt_message( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_no_yaml_config, caplog ): """Test the setting of the location via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -337,9 +358,10 @@ async def test_setting_device_tracker_location_via_mqtt_message( async def test_setting_device_tracker_location_via_lat_lon_message( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_no_yaml_config, caplog ): """Test the setting of the latitude and longitude via MQTT.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", @@ -391,8 +413,14 @@ async def test_setting_device_tracker_location_via_lat_lon_message( assert state.state == STATE_UNKNOWN -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, device_tracker.DOMAIN, DEFAULT_CONFIG, None + hass, + mqtt_mock_entry_no_yaml_config, + device_tracker.DOMAIN, + DEFAULT_CONFIG, + None, ) diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index ef5dd22692f..fe08c85a853 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -39,8 +39,11 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_get_triggers(hass, device_reg, entity_reg, mqtt_mock): +async def test_get_triggers( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test we get the expected triggers from a discovered mqtt device.""" + await mqtt_mock_entry_no_yaml_config() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -70,8 +73,11 @@ async def test_get_triggers(hass, device_reg, entity_reg, mqtt_mock): assert_lists_same(triggers, expected_triggers) -async def test_get_unknown_triggers(hass, device_reg, entity_reg, mqtt_mock): +async def test_get_unknown_triggers( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test we don't get unknown triggers.""" + await mqtt_mock_entry_no_yaml_config() # Discover a sensor (without device triggers) data1 = ( '{ "device":{"identifiers":["0AFFD2"]},' @@ -112,8 +118,11 @@ async def test_get_unknown_triggers(hass, device_reg, entity_reg, mqtt_mock): assert_lists_same(triggers, []) -async def test_get_non_existing_triggers(hass, device_reg, entity_reg, mqtt_mock): +async def test_get_non_existing_triggers( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test getting non existing triggers.""" + await mqtt_mock_entry_no_yaml_config() # Discover a sensor (without device triggers) data1 = ( '{ "device":{"identifiers":["0AFFD2"]},' @@ -131,8 +140,11 @@ async def test_get_non_existing_triggers(hass, device_reg, entity_reg, mqtt_mock @pytest.mark.no_fail_on_log_exception -async def test_discover_bad_triggers(hass, device_reg, entity_reg, mqtt_mock): +async def test_discover_bad_triggers( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test bad discovery message.""" + await mqtt_mock_entry_no_yaml_config() # Test sending bad data data0 = ( '{ "automation_type":"trigger",' @@ -176,8 +188,11 @@ async def test_discover_bad_triggers(hass, device_reg, entity_reg, mqtt_mock): assert_lists_same(triggers, expected_triggers) -async def test_update_remove_triggers(hass, device_reg, entity_reg, mqtt_mock): +async def test_update_remove_triggers( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test triggers can be updated and removed.""" + await mqtt_mock_entry_no_yaml_config() config1 = { "automation_type": "trigger", "device": {"identifiers": ["0AFFD2"]}, @@ -240,8 +255,11 @@ async def test_update_remove_triggers(hass, device_reg, entity_reg, mqtt_mock): assert device_entry is None -async def test_if_fires_on_mqtt_message(hass, device_reg, calls, mqtt_mock): +async def test_if_fires_on_mqtt_message( + hass, device_reg, calls, mqtt_mock_entry_no_yaml_config +): """Test triggers firing.""" + await mqtt_mock_entry_no_yaml_config() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -313,8 +331,11 @@ async def test_if_fires_on_mqtt_message(hass, device_reg, calls, mqtt_mock): assert calls[1].data["some"] == "long_press" -async def test_if_fires_on_mqtt_message_template(hass, device_reg, calls, mqtt_mock): +async def test_if_fires_on_mqtt_message_template( + hass, device_reg, calls, mqtt_mock_entry_no_yaml_config +): """Test triggers firing.""" + await mqtt_mock_entry_no_yaml_config() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -389,9 +410,10 @@ async def test_if_fires_on_mqtt_message_template(hass, device_reg, calls, mqtt_m async def test_if_fires_on_mqtt_message_late_discover( - hass, device_reg, calls, mqtt_mock + hass, device_reg, calls, mqtt_mock_entry_no_yaml_config ): """Test triggers firing of MQTT device triggers discovered after setup.""" + await mqtt_mock_entry_no_yaml_config() data0 = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -472,9 +494,10 @@ async def test_if_fires_on_mqtt_message_late_discover( async def test_if_fires_on_mqtt_message_after_update( - hass, device_reg, calls, mqtt_mock + hass, device_reg, calls, mqtt_mock_entry_no_yaml_config ): """Test triggers firing after update.""" + await mqtt_mock_entry_no_yaml_config() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -515,6 +538,7 @@ async def test_if_fires_on_mqtt_message_after_update( ] }, ) + await hass.async_block_till_done() # Fake short press. async_fire_mqtt_message(hass, "foobar/triggers/button1", "") @@ -546,8 +570,11 @@ async def test_if_fires_on_mqtt_message_after_update( assert len(calls) == 3 -async def test_no_resubscribe_same_topic(hass, device_reg, mqtt_mock): +async def test_no_resubscribe_same_topic( + hass, device_reg, mqtt_mock_entry_no_yaml_config +): """Test subscription to topics without change.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -589,9 +616,10 @@ async def test_no_resubscribe_same_topic(hass, device_reg, mqtt_mock): async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( - hass, device_reg, calls, mqtt_mock + hass, device_reg, calls, mqtt_mock_entry_no_yaml_config ): """Test triggers not firing after removal.""" + await mqtt_mock_entry_no_yaml_config() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -625,6 +653,7 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( ] }, ) + await hass.async_block_till_done() # Fake short press. async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") @@ -649,10 +678,13 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( async def test_not_fires_on_mqtt_message_after_remove_from_registry( - hass, hass_ws_client, device_reg, calls, mqtt_mock + hass, hass_ws_client, device_reg, calls, mqtt_mock_entry_no_yaml_config ): """Test triggers not firing after removal.""" assert await async_setup_component(hass, "config", {}) + await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() + ws_client = await hass_ws_client(hass) data1 = ( @@ -713,8 +745,9 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( assert len(calls) == 1 -async def test_attach_remove(hass, device_reg, mqtt_mock): +async def test_attach_remove(hass, device_reg, mqtt_mock_entry_no_yaml_config): """Test attach and removal of trigger.""" + await mqtt_mock_entry_no_yaml_config() data1 = ( '{ "automation_type":"trigger",' ' "device":{"identifiers":["0AFFD2"]},' @@ -766,8 +799,9 @@ async def test_attach_remove(hass, device_reg, mqtt_mock): assert len(calls) == 1 -async def test_attach_remove_late(hass, device_reg, mqtt_mock): +async def test_attach_remove_late(hass, device_reg, mqtt_mock_entry_no_yaml_config): """Test attach and removal of trigger .""" + await mqtt_mock_entry_no_yaml_config() data0 = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -827,8 +861,9 @@ async def test_attach_remove_late(hass, device_reg, mqtt_mock): assert len(calls) == 1 -async def test_attach_remove_late2(hass, device_reg, mqtt_mock): +async def test_attach_remove_late2(hass, device_reg, mqtt_mock_entry_no_yaml_config): """Test attach and removal of trigger .""" + await mqtt_mock_entry_no_yaml_config() data0 = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -882,8 +917,9 @@ async def test_attach_remove_late2(hass, device_reg, mqtt_mock): assert len(calls) == 0 -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT device registry integration.""" + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) data = json.dumps( @@ -915,8 +951,9 @@ async def test_entity_device_info_with_connection(hass, mqtt_mock): assert device.sw_version == "0.1-beta" -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT device registry integration.""" + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) data = json.dumps( @@ -946,8 +983,9 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == "0.1-beta" -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) config = { @@ -983,8 +1021,11 @@ async def test_entity_device_info_update(hass, mqtt_mock): assert device.name == "Milk" -async def test_cleanup_trigger(hass, hass_ws_client, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_trigger( + hass, hass_ws_client, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test trigger discovery topic is cleaned when device is removed from registry.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) @@ -1034,8 +1075,11 @@ async def test_cleanup_trigger(hass, hass_ws_client, device_reg, entity_reg, mqt ) -async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test removal from device registry when trigger is removed.""" + await mqtt_mock_entry_no_yaml_config() config = { "automation_type": "trigger", "topic": "test-topic", @@ -1065,8 +1109,11 @@ async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): assert device_entry is None -async def test_cleanup_device_several_triggers(hass, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device_several_triggers( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test removal from device registry when the last trigger is removed.""" + await mqtt_mock_entry_no_yaml_config() config1 = { "automation_type": "trigger", "topic": "test-topic", @@ -1122,11 +1169,14 @@ async def test_cleanup_device_several_triggers(hass, device_reg, entity_reg, mqt assert device_entry is None -async def test_cleanup_device_with_entity1(hass, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device_with_entity1( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test removal from device registry for device with entity. Trigger removed first, then entity. """ + await mqtt_mock_entry_no_yaml_config() config1 = { "automation_type": "trigger", "topic": "test-topic", @@ -1178,11 +1228,14 @@ async def test_cleanup_device_with_entity1(hass, device_reg, entity_reg, mqtt_mo assert device_entry is None -async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device_with_entity2( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test removal from device registry for device with entity. Entity removed first, then trigger. """ + await mqtt_mock_entry_no_yaml_config() config1 = { "automation_type": "trigger", "topic": "test-topic", @@ -1234,11 +1287,12 @@ async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mo assert device_entry is None -async def test_trigger_debug_info(hass, mqtt_mock): +async def test_trigger_debug_info(hass, mqtt_mock_entry_no_yaml_config): """Test debug_info. This is a test helper for MQTT debug_info. """ + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) config1 = { diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index bbd42a20c87..65399a22f70 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -37,8 +37,11 @@ def device_reg(hass): return mock_device_registry(hass) -async def test_entry_diagnostics(hass, device_reg, hass_client, mqtt_mock): +async def test_entry_diagnostics( + hass, device_reg, hass_client, mqtt_mock_entry_no_yaml_config +): """Test config entry diagnostics.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() config_entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] mqtt_mock.connected = True @@ -154,8 +157,11 @@ async def test_entry_diagnostics(hass, device_reg, hass_client, mqtt_mock): } ], ) -async def test_redact_diagnostics(hass, device_reg, hass_client, mqtt_mock): +async def test_redact_diagnostics( + hass, device_reg, hass_client, mqtt_mock_entry_no_yaml_config +): """Test redacting diagnostics.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() expected_config = dict(default_config) expected_config["password"] = "**REDACTED**" expected_config["username"] = "**REDACTED**" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 9215ab651b2..df20dc031d0 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -48,8 +48,9 @@ def entity_reg(hass): "mqtt_config", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], ) -async def test_subscribing_config_topic(hass, mqtt_mock): +async def test_subscribing_config_topic(hass, mqtt_mock_entry_no_yaml_config): """Test setting up discovery.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] discovery_topic = "homeassistant" @@ -71,8 +72,9 @@ async def test_subscribing_config_topic(hass, mqtt_mock): ("homeassistant/binary_sensor/rörkrökare/config", True), ], ) -async def test_invalid_topic(hass, mqtt_mock, caplog, topic, log): +async def test_invalid_topic(hass, mqtt_mock_entry_no_yaml_config, caplog, topic, log): """Test sending to invalid topic.""" + await mqtt_mock_entry_no_yaml_config() with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: @@ -90,8 +92,9 @@ async def test_invalid_topic(hass, mqtt_mock, caplog, topic, log): caplog.clear() -async def test_invalid_json(hass, mqtt_mock, caplog): +async def test_invalid_json(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in invalid JSON.""" + await mqtt_mock_entry_no_yaml_config() with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: @@ -106,8 +109,9 @@ async def test_invalid_json(hass, mqtt_mock, caplog): assert not mock_dispatcher_send.called -async def test_only_valid_components(hass, mqtt_mock, caplog): +async def test_only_valid_components(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test for a valid component.""" + await mqtt_mock_entry_no_yaml_config() with patch( "homeassistant.components.mqtt.discovery.async_dispatcher_send" ) as mock_dispatcher_send: @@ -127,8 +131,9 @@ async def test_only_valid_components(hass, mqtt_mock, caplog): assert not mock_dispatcher_send.called -async def test_correct_config_discovery(hass, mqtt_mock, caplog): +async def test_correct_config_discovery(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in correct JSON.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -143,8 +148,9 @@ async def test_correct_config_discovery(hass, mqtt_mock, caplog): assert ("binary_sensor", "bla") in hass.data[ALREADY_DISCOVERED] -async def test_discover_fan(hass, mqtt_mock, caplog): +async def test_discover_fan(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovering an MQTT fan.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/fan/bla/config", @@ -159,8 +165,9 @@ async def test_discover_fan(hass, mqtt_mock, caplog): assert ("fan", "bla") in hass.data[ALREADY_DISCOVERED] -async def test_discover_climate(hass, mqtt_mock, caplog): +async def test_discover_climate(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovering an MQTT climate component.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "name": "ClimateTest",' ' "current_temperature_topic": "climate/bla/current_temp",' @@ -177,8 +184,11 @@ async def test_discover_climate(hass, mqtt_mock, caplog): assert ("climate", "bla") in hass.data[ALREADY_DISCOVERED] -async def test_discover_alarm_control_panel(hass, mqtt_mock, caplog): +async def test_discover_alarm_control_panel( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test discovering an MQTT alarm control panel component.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "name": "AlarmControlPanelTest",' ' "state_topic": "test_topic",' @@ -341,9 +351,10 @@ async def test_discover_alarm_control_panel(hass, mqtt_mock, caplog): ], ) async def test_discovery_with_object_id( - hass, mqtt_mock, caplog, topic, config, entity_id, name, domain + hass, mqtt_mock_entry_no_yaml_config, caplog, topic, config, entity_id, name, domain ): """Test discovering an MQTT entity with object_id.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message(hass, topic, config) await hass.async_block_till_done() @@ -354,8 +365,9 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data[ALREADY_DISCOVERED] -async def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): +async def test_discovery_incl_nodeid(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in correct JSON with optional node_id included.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/my_node_id/bla/config", @@ -370,8 +382,9 @@ async def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): assert ("binary_sensor", "my_node_id bla") in hass.data[ALREADY_DISCOVERED] -async def test_non_duplicate_discovery(hass, mqtt_mock, caplog): +async def test_non_duplicate_discovery(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test for a non duplicate component.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -393,8 +406,9 @@ async def test_non_duplicate_discovery(hass, mqtt_mock, caplog): assert "Component has already been discovered: binary_sensor bla" in caplog.text -async def test_removal(hass, mqtt_mock, caplog): +async def test_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of component through empty discovery message.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -410,8 +424,9 @@ async def test_removal(hass, mqtt_mock, caplog): assert state is None -async def test_rediscover(hass, mqtt_mock, caplog): +async def test_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test rediscover of removed component.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -436,9 +451,9 @@ async def test_rediscover(hass, mqtt_mock, caplog): assert state is not None -async def test_rapid_rediscover(hass, mqtt_mock, caplog): +async def test_rapid_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate rediscover of removed component.""" - + await mqtt_mock_entry_no_yaml_config() events = async_capture_events(hass, EVENT_STATE_CHANGED) async_fire_mqtt_message( @@ -485,9 +500,9 @@ async def test_rapid_rediscover(hass, mqtt_mock, caplog): assert events[4].data["old_state"] is None -async def test_rapid_rediscover_unique(hass, mqtt_mock, caplog): +async def test_rapid_rediscover_unique(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate rediscover of removed component.""" - + await mqtt_mock_entry_no_yaml_config() events = [] @ha.callback @@ -544,9 +559,9 @@ async def test_rapid_rediscover_unique(hass, mqtt_mock, caplog): assert events[3].data["old_state"] is None -async def test_rapid_reconfigure(hass, mqtt_mock, caplog): +async def test_rapid_reconfigure(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate reconfigure of added component.""" - + await mqtt_mock_entry_no_yaml_config() events = [] @ha.callback @@ -596,8 +611,9 @@ async def test_rapid_reconfigure(hass, mqtt_mock, caplog): assert events[2].data["new_state"].attributes["friendly_name"] == "Wine" -async def test_duplicate_removal(hass, mqtt_mock, caplog): +async def test_duplicate_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test for a non duplicate component.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, "homeassistant/binary_sensor/bla/config", @@ -614,8 +630,11 @@ async def test_duplicate_removal(hass, mqtt_mock, caplog): assert "Component has already been discovered: binary_sensor bla" not in caplog.text -async def test_cleanup_device(hass, hass_ws_client, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device( + hass, hass_ws_client, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test discvered device is cleaned up when entry removed from device.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) @@ -669,8 +688,11 @@ async def test_cleanup_device(hass, hass_ws_client, device_reg, entity_reg, mqtt ) -async def test_cleanup_device_mqtt(hass, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device_mqtt( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test discvered device is cleaned up when removed through MQTT.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() data = ( '{ "device":{"identifiers":["0AFFD2"]},' ' "state_topic": "foobar/sensor",' @@ -709,10 +731,12 @@ async def test_cleanup_device_mqtt(hass, device_reg, entity_reg, mqtt_mock): async def test_cleanup_device_multiple_config_entries( - hass, hass_ws_client, device_reg, entity_reg, mqtt_mock + hass, hass_ws_client, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): """Test discovered device is cleaned up when entry removed from device.""" assert await async_setup_component(hass, "config", {}) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() ws_client = await hass_ws_client(hass) config_entry = MockConfigEntry(domain="test", data={}) @@ -804,9 +828,10 @@ async def test_cleanup_device_multiple_config_entries( async def test_cleanup_device_multiple_config_entries_mqtt( - hass, device_reg, entity_reg, mqtt_mock + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): """Test discovered device is cleaned up when removed through MQTT.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() config_entry = MockConfigEntry(domain="test", data={}) config_entry.add_to_hass(hass) device_entry = device_reg.async_get_or_create( @@ -880,8 +905,9 @@ async def test_cleanup_device_multiple_config_entries_mqtt( mqtt_mock.async_publish.assert_not_called() -async def test_discovery_expansion(hass, mqtt_mock, caplog): +async def test_discovery_expansion(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of abbreviated discovery payload.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -937,8 +963,9 @@ async def test_discovery_expansion(hass, mqtt_mock, caplog): assert state.state == STATE_UNAVAILABLE -async def test_discovery_expansion_2(hass, mqtt_mock, caplog): +async def test_discovery_expansion_2(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of abbreviated discovery payload.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -977,8 +1004,9 @@ async def test_discovery_expansion_2(hass, mqtt_mock, caplog): @pytest.mark.no_fail_on_log_exception -async def test_discovery_expansion_3(hass, mqtt_mock, caplog): +async def test_discovery_expansion_3(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of broken discovery payload.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1008,9 +1036,10 @@ async def test_discovery_expansion_3(hass, mqtt_mock, caplog): async def test_discovery_expansion_without_encoding_and_value_template_1( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_no_yaml_config, caplog ): """Test expansion of raw availability payload with a template as list.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1056,9 +1085,10 @@ async def test_discovery_expansion_without_encoding_and_value_template_1( async def test_discovery_expansion_without_encoding_and_value_template_2( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_no_yaml_config, caplog ): """Test expansion of raw availability payload with a template directly.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "~": "some/base/topic",' ' "name": "DiscoveryExpansionTest1",' @@ -1133,8 +1163,11 @@ ABBREVIATIONS_WHITE_LIST = [ ] -async def test_missing_discover_abbreviations(hass, mqtt_mock, caplog): +async def test_missing_discover_abbreviations( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Check MQTT platforms for missing abbreviations.""" + await mqtt_mock_entry_no_yaml_config() missing = [] regex = re.compile(r"(CONF_[a-zA-Z\d_]*) *= *[\'\"]([a-zA-Z\d_]*)[\'\"]") for fil in Path(mqtt.__file__).parent.rglob("*.py"): @@ -1157,8 +1190,11 @@ async def test_missing_discover_abbreviations(hass, mqtt_mock, caplog): assert not missing -async def test_no_implicit_state_topic_switch(hass, mqtt_mock, caplog): +async def test_no_implicit_state_topic_switch( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test no implicit state topic for switch.""" + await mqtt_mock_entry_no_yaml_config() data = '{ "name": "Test1",' ' "command_topic": "cmnd"' "}" async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) @@ -1187,8 +1223,11 @@ async def test_no_implicit_state_topic_switch(hass, mqtt_mock, caplog): } ], ) -async def test_complex_discovery_topic_prefix(hass, mqtt_mock, caplog): +async def test_complex_discovery_topic_prefix( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Tests handling of discovery topic prefix with multiple slashes.""" + await mqtt_mock_entry_no_yaml_config() async_fire_mqtt_message( hass, ("my_home/homeassistant/register/binary_sensor/node1/object1/config"), @@ -1204,9 +1243,10 @@ async def test_complex_discovery_topic_prefix(hass, mqtt_mock, caplog): async def test_mqtt_integration_discovery_subscribe_unsubscribe( - hass, mqtt_client_mock, mqtt_mock + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): """Check MQTT integration discovery subscribe and unsubscribe.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() mock_entity_platform(hass, "config_flow.comp", None) entry = hass.config_entries.async_entries("mqtt")[0] @@ -1243,8 +1283,11 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( assert not mqtt_client_mock.unsubscribe.called -async def test_mqtt_discovery_unsubscribe_once(hass, mqtt_client_mock, mqtt_mock): +async def test_mqtt_discovery_unsubscribe_once( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """Check MQTT integration discovery unsubscribe once.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() mock_entity_platform(hass, "config_flow.comp", None) entry = hass.config_entries.async_entries("mqtt")[0] diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 1a533db63c0..145edf5ac7d 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -74,16 +74,25 @@ DEFAULT_CONFIG = { } -async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): +async def test_fail_setup_if_no_command_topic( + hass, caplog, mqtt_mock_entry_no_yaml_config +): """Test if command fails with command topic.""" assert await async_setup_component( hass, fan.DOMAIN, {fan.DOMAIN: {"platform": "mqtt", "name": "test"}} ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("fan.test") is None + assert ( + "Invalid config for [fan.mqtt]: required key not provided @ data['command_topic']" + in caplog.text + ) -async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): +async def test_controlling_state_via_topic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -120,6 +129,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -202,7 +212,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): async def test_controlling_state_via_topic_with_different_speed_range( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test the controlling state via topic using an alternate speed range.""" assert await async_setup_component( @@ -241,6 +251,7 @@ async def test_controlling_state_via_topic_with_different_speed_range( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "percentage-state-topic1", "100") state = hass.states.get("fan.test1") @@ -264,7 +275,7 @@ async def test_controlling_state_via_topic_with_different_speed_range( async def test_controlling_state_via_topic_no_percentage_topics( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test the controlling state via topic without percentage topics.""" assert await async_setup_component( @@ -289,6 +300,7 @@ async def test_controlling_state_via_topic_no_percentage_topics( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -318,7 +330,9 @@ async def test_controlling_state_via_topic_no_percentage_topics( caplog.clear() -async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, caplog): +async def test_controlling_state_via_topic_and_json_message( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the controlling state via topic and JSON message (percentage mode).""" assert await async_setup_component( hass, @@ -353,6 +367,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, cap }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -421,7 +436,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, cap async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test the controlling state via topic and JSON message using a shared topic.""" assert await async_setup_component( @@ -457,6 +472,7 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -509,7 +525,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test optimistic mode without state topic.""" assert await async_setup_component( hass, @@ -535,6 +553,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -630,7 +649,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_with_alternate_speed_range(hass, mqtt_mock): +async def test_sending_mqtt_commands_with_alternate_speed_range( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling state via topic using an alternate speed range.""" assert await async_setup_component( hass, @@ -668,6 +689,7 @@ async def test_sending_mqtt_commands_with_alternate_speed_range(hass, mqtt_mock) }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_set_percentage(hass, "fan.test1", 0) mqtt_mock.async_publish.assert_called_once_with( @@ -734,7 +756,9 @@ async def test_sending_mqtt_commands_with_alternate_speed_range(hass, mqtt_mock) assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_optimistic_no_legacy(hass, mqtt_mock, caplog): +async def test_sending_mqtt_commands_and_optimistic_no_legacy( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test optimistic mode without state topic without legacy speed command topic.""" assert await async_setup_component( hass, @@ -755,6 +779,7 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy(hass, mqtt_mock, c }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -862,7 +887,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_legacy(hass, mqtt_mock, c await common.async_turn_on(hass, "fan.test", preset_mode="freaking-high") -async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): +async def test_sending_mqtt_command_templates_( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test optimistic mode without state topic without legacy speed command topic.""" assert await async_setup_component( hass, @@ -888,6 +915,7 @@ async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1002,7 +1030,7 @@ async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test optimistic mode without state topic without percentage command topic.""" assert await async_setup_component( @@ -1025,6 +1053,7 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1061,7 +1090,9 @@ async def test_sending_mqtt_commands_and_optimistic_no_percentage_topic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, caplog): +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test optimistic mode with state topic and turn on attributes.""" assert await async_setup_component( hass, @@ -1088,6 +1119,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1305,7 +1337,13 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[fan.DOMAIN]) @@ -1315,7 +1353,7 @@ async def test_encoding_subscribable_topics( config[CONF_OSCILLATION_COMMAND_TOPIC] = "fan/some_oscillation_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, fan.DOMAIN, config, @@ -1326,7 +1364,7 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes(hass, mqtt_mock, caplog): +async def test_attributes(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test attributes.""" assert await async_setup_component( hass, @@ -1347,6 +1385,7 @@ async def test_attributes(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test") assert state.state == STATE_UNKNOWN @@ -1376,7 +1415,7 @@ async def test_attributes(hass, mqtt_mock, caplog): assert state.attributes.get(fan.ATTR_OSCILLATING) is False -async def test_supported_features(hass, mqtt_mock): +async def test_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test optimistic mode without state topic.""" assert await async_setup_component( hass, @@ -1498,6 +1537,7 @@ async def test_supported_features(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("fan.test1") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 @@ -1548,77 +1588,103 @@ async def test_supported_features(hass, mqtt_mock): assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == fan.SUPPORT_PRESET_MODE -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + fan.DOMAIN, + DEFAULT_CONFIG, + True, + "state-topic", + "1", ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + fan.DOMAIN, + DEFAULT_CONFIG, + True, + "state-topic", + "1", ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG, MQTT_FAN_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + fan.DOMAIN, + DEFAULT_CONFIG, + MQTT_FAN_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique_id option only creates one fan per id.""" config = { fan.DOMAIN: [ @@ -1638,89 +1704,107 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, fan.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, config + ) -async def test_discovery_removal_fan(hass, mqtt_mock, caplog): +async def test_discovery_removal_fan(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered fan.""" data = '{ "name": "test", "command_topic": "test_topic" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, fan.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, data + ) -async def test_discovery_update_fan(hass, mqtt_mock, caplog): +async def test_discovery_update_fan(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered fan.""" config1 = {"name": "Beer", "command_topic": "test_topic"} config2 = {"name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock, caplog, fan.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_fan(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_fan( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered fan.""" data1 = '{ "name": "Beer", "command_topic": "test_topic" }' with patch( "homeassistant.components.mqtt.fan.MqttFan.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, fan.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + fan.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk", "command_topic": "test_topic" }' - await help_test_discovery_broken(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) + + await help_test_discovery_broken( + hass, mqtt_mock_entry_no_yaml_config, caplog, fan.DOMAIN, data1, data2 + ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, fan.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG, fan.SERVICE_TURN_ON + hass, + mqtt_mock_entry_no_yaml_config, + fan.DOMAIN, + DEFAULT_CONFIG, + fan.SERVICE_TURN_ON, ) @@ -1766,7 +1850,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -1782,7 +1866,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -1794,11 +1878,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = fan.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index bc00d3afffb..ea9a6edf0e3 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -116,7 +116,7 @@ async def async_set_humidity( await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True) -async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): +async def test_fail_setup_if_no_command_topic(hass, mqtt_mock_entry_no_yaml_config): """Test if command fails with command topic.""" assert await async_setup_component( hass, @@ -124,10 +124,13 @@ async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): {humidifier.DOMAIN: {"platform": "mqtt", "name": "test"}}, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("humidifier.test") is None -async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): +async def test_controlling_state_via_topic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -158,6 +161,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -228,7 +232,9 @@ async def test_controlling_state_via_topic(hass, mqtt_mock, caplog): assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, caplog): +async def test_controlling_state_via_topic_and_json_message( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the controlling state via topic and JSON message.""" assert await async_setup_component( hass, @@ -255,6 +261,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, cap }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -314,7 +321,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, cap async def test_controlling_state_via_topic_and_json_message_shared_topic( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test the controlling state via topic and JSON message using a shared topic.""" assert await async_setup_component( @@ -342,6 +349,7 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -390,7 +398,9 @@ async def test_controlling_state_via_topic_and_json_message_shared_topic( caplog.clear() -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test optimistic mode without state topic.""" assert await async_setup_component( hass, @@ -413,6 +423,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -483,7 +494,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock, caplog): assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): +async def test_sending_mqtt_command_templates_( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Testing command templates with optimistic mode without state topic.""" assert await async_setup_component( hass, @@ -507,6 +520,7 @@ async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -577,7 +591,9 @@ async def test_sending_mqtt_command_templates_(hass, mqtt_mock, caplog): assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, caplog): +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test optimistic mode with state topic and turn on attributes.""" assert await async_setup_component( hass, @@ -602,6 +618,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -701,7 +718,13 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock, ca ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[humidifier.DOMAIN]) @@ -709,7 +732,7 @@ async def test_encoding_subscribable_topics( config[CONF_MODE_COMMAND_TOPIC] = "humidifier/some_mode_command_topic" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, humidifier.DOMAIN, config, @@ -720,7 +743,7 @@ async def test_encoding_subscribable_topics( ) -async def test_attributes(hass, mqtt_mock, caplog): +async def test_attributes(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test attributes.""" assert await async_setup_component( hass, @@ -740,6 +763,7 @@ async def test_attributes(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test") assert state.state == STATE_UNKNOWN @@ -765,7 +789,7 @@ async def test_attributes(hass, mqtt_mock, caplog): assert state.attributes.get(humidifier.ATTR_MODE) is None -async def test_invalid_configurations(hass, mqtt_mock, caplog): +async def test_invalid_configurations(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test invalid configurations.""" assert await async_setup_component( hass, @@ -834,6 +858,7 @@ async def test_invalid_configurations(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert hass.states.get("humidifier.test_valid_1") is not None assert hass.states.get("humidifier.test_valid_2") is not None assert hass.states.get("humidifier.test_valid_3") is not None @@ -847,7 +872,7 @@ async def test_invalid_configurations(hass, mqtt_mock, caplog): assert hass.states.get("humidifier.test_invalid_mode_is_reset") is None -async def test_supported_features(hass, mqtt_mock): +async def test_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test supported features.""" assert await async_setup_component( hass, @@ -896,6 +921,7 @@ async def test_supported_features(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("humidifier.test1") assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 @@ -916,81 +942,111 @@ async def test_supported_features(hass, mqtt_mock): assert state is None -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + humidifier.DOMAIN, + DEFAULT_CONFIG, + True, + "state-topic", + "1", ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + humidifier.DOMAIN, + DEFAULT_CONFIG, + True, + "state-topic", + "1", ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG, MQTT_HUMIDIFIER_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, humidifier.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + humidifier.DOMAIN, + DEFAULT_CONFIG, ) -async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, humidifier.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + humidifier.DOMAIN, + DEFAULT_CONFIG, ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique_id option only creates one fan per id.""" config = { humidifier.DOMAIN: [ @@ -1012,16 +1068,24 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, humidifier.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, config + ) -async def test_discovery_removal_humidifier(hass, mqtt_mock, caplog): +async def test_discovery_removal_humidifier( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test removal of discovered humidifier.""" data = '{ "name": "test", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, humidifier.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, data + ) -async def test_discovery_update_humidifier(hass, mqtt_mock, caplog): +async def test_discovery_update_humidifier( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered humidifier.""" config1 = { "name": "Beer", @@ -1034,77 +1098,93 @@ async def test_discovery_update_humidifier(hass, mqtt_mock, caplog): "target_humidity_command_topic": "test-topic2", } await help_test_discovery_update( - hass, mqtt_mock, caplog, humidifier.DOMAIN, config1, config2 + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + humidifier.DOMAIN, + config1, + config2, ) -async def test_discovery_update_unchanged_humidifier(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_humidifier( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered humidifier.""" data1 = '{ "name": "Beer", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' with patch( "homeassistant.components.mqtt.fan.MqttFan.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, humidifier.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + humidifier.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk", "command_topic": "test_topic", "target_humidity_command_topic": "test-topic2" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, humidifier.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, humidifier.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT fan device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, humidifier.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, humidifier.DOMAIN, DEFAULT_CONFIG, humidifier.SERVICE_TURN_ON + hass, + mqtt_mock_entry_no_yaml_config, + humidifier.DOMAIN, + DEFAULT_CONFIG, + humidifier.SERVICE_TURN_ON, ) @@ -1143,7 +1223,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -1159,7 +1239,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -1171,11 +1251,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = humidifier.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index aa0bfb82608..861b50d2f71 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -96,22 +96,27 @@ def record_calls(calls): async def test_mqtt_connects_on_home_assistant_mqtt_setup( - hass, mqtt_client_mock, mqtt_mock + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): """Test if client is connected after mqtt init on bootstrap.""" + await mqtt_mock_entry_no_yaml_config() assert mqtt_client_mock.connect.call_count == 1 -async def test_mqtt_disconnects_on_home_assistant_stop(hass, mqtt_mock): +async def test_mqtt_disconnects_on_home_assistant_stop( + hass, mqtt_mock_entry_no_yaml_config +): """Test if client stops on HA stop.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() hass.bus.fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() await hass.async_block_till_done() assert mqtt_mock.async_disconnect.called -async def test_publish(hass, mqtt_mock): +async def test_publish(hass, mqtt_mock_entry_no_yaml_config): """Test the publish function.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await mqtt.async_publish(hass, "test-topic", "test-payload") await hass.async_block_till_done() assert mqtt_mock.async_publish.called @@ -208,7 +213,7 @@ async def test_command_template_value(hass): assert cmd_tpl.async_render(None, variables=variables) == "beer" -async def test_command_template_variables(hass, mqtt_mock): +async def test_command_template_variables(hass, mqtt_mock_entry_with_yaml_config): """Test the rendering of enitity_variables.""" topic = "test/select" @@ -232,6 +237,7 @@ async def test_command_template_variables(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -291,8 +297,11 @@ async def test_value_template_value(hass): assert val_tpl.async_render_with_possible_json_value('{"id": 4321}') == "4321" -async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): +async def test_service_call_without_topic_does_not_publish( + hass, mqtt_mock_entry_no_yaml_config +): """Test the service call if topic is missing.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() with pytest.raises(vol.Invalid): await hass.services.async_call( mqtt.DOMAIN, @@ -304,12 +313,13 @@ async def test_service_call_without_topic_does_not_publish(hass, mqtt_mock): async def test_service_call_with_topic_and_topic_template_does_not_publish( - hass, mqtt_mock + hass, mqtt_mock_entry_no_yaml_config ): """Test the service call with topic/topic template. If both 'topic' and 'topic_template' are provided then fail. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() topic = "test/topic" topic_template = "test/{{ 'topic' }}" with pytest.raises(vol.Invalid): @@ -327,9 +337,10 @@ async def test_service_call_with_topic_and_topic_template_does_not_publish( async def test_service_call_with_invalid_topic_template_does_not_publish( - hass, mqtt_mock + hass, mqtt_mock_entry_no_yaml_config ): """Test the service call with a problematic topic template.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -342,11 +353,14 @@ async def test_service_call_with_invalid_topic_template_does_not_publish( assert not mqtt_mock.async_publish.called -async def test_service_call_with_template_topic_renders_template(hass, mqtt_mock): +async def test_service_call_with_template_topic_renders_template( + hass, mqtt_mock_entry_no_yaml_config +): """Test the service call with rendered topic template. If 'topic_template' is provided and 'topic' is not, then render it. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -360,11 +374,14 @@ async def test_service_call_with_template_topic_renders_template(hass, mqtt_mock assert mqtt_mock.async_publish.call_args[0][0] == "test/2" -async def test_service_call_with_template_topic_renders_invalid_topic(hass, mqtt_mock): +async def test_service_call_with_template_topic_renders_invalid_topic( + hass, mqtt_mock_entry_no_yaml_config +): """Test the service call with rendered, invalid topic template. If a wildcard topic is rendered, then fail. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -378,12 +395,13 @@ async def test_service_call_with_template_topic_renders_invalid_topic(hass, mqtt async def test_service_call_with_invalid_rendered_template_topic_doesnt_render_template( - hass, mqtt_mock + hass, mqtt_mock_entry_no_yaml_config ): """Test the service call with unrendered template. If both 'payload' and 'payload_template' are provided then fail. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() payload = "not a template" payload_template = "a template" with pytest.raises(vol.Invalid): @@ -400,11 +418,14 @@ async def test_service_call_with_invalid_rendered_template_topic_doesnt_render_t assert not mqtt_mock.async_publish.called -async def test_service_call_with_template_payload_renders_template(hass, mqtt_mock): +async def test_service_call_with_template_payload_renders_template( + hass, mqtt_mock_entry_no_yaml_config +): """Test the service call with rendered template. If 'payload_template' is provided and 'payload' is not, then render it. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -429,8 +450,9 @@ async def test_service_call_with_template_payload_renders_template(hass, mqtt_mo mqtt_mock.reset_mock() -async def test_service_call_with_bad_template(hass, mqtt_mock): +async def test_service_call_with_bad_template(hass, mqtt_mock_entry_no_yaml_config): """Test the service call with a bad template does not publish.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -440,11 +462,14 @@ async def test_service_call_with_bad_template(hass, mqtt_mock): assert not mqtt_mock.async_publish.called -async def test_service_call_with_payload_doesnt_render_template(hass, mqtt_mock): +async def test_service_call_with_payload_doesnt_render_template( + hass, mqtt_mock_entry_no_yaml_config +): """Test the service call with unrendered template. If both 'payload' and 'payload_template' are provided then fail. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() payload = "not a template" payload_template = "a template" with pytest.raises(vol.Invalid): @@ -461,11 +486,14 @@ async def test_service_call_with_payload_doesnt_render_template(hass, mqtt_mock) assert not mqtt_mock.async_publish.called -async def test_service_call_with_ascii_qos_retain_flags(hass, mqtt_mock): +async def test_service_call_with_ascii_qos_retain_flags( + hass, mqtt_mock_entry_no_yaml_config +): """Test the service call with args that can be misinterpreted. Empty payload message and ascii formatted qos and retain flags. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() await hass.services.async_call( mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, @@ -665,9 +693,10 @@ def test_entity_device_info_schema(): async def test_receiving_non_utf8_message_gets_logged( - hass, mqtt_mock, calls, record_calls, caplog + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls, caplog ): """Test receiving a non utf8 encoded message.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic", record_calls) async_fire_mqtt_message(hass, "test-topic", b"\x9a") @@ -679,9 +708,10 @@ async def test_receiving_non_utf8_message_gets_logged( async def test_all_subscriptions_run_when_decode_fails( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test all other subscriptions still run when decode fails for one.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic", record_calls, encoding="ascii") await mqtt.async_subscribe(hass, "test-topic", record_calls) @@ -691,8 +721,11 @@ async def test_all_subscriptions_run_when_decode_fails( assert len(calls) == 1 -async def test_subscribe_topic(hass, mqtt_mock, calls, record_calls): +async def test_subscribe_topic( + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls +): """Test the subscription of a topic.""" + await mqtt_mock_entry_no_yaml_config() unsub = await mqtt.async_subscribe(hass, "test-topic", record_calls) async_fire_mqtt_message(hass, "test-topic", "test-payload") @@ -714,8 +747,11 @@ async def test_subscribe_topic(hass, mqtt_mock, calls, record_calls): unsub() -async def test_subscribe_topic_non_async(hass, mqtt_mock, calls, record_calls): +async def test_subscribe_topic_non_async( + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls +): """Test the subscription of a topic using the non-async function.""" + await mqtt_mock_entry_no_yaml_config() unsub = await hass.async_add_executor_job( mqtt.subscribe, hass, "test-topic", record_calls ) @@ -736,14 +772,18 @@ async def test_subscribe_topic_non_async(hass, mqtt_mock, calls, record_calls): assert len(calls) == 1 -async def test_subscribe_bad_topic(hass, mqtt_mock, calls, record_calls): +async def test_subscribe_bad_topic( + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls +): """Test the subscription of a topic.""" + await mqtt_mock_entry_no_yaml_config() with pytest.raises(HomeAssistantError): await mqtt.async_subscribe(hass, 55, record_calls) -async def test_subscribe_deprecated(hass, mqtt_mock): +async def test_subscribe_deprecated(hass, mqtt_mock_entry_no_yaml_config): """Test the subscription of a topic using deprecated callback signature.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() @callback def record_calls(topic, payload, qos): @@ -789,8 +829,9 @@ async def test_subscribe_deprecated(hass, mqtt_mock): assert len(calls) == 1 -async def test_subscribe_deprecated_async(hass, mqtt_mock): +async def test_subscribe_deprecated_async(hass, mqtt_mock_entry_no_yaml_config): """Test the subscription of a topic using deprecated coroutine signature.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() def async_record_calls(topic, payload, qos): """Record calls.""" @@ -835,8 +876,11 @@ async def test_subscribe_deprecated_async(hass, mqtt_mock): assert len(calls) == 1 -async def test_subscribe_topic_not_match(hass, mqtt_mock, calls, record_calls): +async def test_subscribe_topic_not_match( + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls +): """Test if subscribed topic is not a match.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic", record_calls) async_fire_mqtt_message(hass, "another-test-topic", "test-payload") @@ -845,8 +889,11 @@ async def test_subscribe_topic_not_match(hass, mqtt_mock, calls, record_calls): assert len(calls) == 0 -async def test_subscribe_topic_level_wildcard(hass, mqtt_mock, calls, record_calls): +async def test_subscribe_topic_level_wildcard( + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls +): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic/+/on", record_calls) async_fire_mqtt_message(hass, "test-topic/bier/on", "test-payload") @@ -858,9 +905,10 @@ async def test_subscribe_topic_level_wildcard(hass, mqtt_mock, calls, record_cal async def test_subscribe_topic_level_wildcard_no_subtree_match( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic/+/on", record_calls) async_fire_mqtt_message(hass, "test-topic/bier", "test-payload") @@ -870,9 +918,10 @@ async def test_subscribe_topic_level_wildcard_no_subtree_match( async def test_subscribe_topic_level_wildcard_root_topic_no_subtree_match( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "test-topic-123", "test-payload") @@ -882,9 +931,10 @@ async def test_subscribe_topic_level_wildcard_root_topic_no_subtree_match( async def test_subscribe_topic_subtree_wildcard_subtree_topic( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "test-topic/bier/on", "test-payload") @@ -896,9 +946,10 @@ async def test_subscribe_topic_subtree_wildcard_subtree_topic( async def test_subscribe_topic_subtree_wildcard_root_topic( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "test-topic", "test-payload") @@ -910,9 +961,10 @@ async def test_subscribe_topic_subtree_wildcard_root_topic( async def test_subscribe_topic_subtree_wildcard_no_match( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "test-topic/#", record_calls) async_fire_mqtt_message(hass, "another-test-topic", "test-payload") @@ -922,9 +974,10 @@ async def test_subscribe_topic_subtree_wildcard_no_match( async def test_subscribe_topic_level_wildcard_and_wildcard_root_topic( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/test-topic", "test-payload") @@ -936,9 +989,10 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_root_topic( async def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/test-topic/here-iam", "test-payload") @@ -950,9 +1004,10 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic( async def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/here-iam/test-topic", "test-payload") @@ -962,9 +1017,10 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match( async def test_subscribe_topic_level_wildcard_and_wildcard_no_match( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "+/test-topic/#", record_calls) async_fire_mqtt_message(hass, "hi/another-test-topic", "test-payload") @@ -973,8 +1029,11 @@ async def test_subscribe_topic_level_wildcard_and_wildcard_no_match( assert len(calls) == 0 -async def test_subscribe_topic_sys_root(hass, mqtt_mock, calls, record_calls): +async def test_subscribe_topic_sys_root( + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls +): """Test the subscription of $ root topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "$test-topic/subtree/on", record_calls) async_fire_mqtt_message(hass, "$test-topic/subtree/on", "test-payload") @@ -986,9 +1045,10 @@ async def test_subscribe_topic_sys_root(hass, mqtt_mock, calls, record_calls): async def test_subscribe_topic_sys_root_and_wildcard_topic( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of $ root and wildcard topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "$test-topic/#", record_calls) async_fire_mqtt_message(hass, "$test-topic/some-topic", "test-payload") @@ -1000,9 +1060,10 @@ async def test_subscribe_topic_sys_root_and_wildcard_topic( async def test_subscribe_topic_sys_root_and_wildcard_subtree_topic( - hass, mqtt_mock, calls, record_calls + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls ): """Test the subscription of $ root and wildcard subtree topics.""" + await mqtt_mock_entry_no_yaml_config() await mqtt.async_subscribe(hass, "$test-topic/subtree/#", record_calls) async_fire_mqtt_message(hass, "$test-topic/subtree/some-topic", "test-payload") @@ -1013,8 +1074,11 @@ async def test_subscribe_topic_sys_root_and_wildcard_subtree_topic( assert calls[0][0].payload == "test-payload" -async def test_subscribe_special_characters(hass, mqtt_mock, calls, record_calls): +async def test_subscribe_special_characters( + hass, mqtt_mock_entry_no_yaml_config, calls, record_calls +): """Test the subscription to topics with special characters.""" + await mqtt_mock_entry_no_yaml_config() topic = "/test-topic/$(.)[^]{-}" payload = "p4y.l[]a|> ?" @@ -1027,13 +1091,16 @@ async def test_subscribe_special_characters(hass, mqtt_mock, calls, record_calls assert calls[0][0].payload == payload -async def test_subscribe_same_topic(hass, mqtt_client_mock, mqtt_mock): +async def test_subscribe_same_topic( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """ Test subscring to same topic twice and simulate retained messages. When subscribing to the same topic again, SUBSCRIBE must be sent to the broker again for it to resend any retained messages. """ + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fake that the client is connected mqtt_mock().connected = True @@ -1061,9 +1128,10 @@ async def test_subscribe_same_topic(hass, mqtt_client_mock, mqtt_mock): async def test_not_calling_unsubscribe_with_active_subscribers( - hass, mqtt_client_mock, mqtt_mock + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): """Test not calling unsubscribe() when other subscribers are active.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fake that the client is connected mqtt_mock().connected = True @@ -1077,8 +1145,9 @@ async def test_not_calling_unsubscribe_with_active_subscribers( assert not mqtt_client_mock.unsubscribe.called -async def test_unsubscribe_race(hass, mqtt_client_mock, mqtt_mock): +async def test_unsubscribe_race(hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config): """Test not calling unsubscribe() when other subscribers are active.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fake that the client is connected mqtt_mock().connected = True @@ -1113,8 +1182,11 @@ async def test_unsubscribe_race(hass, mqtt_client_mock, mqtt_mock): "mqtt_config", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], ) -async def test_restore_subscriptions_on_reconnect(hass, mqtt_client_mock, mqtt_mock): +async def test_restore_subscriptions_on_reconnect( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """Test subscriptions are restored on reconnect.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fake that the client is connected mqtt_mock().connected = True @@ -1134,9 +1206,10 @@ async def test_restore_subscriptions_on_reconnect(hass, mqtt_client_mock, mqtt_m [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_DISCOVERY: False}], ) async def test_restore_all_active_subscriptions_on_reconnect( - hass, mqtt_client_mock, mqtt_mock + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): """Test active subscriptions are restored correctly on reconnect.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() # Fake that the client is connected mqtt_mock().connected = True @@ -1176,9 +1249,10 @@ async def test_initial_setup_logs_error(hass, caplog, mqtt_client_mock): async def test_logs_error_if_no_connect_broker( - hass, caplog, mqtt_mock, mqtt_client_mock + hass, caplog, mqtt_mock_entry_no_yaml_config, mqtt_client_mock ): """Test for setup failure if connection to broker is missing.""" + await mqtt_mock_entry_no_yaml_config() # test with rc = 3 -> broker unavailable mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 3) await hass.async_block_till_done() @@ -1189,8 +1263,11 @@ async def test_logs_error_if_no_connect_broker( @patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.3) -async def test_handle_mqtt_on_callback(hass, caplog, mqtt_mock, mqtt_client_mock): +async def test_handle_mqtt_on_callback( + hass, caplog, mqtt_mock_entry_no_yaml_config, mqtt_client_mock +): """Test receiving an ACK callback before waiting for it.""" + await mqtt_mock_entry_no_yaml_config() # Simulate an ACK for mid == 1, this will call mqtt_mock._mqtt_handle_mid(mid) mqtt_client_mock.on_publish(mqtt_client_mock, None, 1) await hass.async_block_till_done() @@ -1224,8 +1301,11 @@ async def test_publish_error(hass, caplog): assert "Failed to connect to MQTT server: Out of memory." in caplog.text -async def test_handle_message_callback(hass, caplog, mqtt_mock, mqtt_client_mock): +async def test_handle_message_callback( + hass, caplog, mqtt_mock_entry_no_yaml_config, mqtt_client_mock +): """Test for handling an incoming message callback.""" + await mqtt_mock_entry_no_yaml_config() msg = ReceiveMessage("some-topic", b"test-payload", 0, False) mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) await mqtt.async_subscribe(hass, "some-topic", lambda *args: 0) @@ -1478,8 +1558,11 @@ async def test_setup_without_tls_config_uses_tlsv1_under_python36(hass): } ], ) -async def test_custom_birth_message(hass, mqtt_client_mock, mqtt_mock): +async def test_custom_birth_message( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """Test sending birth message.""" + await mqtt_mock_entry_no_yaml_config() birth = asyncio.Event() async def wait_birth(topic, payload, qos): @@ -1508,8 +1591,11 @@ async def test_custom_birth_message(hass, mqtt_client_mock, mqtt_mock): } ], ) -async def test_default_birth_message(hass, mqtt_client_mock, mqtt_mock): +async def test_default_birth_message( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """Test sending birth message.""" + await mqtt_mock_entry_no_yaml_config() birth = asyncio.Event() async def wait_birth(topic, payload, qos): @@ -1530,8 +1616,9 @@ async def test_default_birth_message(hass, mqtt_client_mock, mqtt_mock): "mqtt_config", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}], ) -async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock): +async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config): """Test disabling birth message.""" + await mqtt_mock_entry_no_yaml_config() with patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.1): mqtt_client_mock.on_connect(None, None, 0, 0) await hass.async_block_till_done() @@ -1553,8 +1640,12 @@ async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock): } ], ) -async def test_delayed_birth_message(hass, mqtt_client_mock, mqtt_config, mqtt_mock): +async def test_delayed_birth_message( + hass, mqtt_client_mock, mqtt_config, mqtt_mock_entry_no_yaml_config +): """Test sending birth message does not happen until Home Assistant starts.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + hass.state = CoreState.starting birth = asyncio.Event() @@ -1610,15 +1701,23 @@ async def test_delayed_birth_message(hass, mqtt_client_mock, mqtt_config, mqtt_m } ], ) -async def test_custom_will_message(hass, mqtt_client_mock, mqtt_mock): +async def test_custom_will_message( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """Test will message.""" + await mqtt_mock_entry_no_yaml_config() + mqtt_client_mock.will_set.assert_called_with( topic="death", payload="death", qos=0, retain=False ) -async def test_default_will_message(hass, mqtt_client_mock, mqtt_mock): +async def test_default_will_message( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """Test will message.""" + await mqtt_mock_entry_no_yaml_config() + mqtt_client_mock.will_set.assert_called_with( topic="homeassistant/status", payload="offline", qos=0, retain=False ) @@ -1628,8 +1727,10 @@ async def test_default_will_message(hass, mqtt_client_mock, mqtt_mock): "mqtt_config", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_WILL_MESSAGE: {}}], ) -async def test_no_will_message(hass, mqtt_client_mock, mqtt_mock): +async def test_no_will_message(hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config): """Test will message.""" + await mqtt_mock_entry_no_yaml_config() + mqtt_client_mock.will_set.assert_not_called() @@ -1643,8 +1744,12 @@ async def test_no_will_message(hass, mqtt_client_mock, mqtt_mock): } ], ) -async def test_mqtt_subscribes_topics_on_connect(hass, mqtt_client_mock, mqtt_mock): +async def test_mqtt_subscribes_topics_on_connect( + hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config +): """Test subscription to topic on connect.""" + await mqtt_mock_entry_no_yaml_config() + await mqtt.async_subscribe(hass, "topic/test", None) await mqtt.async_subscribe(hass, "home/sensor", None, 2) await mqtt.async_subscribe(hass, "still/pending", None) @@ -1662,7 +1767,9 @@ async def test_mqtt_subscribes_topics_on_connect(hass, mqtt_client_mock, mqtt_mo assert calls == expected -async def test_setup_entry_with_config_override(hass, device_reg, mqtt_client_mock): +async def test_setup_entry_with_config_override( + hass, device_reg, mqtt_mock_entry_with_yaml_config +): """Test if the MQTT component loads with no config and config entry can be setup.""" data = ( '{ "device":{"identifiers":["0AFFD2"]},' @@ -1672,6 +1779,8 @@ async def test_setup_entry_with_config_override(hass, device_reg, mqtt_client_mo # mqtt present in yaml config assert await async_setup_component(hass, mqtt.DOMAIN, {}) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() # User sets up a config entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) @@ -1734,8 +1843,11 @@ async def test_fail_no_broker(hass, device_reg, mqtt_client_mock, caplog): @pytest.mark.no_fail_on_log_exception -async def test_message_callback_exception_gets_logged(hass, caplog, mqtt_mock): +async def test_message_callback_exception_gets_logged( + hass, caplog, mqtt_mock_entry_no_yaml_config +): """Test exception raised by message handler.""" + await mqtt_mock_entry_no_yaml_config() @callback def bad_handler(*args): @@ -1752,8 +1864,11 @@ async def test_message_callback_exception_gets_logged(hass, caplog, mqtt_mock): ) -async def test_mqtt_ws_subscription(hass, hass_ws_client, mqtt_mock): +async def test_mqtt_ws_subscription( + hass, hass_ws_client, mqtt_mock_entry_no_yaml_config +): """Test MQTT websocket subscription.""" + await mqtt_mock_entry_no_yaml_config() client = await hass_ws_client(hass) await client.send_json({"id": 5, "type": "mqtt/subscribe", "topic": "test-topic"}) response = await client.receive_json() @@ -1782,9 +1897,10 @@ async def test_mqtt_ws_subscription(hass, hass_ws_client, mqtt_mock): async def test_mqtt_ws_subscription_not_admin( - hass, hass_ws_client, mqtt_mock, hass_read_only_access_token + hass, hass_ws_client, mqtt_mock_entry_no_yaml_config, hass_read_only_access_token ): """Test MQTT websocket user is not admin.""" + await mqtt_mock_entry_no_yaml_config() client = await hass_ws_client(hass, access_token=hass_read_only_access_token) await client.send_json({"id": 5, "type": "mqtt/subscribe", "topic": "test-topic"}) response = await client.receive_json() @@ -1793,8 +1909,9 @@ async def test_mqtt_ws_subscription_not_admin( assert response["error"]["message"] == "Unauthorized" -async def test_dump_service(hass, mqtt_mock): +async def test_dump_service(hass, mqtt_mock_entry_no_yaml_config): """Test that we can dump a topic.""" + await mqtt_mock_entry_no_yaml_config() mopen = mock_open() await hass.services.async_call( @@ -1814,10 +1931,12 @@ async def test_dump_service(hass, mqtt_mock): async def test_mqtt_ws_remove_discovered_device( - hass, device_reg, entity_reg, hass_ws_client, mqtt_mock + hass, device_reg, entity_reg, hass_ws_client, mqtt_mock_entry_no_yaml_config ): """Test MQTT websocket device removal.""" assert await async_setup_component(hass, "config", {}) + await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() data = ( '{ "device":{"identifiers":["0AFFD2"]},' @@ -1851,9 +1970,10 @@ async def test_mqtt_ws_remove_discovered_device( async def test_mqtt_ws_get_device_debug_info( - hass, device_reg, hass_ws_client, mqtt_mock + hass, device_reg, hass_ws_client, mqtt_mock_entry_no_yaml_config ): """Test MQTT websocket device debug info.""" + await mqtt_mock_entry_no_yaml_config() config_sensor = { "device": {"identifiers": ["0AFFD2"]}, "platform": "mqtt", @@ -1913,9 +2033,10 @@ async def test_mqtt_ws_get_device_debug_info( async def test_mqtt_ws_get_device_debug_info_binary( - hass, device_reg, hass_ws_client, mqtt_mock + hass, device_reg, hass_ws_client, mqtt_mock_entry_no_yaml_config ): """Test MQTT websocket device debug info.""" + await mqtt_mock_entry_no_yaml_config() config = { "device": {"identifiers": ["0AFFD2"]}, "platform": "mqtt", @@ -1975,8 +2096,9 @@ async def test_mqtt_ws_get_device_debug_info_binary( assert response["result"] == expected_result -async def test_debug_info_multiple_devices(hass, mqtt_mock): +async def test_debug_info_multiple_devices(hass, mqtt_mock_entry_no_yaml_config): """Test we get correct debug_info when multiple devices are present.""" + await mqtt_mock_entry_no_yaml_config() devices = [ { "domain": "sensor", @@ -2054,8 +2176,11 @@ async def test_debug_info_multiple_devices(hass, mqtt_mock): assert discovery_data["payload"] == d["config"] -async def test_debug_info_multiple_entities_triggers(hass, mqtt_mock): +async def test_debug_info_multiple_entities_triggers( + hass, mqtt_mock_entry_no_yaml_config +): """Test we get correct debug_info for a device with multiple entities and triggers.""" + await mqtt_mock_entry_no_yaml_config() config = [ { "domain": "sensor", @@ -2137,8 +2262,11 @@ async def test_debug_info_multiple_entities_triggers(hass, mqtt_mock): } in discovery_data -async def test_debug_info_non_mqtt(hass, device_reg, entity_reg, mqtt_mock): +async def test_debug_info_non_mqtt( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test we get empty debug_info for a device with non MQTT entities.""" + await mqtt_mock_entry_no_yaml_config() DOMAIN = "sensor" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() @@ -2164,8 +2292,9 @@ async def test_debug_info_non_mqtt(hass, device_reg, entity_reg, mqtt_mock): assert len(debug_info_data["triggers"]) == 0 -async def test_debug_info_wildcard(hass, mqtt_mock): +async def test_debug_info_wildcard(hass, mqtt_mock_entry_no_yaml_config): """Test debug info.""" + await mqtt_mock_entry_no_yaml_config() config = { "device": {"identifiers": ["helloworld"]}, "platform": "mqtt", @@ -2210,8 +2339,9 @@ async def test_debug_info_wildcard(hass, mqtt_mock): } in debug_info_data["entities"][0]["subscriptions"] -async def test_debug_info_filter_same(hass, mqtt_mock): +async def test_debug_info_filter_same(hass, mqtt_mock_entry_no_yaml_config): """Test debug info removes messages with same timestamp.""" + await mqtt_mock_entry_no_yaml_config() config = { "device": {"identifiers": ["helloworld"]}, "platform": "mqtt", @@ -2268,8 +2398,9 @@ async def test_debug_info_filter_same(hass, mqtt_mock): } == debug_info_data["entities"][0]["subscriptions"][0] -async def test_debug_info_same_topic(hass, mqtt_mock): +async def test_debug_info_same_topic(hass, mqtt_mock_entry_no_yaml_config): """Test debug info.""" + await mqtt_mock_entry_no_yaml_config() config = { "device": {"identifiers": ["helloworld"]}, "platform": "mqtt", @@ -2320,8 +2451,9 @@ async def test_debug_info_same_topic(hass, mqtt_mock): async_fire_mqtt_message(hass, "sensor/status", "123", qos=0, retain=False) -async def test_debug_info_qos_retain(hass, mqtt_mock): +async def test_debug_info_qos_retain(hass, mqtt_mock_entry_no_yaml_config): """Test debug info.""" + await mqtt_mock_entry_no_yaml_config() config = { "device": {"identifiers": ["helloworld"]}, "platform": "mqtt", @@ -2377,8 +2509,10 @@ async def test_debug_info_qos_retain(hass, mqtt_mock): } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] -async def test_publish_json_from_template(hass, mqtt_mock): +async def test_publish_json_from_template(hass, mqtt_mock_entry_no_yaml_config): """Test the publishing of call to services.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() + test_str = "{'valid': 'python', 'invalid': 'json'}" test_str_tpl = "{'valid': '{{ \"python\" }}', 'invalid': 'json'}" @@ -2424,8 +2558,11 @@ async def test_publish_json_from_template(hass, mqtt_mock): assert mqtt_mock.async_publish.call_args[0][1] == test_str -async def test_subscribe_connection_status(hass, mqtt_mock, mqtt_client_mock): +async def test_subscribe_connection_status( + hass, mqtt_mock_entry_no_yaml_config, mqtt_client_mock +): """Test connextion status subscription.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() mqtt_connected_calls = [] @callback @@ -2459,7 +2596,9 @@ async def test_subscribe_connection_status(hass, mqtt_mock, mqtt_client_mock): assert mqtt_connected_calls[1] is False -async def test_one_deprecation_warning_per_platform(hass, mqtt_mock, caplog): +async def test_one_deprecation_warning_per_platform( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test a deprecation warning is is logged once per platform.""" platform = "light" config = {"platform": "mqtt", "command_topic": "test-topic"} @@ -2469,6 +2608,7 @@ async def test_one_deprecation_warning_per_platform(hass, mqtt_mock, caplog): config2["name"] = "test2" await async_setup_component(hass, platform, {platform: [config1, config2]}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() count = 0 for record in caplog.records: if record.levelname == "WARNING" and ( diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index b7a3b5f2118..43b6e839904 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -89,12 +89,13 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {vacuum.DOMAIN: {"platform": "mqtt", "name": "test"}} -async def test_default_supported_features(hass, mqtt_mock): +async def test_default_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test that the correct supported features.""" assert await async_setup_component( hass, vacuum.DOMAIN, {vacuum.DOMAIN: DEFAULT_CONFIG} ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -110,7 +111,7 @@ async def test_default_supported_features(hass, mqtt_mock): ) -async def test_all_commands(hass, mqtt_mock): +async def test_all_commands(hass, mqtt_mock_entry_with_yaml_config): """Test simple commands to the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -119,6 +120,7 @@ async def test_all_commands(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_called_once_with( @@ -189,7 +191,9 @@ async def test_all_commands(hass, mqtt_mock): } -async def test_commands_without_supported_features(hass, mqtt_mock): +async def test_commands_without_supported_features( + hass, mqtt_mock_entry_with_yaml_config +): """Test commands which are not supported by the vacuum.""" config = deepcopy(DEFAULT_CONFIG) services = mqttvacuum.STRING_TO_SERVICE["status"] @@ -199,6 +203,7 @@ async def test_commands_without_supported_features(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await common.async_turn_on(hass, "vacuum.mqtttest") mqtt_mock.async_publish.assert_not_called() @@ -237,7 +242,9 @@ async def test_commands_without_supported_features(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_attributes_without_supported_features(hass, mqtt_mock): +async def test_attributes_without_supported_features( + hass, mqtt_mock_entry_with_yaml_config +): """Test attributes which are not supported by the vacuum.""" config = deepcopy(DEFAULT_CONFIG) services = mqttvacuum.STRING_TO_SERVICE["turn_on"] @@ -247,6 +254,7 @@ async def test_attributes_without_supported_features(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "battery_level": 54, @@ -264,7 +272,7 @@ async def test_attributes_without_supported_features(hass, mqtt_mock): assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None -async def test_status(hass, mqtt_mock): +async def test_status(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -273,6 +281,7 @@ async def test_status(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "battery_level": 54, @@ -304,7 +313,7 @@ async def test_status(hass, mqtt_mock): assert state.attributes.get(ATTR_FAN_SPEED) == "min" -async def test_status_battery(hass, mqtt_mock): +async def test_status_battery(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -313,6 +322,7 @@ async def test_status_battery(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "battery_level": 54 @@ -322,7 +332,7 @@ async def test_status_battery(hass, mqtt_mock): assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" -async def test_status_cleaning(hass, mqtt_mock): +async def test_status_cleaning(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -331,6 +341,7 @@ async def test_status_cleaning(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "cleaning": true @@ -340,7 +351,7 @@ async def test_status_cleaning(hass, mqtt_mock): assert state.state == STATE_ON -async def test_status_docked(hass, mqtt_mock): +async def test_status_docked(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -349,6 +360,7 @@ async def test_status_docked(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "docked": true @@ -358,7 +370,7 @@ async def test_status_docked(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_status_charging(hass, mqtt_mock): +async def test_status_charging(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -367,6 +379,7 @@ async def test_status_charging(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "charging": true @@ -376,7 +389,7 @@ async def test_status_charging(hass, mqtt_mock): assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-outline" -async def test_status_fan_speed(hass, mqtt_mock): +async def test_status_fan_speed(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -385,6 +398,7 @@ async def test_status_fan_speed(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "fan_speed": "max" @@ -394,7 +408,7 @@ async def test_status_fan_speed(hass, mqtt_mock): assert state.attributes.get(ATTR_FAN_SPEED) == "max" -async def test_status_fan_speed_list(hass, mqtt_mock): +async def test_status_fan_speed_list(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -403,12 +417,13 @@ async def test_status_fan_speed_list(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] -async def test_status_no_fan_speed_list(hass, mqtt_mock): +async def test_status_no_fan_speed_list(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum. If the vacuum doesn't support fan speed, fan speed list should be None. @@ -421,12 +436,13 @@ async def test_status_no_fan_speed_list(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None -async def test_status_error(hass, mqtt_mock): +async def test_status_error(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -435,6 +451,7 @@ async def test_status_error(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "error": "Error1" @@ -451,7 +468,7 @@ async def test_status_error(hass, mqtt_mock): assert state.attributes.get(ATTR_STATUS) == "Stopped" -async def test_battery_template(hass, mqtt_mock): +async def test_battery_template(hass, mqtt_mock_entry_with_yaml_config): """Test that you can use non-default templates for battery_level.""" config = deepcopy(DEFAULT_CONFIG) config.update( @@ -466,6 +483,7 @@ async def test_battery_template(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "retroroomba/battery_level", "54") state = hass.states.get("vacuum.mqtttest") @@ -473,7 +491,7 @@ async def test_battery_template(hass, mqtt_mock): assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" -async def test_status_invalid_json(hass, mqtt_mock): +async def test_status_invalid_json(hass, mqtt_mock_entry_with_yaml_config): """Test to make sure nothing breaks if the vacuum sends bad JSON.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -482,6 +500,7 @@ async def test_status_invalid_json(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") @@ -489,153 +508,169 @@ async def test_status_invalid_json(hass, mqtt_mock): assert state.attributes.get(ATTR_STATUS) == "Stopped" -async def test_missing_battery_template(hass, mqtt_mock): +async def test_missing_battery_template(hass, mqtt_mock_entry_no_yaml_config): """Test to make sure missing template is not allowed.""" config = deepcopy(DEFAULT_CONFIG) config.pop(mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE) assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state is None -async def test_missing_charging_template(hass, mqtt_mock): +async def test_missing_charging_template(hass, mqtt_mock_entry_no_yaml_config): """Test to make sure missing template is not allowed.""" config = deepcopy(DEFAULT_CONFIG) config.pop(mqttvacuum.CONF_CHARGING_TEMPLATE) assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state is None -async def test_missing_cleaning_template(hass, mqtt_mock): +async def test_missing_cleaning_template(hass, mqtt_mock_entry_no_yaml_config): """Test to make sure missing template is not allowed.""" config = deepcopy(DEFAULT_CONFIG) config.pop(mqttvacuum.CONF_CLEANING_TEMPLATE) assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state is None -async def test_missing_docked_template(hass, mqtt_mock): +async def test_missing_docked_template(hass, mqtt_mock_entry_no_yaml_config): """Test to make sure missing template is not allowed.""" config = deepcopy(DEFAULT_CONFIG) config.pop(mqttvacuum.CONF_DOCKED_TEMPLATE) assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state is None -async def test_missing_error_template(hass, mqtt_mock): +async def test_missing_error_template(hass, mqtt_mock_entry_no_yaml_config): """Test to make sure missing template is not allowed.""" config = deepcopy(DEFAULT_CONFIG) config.pop(mqttvacuum.CONF_ERROR_TEMPLATE) assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state is None -async def test_missing_fan_speed_template(hass, mqtt_mock): +async def test_missing_fan_speed_template(hass, mqtt_mock_entry_no_yaml_config): """Test to make sure missing template is not allowed.""" config = deepcopy(DEFAULT_CONFIG) config.pop(mqttvacuum.CONF_FAN_SPEED_TEMPLATE) assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state is None -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2, MQTT_LEGACY_VACUUM_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one vacuum per unique_id.""" config = { vacuum.DOMAIN: [ @@ -653,74 +688,85 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, vacuum.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, config + ) -async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): +async def test_discovery_removal_vacuum(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered vacuum.""" data = json.dumps(DEFAULT_CONFIG_2[vacuum.DOMAIN]) - await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data + ) -async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): +async def test_discovery_update_vacuum(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered vacuum.""" config1 = {"name": "Beer", "command_topic": "test_topic"} config2 = {"name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock, caplog, vacuum.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_vacuum(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_vacuum( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered vacuum.""" data1 = '{ "name": "Beer", "command_topic": "test_topic" }' with patch( "homeassistant.components.mqtt.vacuum.schema_legacy.MqttVacuum.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + vacuum.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" config = { vacuum.DOMAIN: { @@ -733,18 +779,22 @@ async def test_entity_id_update_subscriptions(hass, mqtt_mock): } } await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, vacuum.DOMAIN, config, ["test-topic", "avty-topic"] + hass, + mqtt_mock_entry_with_yaml_config, + vacuum.DOMAIN, + config, + ["test-topic", "avty-topic"], ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" config = { vacuum.DOMAIN: { @@ -757,7 +807,11 @@ async def test_entity_debug_info_message(hass, mqtt_mock): } } await help_test_entity_debug_info_message( - hass, mqtt_mock, vacuum.DOMAIN, config, vacuum.SERVICE_TURN_ON + hass, + mqtt_mock_entry_no_yaml_config, + vacuum.DOMAIN, + config, + vacuum.SERVICE_TURN_ON, ) @@ -803,7 +857,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -824,7 +878,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -836,11 +890,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = vacuum.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -872,7 +928,13 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" config = deepcopy(DEFAULT_CONFIG) @@ -892,7 +954,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, vacuum.DOMAIN, config, diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 957178da14f..08d5432ba27 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -251,16 +251,17 @@ DEFAULT_CONFIG = { } -async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): +async def test_fail_setup_if_no_command_topic(hass, mqtt_mock_entry_no_yaml_config): """Test if command fails with command topic.""" assert await async_setup_component( hass, light.DOMAIN, {light.DOMAIN: {"platform": "mqtt", "name": "test"}} ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("light.test") is None -async def test_legacy_rgb_white_light(hass, mqtt_mock): +async def test_legacy_rgb_white_light(hass, mqtt_mock_entry_with_yaml_config): """Test legacy RGB + white light flags brightness support.""" assert await async_setup_component( hass, @@ -276,6 +277,7 @@ async def test_legacy_rgb_white_light(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") expected_features = ( @@ -286,7 +288,9 @@ async def test_legacy_rgb_white_light(hass, mqtt_mock): assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == ["hs", "rgbw"] -async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics(hass, mqtt_mock): +async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics( + hass, mqtt_mock_entry_with_yaml_config +): """Test if there is no color and brightness if no topic.""" assert await async_setup_component( hass, @@ -301,6 +305,7 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics(hass, mqt }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -343,7 +348,9 @@ async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics(hass, mqt assert state.state == STATE_UNKNOWN -async def test_legacy_controlling_state_via_topic(hass, mqtt_mock): +async def test_legacy_controlling_state_via_topic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling of the state via topic for legacy light (white_value).""" config = { light.DOMAIN: { @@ -374,6 +381,7 @@ async def test_legacy_controlling_state_via_topic(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -472,7 +480,7 @@ async def test_legacy_controlling_state_via_topic(hass, mqtt_mock): assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_controlling_state_via_topic(hass, mqtt_mock): +async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling of the state via topic.""" config = { light.DOMAIN: { @@ -505,6 +513,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -593,7 +602,9 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_legacy_invalid_state_via_topic(hass, mqtt_mock, caplog): +async def test_legacy_invalid_state_via_topic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test handling of empty data via topic.""" config = { light.DOMAIN: { @@ -623,6 +634,7 @@ async def test_legacy_invalid_state_via_topic(hass, mqtt_mock, caplog): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -709,7 +721,7 @@ async def test_legacy_invalid_state_via_topic(hass, mqtt_mock, caplog): assert light_state.attributes["white_value"] == 255 -async def test_invalid_state_via_topic(hass, mqtt_mock, caplog): +async def test_invalid_state_via_topic(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test handling of empty data via topic.""" config = { light.DOMAIN: { @@ -742,6 +754,7 @@ async def test_invalid_state_via_topic(hass, mqtt_mock, caplog): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -843,7 +856,7 @@ async def test_invalid_state_via_topic(hass, mqtt_mock, caplog): assert light_state.attributes["color_temp"] == 153 -async def test_brightness_controlling_scale(hass, mqtt_mock): +async def test_brightness_controlling_scale(hass, mqtt_mock_entry_with_yaml_config): """Test the brightness controlling scale.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -865,6 +878,7 @@ async def test_brightness_controlling_scale(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -890,7 +904,9 @@ async def test_brightness_controlling_scale(hass, mqtt_mock): assert light_state.attributes["brightness"] == 255 -async def test_brightness_from_rgb_controlling_scale(hass, mqtt_mock): +async def test_brightness_from_rgb_controlling_scale( + hass, mqtt_mock_entry_with_yaml_config +): """Test the brightness controlling scale.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -911,6 +927,7 @@ async def test_brightness_from_rgb_controlling_scale(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -929,7 +946,9 @@ async def test_brightness_from_rgb_controlling_scale(hass, mqtt_mock): assert state.attributes.get("brightness") == 127 -async def test_legacy_white_value_controlling_scale(hass, mqtt_mock): +async def test_legacy_white_value_controlling_scale( + hass, mqtt_mock_entry_with_yaml_config +): """Test the white_value controlling scale.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -951,6 +970,7 @@ async def test_legacy_white_value_controlling_scale(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -976,7 +996,9 @@ async def test_legacy_white_value_controlling_scale(hass, mqtt_mock): assert light_state.attributes["white_value"] == 255 -async def test_legacy_controlling_state_via_topic_with_templates(hass, mqtt_mock): +async def test_legacy_controlling_state_via_topic_with_templates( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of the state with a template.""" config = { light.DOMAIN: { @@ -1011,6 +1033,7 @@ async def test_legacy_controlling_state_via_topic_with_templates(hass, mqtt_mock assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1065,7 +1088,9 @@ async def test_legacy_controlling_state_via_topic_with_templates(hass, mqtt_mock assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic_with_templates(hass, mqtt_mock): +async def test_controlling_state_via_topic_with_templates( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of the state with a template.""" config = { light.DOMAIN: { @@ -1104,6 +1129,7 @@ async def test_controlling_state_via_topic_with_templates(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1165,7 +1191,9 @@ async def test_controlling_state_via_topic_with_templates(hass, mqtt_mock): assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_legacy_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): +async def test_legacy_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of command in optimistic mode.""" config = { light.DOMAIN: { @@ -1204,6 +1232,7 @@ async def test_legacy_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): ), assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -1295,7 +1324,9 @@ async def test_legacy_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): assert state.attributes["color_temp"] == 125 -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of command in optimistic mode.""" config = { light.DOMAIN: { @@ -1334,6 +1365,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): ), assert_setup_component(1, light.DOMAIN): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -1484,7 +1516,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_sending_mqtt_rgb_command_with_template(hass, mqtt_mock): +async def test_sending_mqtt_rgb_command_with_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of RGB command with template.""" config = { light.DOMAIN: { @@ -1502,6 +1536,7 @@ async def test_sending_mqtt_rgb_command_with_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1521,7 +1556,9 @@ async def test_sending_mqtt_rgb_command_with_template(hass, mqtt_mock): assert state.attributes["rgb_color"] == (255, 128, 64) -async def test_sending_mqtt_rgbw_command_with_template(hass, mqtt_mock): +async def test_sending_mqtt_rgbw_command_with_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of RGBW command with template.""" config = { light.DOMAIN: { @@ -1539,6 +1576,7 @@ async def test_sending_mqtt_rgbw_command_with_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1558,7 +1596,9 @@ async def test_sending_mqtt_rgbw_command_with_template(hass, mqtt_mock): assert state.attributes["rgbw_color"] == (255, 128, 64, 32) -async def test_sending_mqtt_rgbww_command_with_template(hass, mqtt_mock): +async def test_sending_mqtt_rgbww_command_with_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of RGBWW command with template.""" config = { light.DOMAIN: { @@ -1576,6 +1616,7 @@ async def test_sending_mqtt_rgbww_command_with_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1595,7 +1636,9 @@ async def test_sending_mqtt_rgbww_command_with_template(hass, mqtt_mock): assert state.attributes["rgbww_color"] == (255, 128, 64, 32, 16) -async def test_sending_mqtt_color_temp_command_with_template(hass, mqtt_mock): +async def test_sending_mqtt_color_temp_command_with_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of Color Temp command with template.""" config = { light.DOMAIN: { @@ -1612,6 +1655,7 @@ async def test_sending_mqtt_color_temp_command_with_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1631,7 +1675,7 @@ async def test_sending_mqtt_color_temp_command_with_template(hass, mqtt_mock): assert state.attributes["color_temp"] == 100 -async def test_on_command_first(hass, mqtt_mock): +async def test_on_command_first(hass, mqtt_mock_entry_with_yaml_config): """Test on command being sent before brightness.""" config = { light.DOMAIN: { @@ -1645,6 +1689,7 @@ async def test_on_command_first(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1667,7 +1712,7 @@ async def test_on_command_first(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) -async def test_on_command_last(hass, mqtt_mock): +async def test_on_command_last(hass, mqtt_mock_entry_with_yaml_config): """Test on command being sent after brightness.""" config = { light.DOMAIN: { @@ -1680,6 +1725,7 @@ async def test_on_command_last(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1702,7 +1748,7 @@ async def test_on_command_last(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) -async def test_on_command_brightness(hass, mqtt_mock): +async def test_on_command_brightness(hass, mqtt_mock_entry_with_yaml_config): """Test on command being sent as only brightness.""" config = { light.DOMAIN: { @@ -1717,6 +1763,7 @@ async def test_on_command_brightness(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1757,7 +1804,7 @@ async def test_on_command_brightness(hass, mqtt_mock): ) -async def test_on_command_brightness_scaled(hass, mqtt_mock): +async def test_on_command_brightness_scaled(hass, mqtt_mock_entry_with_yaml_config): """Test brightness scale.""" config = { light.DOMAIN: { @@ -1773,6 +1820,7 @@ async def test_on_command_brightness_scaled(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1827,7 +1875,7 @@ async def test_on_command_brightness_scaled(hass, mqtt_mock): ) -async def test_legacy_on_command_rgb(hass, mqtt_mock): +async def test_legacy_on_command_rgb(hass, mqtt_mock_entry_with_yaml_config): """Test on command in RGB brightness mode.""" config = { light.DOMAIN: { @@ -1841,6 +1889,7 @@ async def test_legacy_on_command_rgb(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1918,7 +1967,7 @@ async def test_legacy_on_command_rgb(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_on_command_rgb(hass, mqtt_mock): +async def test_on_command_rgb(hass, mqtt_mock_entry_with_yaml_config): """Test on command in RGB brightness mode.""" config = { light.DOMAIN: { @@ -1931,6 +1980,7 @@ async def test_on_command_rgb(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2008,7 +2058,7 @@ async def test_on_command_rgb(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_on_command_rgbw(hass, mqtt_mock): +async def test_on_command_rgbw(hass, mqtt_mock_entry_with_yaml_config): """Test on command in RGBW brightness mode.""" config = { light.DOMAIN: { @@ -2021,6 +2071,7 @@ async def test_on_command_rgbw(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2098,7 +2149,7 @@ async def test_on_command_rgbw(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_on_command_rgbww(hass, mqtt_mock): +async def test_on_command_rgbww(hass, mqtt_mock_entry_with_yaml_config): """Test on command in RGBWW brightness mode.""" config = { light.DOMAIN: { @@ -2111,6 +2162,7 @@ async def test_on_command_rgbww(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2188,7 +2240,7 @@ async def test_on_command_rgbww(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_on_command_rgb_template(hass, mqtt_mock): +async def test_on_command_rgb_template(hass, mqtt_mock_entry_with_yaml_config): """Test on command in RGB brightness mode with RGB template.""" config = { light.DOMAIN: { @@ -2202,6 +2254,7 @@ async def test_on_command_rgb_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2225,7 +2278,7 @@ async def test_on_command_rgb_template(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) -async def test_on_command_rgbw_template(hass, mqtt_mock): +async def test_on_command_rgbw_template(hass, mqtt_mock_entry_with_yaml_config): """Test on command in RGBW brightness mode with RGBW template.""" config = { light.DOMAIN: { @@ -2239,6 +2292,7 @@ async def test_on_command_rgbw_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2261,7 +2315,7 @@ async def test_on_command_rgbw_template(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) -async def test_on_command_rgbww_template(hass, mqtt_mock): +async def test_on_command_rgbww_template(hass, mqtt_mock_entry_with_yaml_config): """Test on command in RGBWW brightness mode with RGBWW template.""" config = { light.DOMAIN: { @@ -2275,6 +2329,7 @@ async def test_on_command_rgbww_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2298,7 +2353,7 @@ async def test_on_command_rgbww_template(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) -async def test_on_command_white(hass, mqtt_mock): +async def test_on_command_white(hass, mqtt_mock_entry_with_yaml_config): """Test sending commands for RGB + white light.""" config = { light.DOMAIN: { @@ -2324,6 +2379,7 @@ async def test_on_command_white(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2375,7 +2431,7 @@ async def test_on_command_white(hass, mqtt_mock): ) -async def test_explicit_color_mode(hass, mqtt_mock): +async def test_explicit_color_mode(hass, mqtt_mock_entry_with_yaml_config): """Test explicit color mode over mqtt.""" config = { light.DOMAIN: { @@ -2409,6 +2465,7 @@ async def test_explicit_color_mode(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2525,7 +2582,7 @@ async def test_explicit_color_mode(hass, mqtt_mock): assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_explicit_color_mode_templated(hass, mqtt_mock): +async def test_explicit_color_mode_templated(hass, mqtt_mock_entry_with_yaml_config): """Test templated explicit color mode over mqtt.""" config = { light.DOMAIN: { @@ -2550,6 +2607,7 @@ async def test_explicit_color_mode_templated(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2606,7 +2664,7 @@ async def test_explicit_color_mode_templated(hass, mqtt_mock): assert light_state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_white_state_update(hass, mqtt_mock): +async def test_white_state_update(hass, mqtt_mock_entry_with_yaml_config): """Test state updates for RGB + white light.""" config = { light.DOMAIN: { @@ -2636,6 +2694,7 @@ async def test_white_state_update(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2670,7 +2729,7 @@ async def test_white_state_update(hass, mqtt_mock): assert state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES) == color_modes -async def test_effect(hass, mqtt_mock): +async def test_effect(hass, mqtt_mock_entry_with_yaml_config): """Test effect.""" config = { light.DOMAIN: { @@ -2684,6 +2743,7 @@ async def test_effect(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -2707,77 +2767,91 @@ async def test_effect(hass, mqtt_mock): mqtt_mock.async_publish.assert_called_once_with("test_light/set", "OFF", 0, False) -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG, MQTT_LIGHT_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, + MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one light per unique_id.""" config = { light.DOMAIN: [ @@ -2797,21 +2871,26 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, light.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config + ) -async def test_discovery_removal_light(hass, mqtt_mock, caplog): +async def test_discovery_removal_light(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered light.""" data = ( '{ "name": "test",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data + ) -async def test_discovery_deprecated(hass, mqtt_mock, caplog): +async def test_discovery_deprecated(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovery of mqtt light with deprecated platform option.""" + await mqtt_mock_entry_no_yaml_config() data = ( '{ "name": "Beer",' ' "platform": "mqtt",' ' "command_topic": "test_topic"}' ) @@ -2822,7 +2901,9 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): assert state.name == "Beer" -async def test_discovery_update_light_topic_and_template(hass, mqtt_mock, caplog): +async def test_discovery_update_light_topic_and_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered light.""" config1 = { "name": "Beer", @@ -3073,7 +3154,7 @@ async def test_discovery_update_light_topic_and_template(hass, mqtt_mock, caplog await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, config1, @@ -3083,7 +3164,9 @@ async def test_discovery_update_light_topic_and_template(hass, mqtt_mock, caplog ) -async def test_discovery_update_light_template(hass, mqtt_mock, caplog): +async def test_discovery_update_light_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered light.""" config1 = { "name": "Beer", @@ -3292,7 +3375,7 @@ async def test_discovery_update_light_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, config1, @@ -3302,7 +3385,9 @@ async def test_discovery_update_light_template(hass, mqtt_mock, caplog): ) -async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_light( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered light.""" data1 = ( '{ "name": "Beer",' @@ -3313,12 +3398,17 @@ async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.light.schema_basic.MqttLight.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + light.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = ( @@ -3327,60 +3417,64 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG, light.SERVICE_TURN_ON + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, + light.SERVICE_TURN_ON, ) -async def test_max_mireds(hass, mqtt_mock): +async def test_max_mireds(hass, mqtt_mock_entry_with_yaml_config): """Test setting min_mireds and max_mireds.""" config = { light.DOMAIN: { @@ -3394,6 +3488,7 @@ async def test_max_mireds(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -3488,7 +3583,7 @@ async def test_max_mireds(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -3508,7 +3603,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -3522,11 +3617,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -3568,7 +3665,14 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value, init_payload + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, + init_payload, ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[light.DOMAIN]) @@ -3587,7 +3691,7 @@ async def test_encoding_subscribable_topics( await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, config, @@ -3599,7 +3703,9 @@ async def test_encoding_subscribable_topics( ) -async def test_sending_mqtt_brightness_command_with_template(hass, mqtt_mock): +async def test_sending_mqtt_brightness_command_with_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of Brightness command with template.""" config = { light.DOMAIN: { @@ -3616,6 +3722,7 @@ async def test_sending_mqtt_brightness_command_with_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -3635,7 +3742,9 @@ async def test_sending_mqtt_brightness_command_with_template(hass, mqtt_mock): assert state.attributes["brightness"] == 100 -async def test_sending_mqtt_effect_command_with_template(hass, mqtt_mock): +async def test_sending_mqtt_effect_command_with_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of Effect command with template.""" config = { light.DOMAIN: { @@ -3654,6 +3763,7 @@ async def test_sending_mqtt_effect_command_with_template(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 962bf534370..c24c5e87937 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -162,7 +162,7 @@ class JsonValidator: return json.loads(self.jsondata) == json.loads(other) -async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): +async def test_fail_setup_if_no_command_topic(hass, mqtt_mock_entry_no_yaml_config): """Test if setup fails with no command topic.""" assert await async_setup_component( hass, @@ -170,11 +170,14 @@ async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): {light.DOMAIN: {"platform": "mqtt", "schema": "json", "name": "test"}}, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("light.test") is None @pytest.mark.parametrize("deprecated", ("color_temp", "hs", "rgb", "white_value", "xy")) -async def test_fail_setup_if_color_mode_deprecated(hass, mqtt_mock, deprecated): +async def test_fail_setup_if_color_mode_deprecated( + hass, mqtt_mock_entry_no_yaml_config, deprecated +): """Test if setup fails if color mode is combined with deprecated config keys.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] @@ -196,6 +199,7 @@ async def test_fail_setup_if_color_mode_deprecated(hass, mqtt_mock, deprecated): config, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("light.test") is None @@ -203,7 +207,7 @@ async def test_fail_setup_if_color_mode_deprecated(hass, mqtt_mock, deprecated): "supported_color_modes", [["onoff", "rgb"], ["brightness", "rgb"], ["unknown"]] ) async def test_fail_setup_if_color_modes_invalid( - hass, mqtt_mock, supported_color_modes + hass, mqtt_mock_entry_no_yaml_config, supported_color_modes ): """Test if setup fails if supported color modes is invalid.""" config = { @@ -223,10 +227,11 @@ async def test_fail_setup_if_color_modes_invalid( config, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert hass.states.get("light.test") is None -async def test_rgb_light(hass, mqtt_mock): +async def test_rgb_light(hass, mqtt_mock_entry_with_yaml_config): """Test RGB light flags brightness support.""" assert await async_setup_component( hass, @@ -242,6 +247,7 @@ async def test_rgb_light(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") expected_features = ( @@ -253,7 +259,9 @@ async def test_rgb_light(hass, mqtt_mock): assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_no_color_brightness_color_temp_white_val_if_no_topics(hass, mqtt_mock): +async def test_no_color_brightness_color_temp_white_val_if_no_topics( + hass, mqtt_mock_entry_with_yaml_config +): """Test for no RGB, brightness, color temp, effect, white val or XY.""" assert await async_setup_component( hass, @@ -269,6 +277,7 @@ async def test_no_color_brightness_color_temp_white_val_if_no_topics(hass, mqtt_ }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -305,7 +314,7 @@ async def test_no_color_brightness_color_temp_white_val_if_no_topics(hass, mqtt_ assert state.state == STATE_UNKNOWN -async def test_controlling_state_via_topic(hass, mqtt_mock): +async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling of the state via topic.""" assert await async_setup_component( hass, @@ -329,6 +338,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -434,7 +444,9 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): assert light_state.attributes.get("white_value") == 155 -async def test_controlling_state_via_topic2(hass, mqtt_mock, caplog): +async def test_controlling_state_via_topic2( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the controlling of the state via topic for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] @@ -457,6 +469,7 @@ async def test_controlling_state_via_topic2(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -602,7 +615,9 @@ async def test_controlling_state_via_topic2(hass, mqtt_mock, caplog): assert "Invalid or incomplete color value received" in caplog.text -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of command in optimistic mode.""" fake_state = ha.State( "light.test", @@ -641,6 +656,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -745,7 +761,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): assert state.attributes["xy_color"] == (0.611, 0.375) -async def test_sending_mqtt_commands_and_optimistic2(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_optimistic2( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of command in optimistic mode for a light supporting color mode.""" supported_color_modes = ["color_temp", "hs", "rgb", "rgbw", "rgbww", "xy"] fake_state = ha.State( @@ -783,6 +801,7 @@ async def test_sending_mqtt_commands_and_optimistic2(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -953,7 +972,7 @@ async def test_sending_mqtt_commands_and_optimistic2(hass, mqtt_mock): mqtt_mock.async_publish.reset_mock() -async def test_sending_hs_color(hass, mqtt_mock): +async def test_sending_hs_color(hass, mqtt_mock_entry_with_yaml_config): """Test light.turn_on with hs color sends hs color parameters.""" assert await async_setup_component( hass, @@ -971,6 +990,7 @@ async def test_sending_hs_color(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1018,7 +1038,7 @@ async def test_sending_hs_color(hass, mqtt_mock): ) -async def test_sending_rgb_color_no_brightness(hass, mqtt_mock): +async def test_sending_rgb_color_no_brightness(hass, mqtt_mock_entry_with_yaml_config): """Test light.turn_on with hs color sends rgb color parameters.""" assert await async_setup_component( hass, @@ -1034,6 +1054,7 @@ async def test_sending_rgb_color_no_brightness(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1071,7 +1092,7 @@ async def test_sending_rgb_color_no_brightness(hass, mqtt_mock): ) -async def test_sending_rgb_color_no_brightness2(hass, mqtt_mock): +async def test_sending_rgb_color_no_brightness2(hass, mqtt_mock_entry_with_yaml_config): """Test light.turn_on with hs color sends rgb color parameters.""" supported_color_modes = ["rgb", "rgbw", "rgbww"] assert await async_setup_component( @@ -1089,6 +1110,7 @@ async def test_sending_rgb_color_no_brightness2(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1148,7 +1170,9 @@ async def test_sending_rgb_color_no_brightness2(hass, mqtt_mock): ) -async def test_sending_rgb_color_with_brightness(hass, mqtt_mock): +async def test_sending_rgb_color_with_brightness( + hass, mqtt_mock_entry_with_yaml_config +): """Test light.turn_on with hs color sends rgb color parameters.""" assert await async_setup_component( hass, @@ -1166,6 +1190,7 @@ async def test_sending_rgb_color_with_brightness(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1218,7 +1243,9 @@ async def test_sending_rgb_color_with_brightness(hass, mqtt_mock): ) -async def test_sending_rgb_color_with_scaled_brightness(hass, mqtt_mock): +async def test_sending_rgb_color_with_scaled_brightness( + hass, mqtt_mock_entry_with_yaml_config +): """Test light.turn_on with hs color sends rgb color parameters.""" assert await async_setup_component( hass, @@ -1237,6 +1264,7 @@ async def test_sending_rgb_color_with_scaled_brightness(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1289,7 +1317,7 @@ async def test_sending_rgb_color_with_scaled_brightness(hass, mqtt_mock): ) -async def test_sending_xy_color(hass, mqtt_mock): +async def test_sending_xy_color(hass, mqtt_mock_entry_with_yaml_config): """Test light.turn_on with hs color sends xy color parameters.""" assert await async_setup_component( hass, @@ -1307,6 +1335,7 @@ async def test_sending_xy_color(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1353,7 +1382,7 @@ async def test_sending_xy_color(hass, mqtt_mock): ) -async def test_effect(hass, mqtt_mock): +async def test_effect(hass, mqtt_mock_entry_with_yaml_config): """Test for effect being sent when included.""" assert await async_setup_component( hass, @@ -1370,6 +1399,7 @@ async def test_effect(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1415,7 +1445,7 @@ async def test_effect(hass, mqtt_mock): assert state.attributes.get("effect") == "colorloop" -async def test_flash_short_and_long(hass, mqtt_mock): +async def test_flash_short_and_long(hass, mqtt_mock_entry_with_yaml_config): """Test for flash length being sent when included.""" assert await async_setup_component( hass, @@ -1433,6 +1463,7 @@ async def test_flash_short_and_long(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1476,7 +1507,7 @@ async def test_flash_short_and_long(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_transition(hass, mqtt_mock): +async def test_transition(hass, mqtt_mock_entry_with_yaml_config): """Test for transition time being sent when included.""" assert await async_setup_component( hass, @@ -1492,6 +1523,7 @@ async def test_transition(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1522,7 +1554,7 @@ async def test_transition(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_brightness_scale(hass, mqtt_mock): +async def test_brightness_scale(hass, mqtt_mock_entry_with_yaml_config): """Test for brightness scaling.""" assert await async_setup_component( hass, @@ -1540,6 +1572,7 @@ async def test_brightness_scale(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1563,7 +1596,7 @@ async def test_brightness_scale(hass, mqtt_mock): assert state.attributes.get("brightness") == 255 -async def test_invalid_values(hass, mqtt_mock): +async def test_invalid_values(hass, mqtt_mock_entry_with_yaml_config): """Test that invalid color/brightness/white/etc. values are ignored.""" assert await async_setup_component( hass, @@ -1584,6 +1617,7 @@ async def test_invalid_values(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -1700,77 +1734,95 @@ async def test_invalid_values(hass, mqtt_mock): assert state.attributes.get("color_temp") == 100 -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG, MQTT_LIGHT_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, + MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + light.DOMAIN, + DEFAULT_CONFIG, ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one light per unique_id.""" config = { light.DOMAIN: [ @@ -1792,16 +1844,24 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, light.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config + ) -async def test_discovery_removal(hass, mqtt_mock, caplog): +async def test_discovery_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered mqtt_json lights.""" data = '{ "name": "test",' ' "schema": "json",' ' "command_topic": "test_topic" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data) + await help_test_discovery_removal( + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + light.DOMAIN, + data, + ) -async def test_discovery_update_light(hass, mqtt_mock, caplog): +async def test_discovery_update_light(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered light.""" config1 = { "name": "Beer", @@ -1816,11 +1876,18 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): "command_topic": "test_topic", } await help_test_discovery_update( - hass, mqtt_mock, caplog, light.DOMAIN, config1, config2 + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + light.DOMAIN, + config1, + config2, ) -async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_light( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered light.""" data1 = ( '{ "name": "Beer",' @@ -1832,12 +1899,17 @@ async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.light.schema_json.MqttLightJson.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + light.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = ( @@ -1847,57 +1919,80 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + light.DOMAIN, + data1, + data2, ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_with_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG, light.SERVICE_TURN_ON, @@ -1906,7 +2001,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) -async def test_max_mireds(hass, mqtt_mock): +async def test_max_mireds(hass, mqtt_mock_entry_with_yaml_config): """Test setting min_mireds and max_mireds.""" config = { light.DOMAIN: { @@ -1921,6 +2016,7 @@ async def test_max_mireds(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -1952,7 +2048,7 @@ async def test_max_mireds(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -1972,7 +2068,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -1986,11 +2082,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -2013,7 +2111,14 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value, init_payload + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, + init_payload, ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[light.DOMAIN]) @@ -2028,7 +2133,7 @@ async def test_encoding_subscribable_topics( ] await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, config, diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index a88fc094f6d..0d4b95e9152 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -90,69 +90,53 @@ DEFAULT_CONFIG = { } -async def test_setup_fails(hass, mqtt_mock): +@pytest.mark.parametrize( + "test_config", + [ + ({"platform": "mqtt", "schema": "template", "name": "test"},), + ( + { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test_topic", + }, + ), + ( + { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_on_template": "on", + }, + ), + ( + { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test_topic", + "command_off_template": "off", + }, + ), + ], +) +async def test_setup_fails(hass, mqtt_mock_entry_no_yaml_config, test_config): """Test that setup fails with missing required configuration items.""" - with assert_setup_component(0, light.DOMAIN): + with assert_setup_component(0, light.DOMAIN) as setup_config: assert await async_setup_component( hass, light.DOMAIN, - {light.DOMAIN: {"platform": "mqtt", "schema": "template", "name": "test"}}, - ) - await hass.async_block_till_done() - assert hass.states.get("light.test") is None - - with assert_setup_component(0, light.DOMAIN): - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test_topic", - } - }, - ) - await hass.async_block_till_done() - assert hass.states.get("light.test") is None - - with assert_setup_component(0, light.DOMAIN): - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_on_template": "on", - } - }, - ) - await hass.async_block_till_done() - assert hass.states.get("light.test") is None - - with assert_setup_component(0, light.DOMAIN): - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test_topic", - "command_off_template": "off", - } - }, + {light.DOMAIN: test_config}, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() + assert not setup_config[light.DOMAIN] assert hass.states.get("light.test") is None -async def test_rgb_light(hass, mqtt_mock): +async def test_rgb_light(hass, mqtt_mock_entry_with_yaml_config): """Test RGB light flags brightness support.""" assert await async_setup_component( hass, @@ -172,6 +156,7 @@ async def test_rgb_light(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -184,7 +169,7 @@ async def test_rgb_light(hass, mqtt_mock): assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == expected_features -async def test_state_change_via_topic(hass, mqtt_mock): +async def test_state_change_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test state change via topic.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -210,6 +195,7 @@ async def test_state_change_via_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -240,7 +226,7 @@ async def test_state_change_via_topic(hass, mqtt_mock): async def test_state_brightness_color_effect_temp_white_change_via_topic( - hass, mqtt_mock + hass, mqtt_mock_entry_with_yaml_config ): """Test state, bri, color, effect, color temp, white val change.""" with assert_setup_component(1, light.DOMAIN): @@ -276,6 +262,7 @@ async def test_state_brightness_color_effect_temp_white_change_via_topic( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -340,7 +327,9 @@ async def test_state_brightness_color_effect_temp_white_change_via_topic( assert light_state.attributes.get("effect") == "rainbow" -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending of command in optimistic mode.""" fake_state = ha.State( "light.test", @@ -391,6 +380,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_ON @@ -495,7 +485,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): async def test_sending_mqtt_commands_non_optimistic_brightness_template( - hass, mqtt_mock + hass, mqtt_mock_entry_with_yaml_config ): """Test the sending of command in optimistic mode.""" with assert_setup_component(1, light.DOMAIN): @@ -532,6 +522,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -626,7 +617,7 @@ async def test_sending_mqtt_commands_non_optimistic_brightness_template( state = hass.states.get("light.test") -async def test_effect(hass, mqtt_mock): +async def test_effect(hass, mqtt_mock_entry_with_yaml_config): """Test effect sent over MQTT in optimistic mode.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -646,6 +637,7 @@ async def test_effect(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -678,7 +670,7 @@ async def test_effect(hass, mqtt_mock): assert state.attributes.get("effect") == "colorloop" -async def test_flash(hass, mqtt_mock): +async def test_flash(hass, mqtt_mock_entry_with_yaml_config): """Test flash sent over MQTT in optimistic mode.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -697,6 +689,7 @@ async def test_flash(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -726,7 +719,7 @@ async def test_flash(hass, mqtt_mock): assert state.state == STATE_ON -async def test_transition(hass, mqtt_mock): +async def test_transition(hass, mqtt_mock_entry_with_yaml_config): """Test for transition time being sent when included.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -745,6 +738,7 @@ async def test_transition(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -767,7 +761,7 @@ async def test_transition(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_invalid_values(hass, mqtt_mock): +async def test_invalid_values(hass, mqtt_mock_entry_with_yaml_config): """Test that invalid values are ignored.""" with assert_setup_component(1, light.DOMAIN): assert await async_setup_component( @@ -801,6 +795,7 @@ async def test_invalid_values(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.state == STATE_UNKNOWN @@ -867,77 +862,91 @@ async def test_invalid_values(hass, mqtt_mock): assert state.attributes.get("effect") == "rainbow" -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG, MQTT_LIGHT_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + DEFAULT_CONFIG, + MQTT_LIGHT_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one light per unique_id.""" config = { light.DOMAIN: [ @@ -961,10 +970,12 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, light.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, config + ) -async def test_discovery_removal(hass, mqtt_mock, caplog): +async def test_discovery_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered mqtt_json lights.""" data = ( '{ "name": "test",' @@ -973,10 +984,12 @@ async def test_discovery_removal(hass, mqtt_mock, caplog): ' "command_on_template": "on",' ' "command_off_template": "off"}' ) - await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data + ) -async def test_discovery_update_light(hass, mqtt_mock, caplog): +async def test_discovery_update_light(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered light.""" config1 = { "name": "Beer", @@ -995,11 +1008,13 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): "command_off_template": "off", } await help_test_discovery_update( - hass, mqtt_mock, caplog, light.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_light( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered light.""" data1 = ( '{ "name": "Beer",' @@ -1013,12 +1028,17 @@ async def test_discovery_update_unchanged_light(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.light.schema_template.MqttLightTemplate.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, light.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + light.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = ( @@ -1030,53 +1050,53 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "command_off_template": "off"}' ) await help_test_discovery_broken( - hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, light.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT light device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, light.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" config = { light.DOMAIN: { @@ -1090,11 +1110,15 @@ async def test_entity_debug_info_message(hass, mqtt_mock): } } await help_test_entity_debug_info_message( - hass, mqtt_mock, light.DOMAIN, config, light.SERVICE_TURN_ON + hass, + mqtt_mock_entry_no_yaml_config, + light.DOMAIN, + config, + light.SERVICE_TURN_ON, ) -async def test_max_mireds(hass, mqtt_mock): +async def test_max_mireds(hass, mqtt_mock_entry_with_yaml_config): """Test setting min_mireds and max_mireds.""" config = { light.DOMAIN: { @@ -1111,6 +1135,7 @@ async def test_max_mireds(hass, mqtt_mock): assert await async_setup_component(hass, light.DOMAIN, config) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("light.test") assert state.attributes.get("min_mireds") == 153 @@ -1142,7 +1167,7 @@ async def test_max_mireds(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -1162,7 +1187,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -1176,11 +1201,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = light.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -1197,14 +1224,21 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value, init_payload + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, + init_payload, ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG[light.DOMAIN]) config["state_template"] = "{{ value }}" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, light.DOMAIN, config, diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index ef752ef8749..b48557efc8f 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -58,7 +58,7 @@ DEFAULT_CONFIG = { } -async def test_controlling_state_via_topic(hass, mqtt_mock): +async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -77,6 +77,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -94,7 +95,9 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): assert state.state is STATE_UNLOCKED -async def test_controlling_non_default_state_via_topic(hass, mqtt_mock): +async def test_controlling_non_default_state_via_topic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -113,6 +116,7 @@ async def test_controlling_non_default_state_via_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -129,7 +133,9 @@ async def test_controlling_non_default_state_via_topic(hass, mqtt_mock): assert state.state is STATE_UNLOCKED -async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): +async def test_controlling_state_via_topic_and_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling state via topic and JSON message.""" assert await async_setup_component( hass, @@ -149,6 +155,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -165,7 +172,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): async def test_controlling_non_default_state_via_topic_and_json_message( - hass, mqtt_mock + hass, mqtt_mock_entry_with_yaml_config ): """Test the controlling state via topic and JSON message.""" assert await async_setup_component( @@ -186,6 +193,7 @@ async def test_controlling_non_default_state_via_topic_and_json_message( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -201,7 +209,9 @@ async def test_controlling_non_default_state_via_topic_and_json_message( assert state.state is STATE_UNLOCKED -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test optimistic mode without state topic.""" assert await async_setup_component( hass, @@ -219,6 +229,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -245,7 +256,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_explicit_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test optimistic mode without state topic.""" assert await async_setup_component( hass, @@ -265,6 +278,7 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -291,7 +305,9 @@ async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock): assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_sending_mqtt_commands_support_open_and_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_support_open_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test open function of the lock without state topic.""" assert await async_setup_component( hass, @@ -310,6 +326,7 @@ async def test_sending_mqtt_commands_support_open_and_optimistic(hass, mqtt_mock }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -348,7 +365,7 @@ async def test_sending_mqtt_commands_support_open_and_optimistic(hass, mqtt_mock async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( - hass, mqtt_mock + hass, mqtt_mock_entry_with_yaml_config ): """Test open function of the lock without state topic.""" assert await async_setup_component( @@ -370,6 +387,7 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("lock.test") assert state.state is STATE_UNLOCKED @@ -407,77 +425,91 @@ async def test_sending_mqtt_commands_support_open_and_explicit_optimistic( assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG, MQTT_LOCK_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + LOCK_DOMAIN, + DEFAULT_CONFIG, + MQTT_LOCK_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one lock per unique_id.""" config = { LOCK_DOMAIN: [ @@ -497,16 +529,20 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, LOCK_DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, config + ) -async def test_discovery_removal_lock(hass, mqtt_mock, caplog): +async def test_discovery_removal_lock(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered lock.""" data = '{ "name": "test",' ' "command_topic": "test_topic" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, LOCK_DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, LOCK_DOMAIN, data + ) -async def test_discovery_update_lock(hass, mqtt_mock, caplog): +async def test_discovery_update_lock(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered lock.""" config1 = { "name": "Beer", @@ -521,11 +557,13 @@ async def test_discovery_update_lock(hass, mqtt_mock, caplog): "availability_topic": "availability_topic2", } await help_test_discovery_update( - hass, mqtt_mock, caplog, LOCK_DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, LOCK_DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_lock(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_lock( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered lock.""" data1 = ( '{ "name": "Beer",' @@ -536,65 +574,72 @@ async def test_discovery_update_unchanged_lock(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.lock.MqttLock.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, LOCK_DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + LOCK_DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - await help_test_discovery_broken(hass, mqtt_mock, caplog, LOCK_DOMAIN, data1, data2) + await help_test_discovery_broken( + hass, mqtt_mock_entry_no_yaml_config, caplog, LOCK_DOMAIN, data1, data2 + ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT lock device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT lock device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, LOCK_DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, LOCK_DOMAIN, DEFAULT_CONFIG, SERVICE_LOCK, @@ -616,7 +661,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -630,7 +675,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -642,11 +687,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = LOCK_DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -663,12 +710,18 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, LOCK_DOMAIN, DEFAULT_CONFIG[LOCK_DOMAIN], diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 4eb8fdec351..a49b6de198d 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -64,7 +64,7 @@ DEFAULT_CONFIG = { } -async def test_run_number_setup(hass, mqtt_mock): +async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload.""" topic = "test/number" await async_setup_component( @@ -82,6 +82,7 @@ async def test_run_number_setup(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, topic, "10") @@ -108,7 +109,7 @@ async def test_run_number_setup(hass, mqtt_mock): assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "my unit" -async def test_value_template(hass, mqtt_mock): +async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload with a template.""" topic = "test/number" await async_setup_component( @@ -125,6 +126,7 @@ async def test_value_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, topic, '{"val":10}') @@ -148,7 +150,7 @@ async def test_value_template(hass, mqtt_mock): assert state.state == "unknown" -async def test_run_number_service_optimistic(hass, mqtt_mock): +async def test_run_number_service_optimistic(hass, mqtt_mock_entry_with_yaml_config): """Test that set_value service works in optimistic mode.""" topic = "test/number" @@ -170,6 +172,7 @@ async def test_run_number_service_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -215,7 +218,9 @@ async def test_run_number_service_optimistic(hass, mqtt_mock): assert state.state == "42.1" -async def test_run_number_service_optimistic_with_command_template(hass, mqtt_mock): +async def test_run_number_service_optimistic_with_command_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/number" @@ -238,6 +243,7 @@ async def test_run_number_service_optimistic_with_command_template(hass, mqtt_mo }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -285,7 +291,7 @@ async def test_run_number_service_optimistic_with_command_template(hass, mqtt_mo assert state.state == "42.1" -async def test_run_number_service(hass, mqtt_mock): +async def test_run_number_service(hass, mqtt_mock_entry_with_yaml_config): """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/number/set" state_topic = "test/number" @@ -303,6 +309,7 @@ async def test_run_number_service(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -319,7 +326,9 @@ async def test_run_number_service(hass, mqtt_mock): assert state.state == "32" -async def test_run_number_service_with_command_template(hass, mqtt_mock): +async def test_run_number_service_with_command_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test that set_value service works in non optimistic mode and with a command_template.""" cmd_topic = "test/number/set" state_topic = "test/number" @@ -338,6 +347,7 @@ async def test_run_number_service_with_command_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, state_topic, "32") state = hass.states.get("number.test_number") @@ -356,77 +366,91 @@ async def test_run_number_service_with_command_template(hass, mqtt_mock): assert state.state == "32" -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG, MQTT_NUMBER_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + number.DOMAIN, + DEFAULT_CONFIG, + MQTT_NUMBER_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, number.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, number.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one number per unique_id.""" config = { number.DOMAIN: [ @@ -446,16 +470,20 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, number.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, config + ) -async def test_discovery_removal_number(hass, mqtt_mock, caplog): +async def test_discovery_removal_number(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered number.""" data = json.dumps(DEFAULT_CONFIG[number.DOMAIN]) - await help_test_discovery_removal(hass, mqtt_mock, caplog, number.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, data + ) -async def test_discovery_update_number(hass, mqtt_mock, caplog): +async def test_discovery_update_number(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered number.""" config1 = { "name": "Beer", @@ -469,11 +497,13 @@ async def test_discovery_update_number(hass, mqtt_mock, caplog): } await help_test_discovery_update( - hass, mqtt_mock, caplog, number.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_number(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_number( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered number.""" data1 = ( '{ "name": "Beer", "state_topic": "test-topic", "command_topic": "test-topic"}' @@ -482,12 +512,17 @@ async def test_discovery_update_unchanged_number(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.number.MqttNumber.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, number.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + number.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = ( @@ -495,57 +530,57 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ) await help_test_discovery_broken( - hass, mqtt_mock, caplog, number.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, number.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT number device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT number device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, number.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, number.DOMAIN, DEFAULT_CONFIG, SERVICE_SET_VALUE, @@ -555,7 +590,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) -async def test_min_max_step_attributes(hass, mqtt_mock): +async def test_min_max_step_attributes(hass, mqtt_mock_entry_with_yaml_config): """Test min/max/step attributes.""" topic = "test/number" await async_setup_component( @@ -574,6 +609,7 @@ async def test_min_max_step_attributes(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("number.test_number") assert state.attributes.get(ATTR_MIN) == 5 @@ -581,7 +617,7 @@ async def test_min_max_step_attributes(hass, mqtt_mock): assert state.attributes.get(ATTR_STEP) == 20 -async def test_invalid_min_max_attributes(hass, caplog, mqtt_mock): +async def test_invalid_min_max_attributes(hass, caplog, mqtt_mock_entry_no_yaml_config): """Test invalid min/max attributes.""" topic = "test/number" await async_setup_component( @@ -599,11 +635,14 @@ async def test_invalid_min_max_attributes(hass, caplog, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() assert f"'{CONF_MAX}' must be > '{CONF_MIN}'" in caplog.text -async def test_mqtt_payload_not_a_number_warning(hass, caplog, mqtt_mock): +async def test_mqtt_payload_not_a_number_warning( + hass, caplog, mqtt_mock_entry_with_yaml_config +): """Test warning for MQTT payload which is not a number.""" topic = "test/number" await async_setup_component( @@ -619,6 +658,7 @@ async def test_mqtt_payload_not_a_number_warning(hass, caplog, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, topic, "not_a_number") @@ -627,7 +667,9 @@ async def test_mqtt_payload_not_a_number_warning(hass, caplog, mqtt_mock): assert "Payload 'not_a_number' is not a Number" in caplog.text -async def test_mqtt_payload_out_of_range_error(hass, caplog, mqtt_mock): +async def test_mqtt_payload_out_of_range_error( + hass, caplog, mqtt_mock_entry_with_yaml_config +): """Test error when MQTT payload is out of min/max range.""" topic = "test/number" await async_setup_component( @@ -645,6 +687,7 @@ async def test_mqtt_payload_out_of_range_error(hass, caplog, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, topic, "115.5") @@ -669,7 +712,7 @@ async def test_mqtt_payload_out_of_range_error(hass, caplog, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -683,7 +726,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -695,11 +738,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = number.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -717,12 +762,18 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, "number", DEFAULT_CONFIG["number"], diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 15bbd3964e6..eb5cb94df2d 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -34,7 +34,7 @@ DEFAULT_CONFIG = { } -async def test_sending_mqtt_commands(hass, mqtt_mock): +async def test_sending_mqtt_commands(hass, mqtt_mock_entry_with_yaml_config): """Test the sending MQTT commands.""" fake_state = ha.State("scene.test", STATE_UNKNOWN) @@ -55,6 +55,7 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("scene.test") assert state.state == STATE_UNKNOWN @@ -67,21 +68,23 @@ async def test_sending_mqtt_commands(hass, mqtt_mock): ) -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, scene.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, scene.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" config = { scene.DOMAIN: { @@ -93,11 +96,17 @@ async def test_default_availability_payload(hass, mqtt_mock): } await help_test_default_availability_payload( - hass, mqtt_mock, scene.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + scene.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" config = { scene.DOMAIN: { @@ -109,11 +118,17 @@ async def test_custom_availability_payload(hass, mqtt_mock): } await help_test_custom_availability_payload( - hass, mqtt_mock, scene.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + scene.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one scene per unique_id.""" config = { scene.DOMAIN: [ @@ -131,16 +146,20 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, scene.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, scene.DOMAIN, config + ) -async def test_discovery_removal_scene(hass, mqtt_mock, caplog): +async def test_discovery_removal_scene(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered scene.""" data = '{ "name": "test",' ' "command_topic": "test_topic" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, scene.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, scene.DOMAIN, data + ) -async def test_discovery_update_payload(hass, mqtt_mock, caplog): +async def test_discovery_update_payload(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered scene.""" config1 = copy.deepcopy(DEFAULT_CONFIG[scene.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[scene.DOMAIN]) @@ -151,7 +170,7 @@ async def test_discovery_update_payload(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, scene.DOMAIN, config1, @@ -159,32 +178,41 @@ async def test_discovery_update_payload(hass, mqtt_mock, caplog): ) -async def test_discovery_update_unchanged_scene(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_scene( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered scene.""" data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' with patch( "homeassistant.components.mqtt.scene.MqttScene.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, scene.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + scene.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, scene.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, scene.DOMAIN, data1, data2 ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = scene.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index cf5abf55854..888dd301018 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -59,7 +59,7 @@ DEFAULT_CONFIG = { } -async def test_run_select_setup(hass, mqtt_mock): +async def test_run_select_setup(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload.""" topic = "test/select" await async_setup_component( @@ -76,6 +76,7 @@ async def test_run_select_setup(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, topic, "milk") @@ -92,7 +93,7 @@ async def test_run_select_setup(hass, mqtt_mock): assert state.state == "beer" -async def test_value_template(hass, mqtt_mock): +async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload with a template.""" topic = "test/select" await async_setup_component( @@ -110,6 +111,7 @@ async def test_value_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, topic, '{"val":"milk"}') @@ -133,7 +135,7 @@ async def test_value_template(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_run_select_service_optimistic(hass, mqtt_mock): +async def test_run_select_service_optimistic(hass, mqtt_mock_entry_with_yaml_config): """Test that set_value service works in optimistic mode.""" topic = "test/select" @@ -156,6 +158,7 @@ async def test_run_select_service_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -174,7 +177,9 @@ async def test_run_select_service_optimistic(hass, mqtt_mock): assert state.state == "beer" -async def test_run_select_service_optimistic_with_command_template(hass, mqtt_mock): +async def test_run_select_service_optimistic_with_command_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/select" @@ -198,6 +203,7 @@ async def test_run_select_service_optimistic_with_command_template(hass, mqtt_mo }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("select.test_select") assert state.state == "milk" @@ -218,7 +224,7 @@ async def test_run_select_service_optimistic_with_command_template(hass, mqtt_mo assert state.state == "beer" -async def test_run_select_service(hass, mqtt_mock): +async def test_run_select_service(hass, mqtt_mock_entry_with_yaml_config): """Test that set_value service works in non optimistic mode.""" cmd_topic = "test/select/set" state_topic = "test/select" @@ -237,6 +243,7 @@ async def test_run_select_service(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -253,7 +260,9 @@ async def test_run_select_service(hass, mqtt_mock): assert state.state == "beer" -async def test_run_select_service_with_command_template(hass, mqtt_mock): +async def test_run_select_service_with_command_template( + hass, mqtt_mock_entry_with_yaml_config +): """Test that set_value service works in non optimistic mode and with a command_template.""" cmd_topic = "test/select/set" state_topic = "test/select" @@ -273,6 +282,7 @@ async def test_run_select_service_with_command_template(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, state_topic, "beer") state = hass.states.get("select.test_select") @@ -289,77 +299,91 @@ async def test_run_select_service_with_command_template(hass, mqtt_mock): ) -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG, MQTT_SELECT_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + select.DOMAIN, + DEFAULT_CONFIG, + MQTT_SELECT_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, select.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, select.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one select per unique_id.""" config = { select.DOMAIN: [ @@ -381,16 +405,20 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, select.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, config + ) -async def test_discovery_removal_select(hass, mqtt_mock, caplog): +async def test_discovery_removal_select(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered select.""" data = json.dumps(DEFAULT_CONFIG[select.DOMAIN]) - await help_test_discovery_removal(hass, mqtt_mock, caplog, select.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, data + ) -async def test_discovery_update_select(hass, mqtt_mock, caplog): +async def test_discovery_update_select(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered select.""" config1 = { "name": "Beer", @@ -406,79 +434,86 @@ async def test_discovery_update_select(hass, mqtt_mock, caplog): } await help_test_discovery_update( - hass, mqtt_mock, caplog, select.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_select(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_select( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered select.""" data1 = '{ "name": "Beer", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' with patch( "homeassistant.components.mqtt.select.MqttSelect.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, select.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + select.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk", "state_topic": "test-topic", "command_topic": "test-topic", "options": ["milk", "beer"]}' await help_test_discovery_broken( - hass, mqtt_mock, caplog, select.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, select.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT select device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT select device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, select.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, select.DOMAIN, DEFAULT_CONFIG, select.SERVICE_SELECT_OPTION, @@ -489,7 +524,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): @pytest.mark.parametrize("options", [["milk", "beer"], ["milk"], []]) -async def test_options_attributes(hass, mqtt_mock, options): +async def test_options_attributes(hass, mqtt_mock_entry_with_yaml_config, options): """Test options attribute.""" topic = "test/select" await async_setup_component( @@ -506,12 +541,15 @@ async def test_options_attributes(hass, mqtt_mock, options): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("select.test_select") assert state.attributes.get(ATTR_OPTIONS) == options -async def test_mqtt_payload_not_an_option_warning(hass, caplog, mqtt_mock): +async def test_mqtt_payload_not_an_option_warning( + hass, caplog, mqtt_mock_entry_with_yaml_config +): """Test warning for MQTT payload which is not a valid option.""" topic = "test/select" await async_setup_component( @@ -528,6 +566,7 @@ async def test_mqtt_payload_not_an_option_warning(hass, caplog, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, topic, "öl") @@ -552,7 +591,14 @@ async def test_mqtt_payload_not_an_option_warning(hass, caplog, mqtt_mock): ], ) async def test_publishing_with_custom_encoding( - hass, mqtt_mock, caplog, service, topic, parameters, payload, template + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + service, + topic, + parameters, + payload, + template, ): """Test publishing MQTT payload with different encoding.""" domain = select.DOMAIN @@ -561,7 +607,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -573,11 +619,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = select.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -595,14 +643,20 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" config = copy.deepcopy(DEFAULT_CONFIG["select"]) config["options"] = ["milk", "beer"] await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, "select", config, diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index befb5785cdd..894ecc32ecc 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -72,7 +72,9 @@ DEFAULT_CONFIG = { } -async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): +async def test_setting_sensor_value_via_mqtt_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of the value via MQTT.""" assert await async_setup_component( hass, @@ -87,6 +89,7 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100") state = hass.states.get("sensor.test") @@ -122,7 +125,13 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): ], ) async def test_setting_sensor_native_value_handling_via_mqtt_message( - hass, mqtt_mock, caplog, device_class, native_value, state_value, log + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + device_class, + native_value, + state_value, + log, ): """Test the setting of the value via MQTT.""" assert await async_setup_component( @@ -138,6 +147,7 @@ async def test_setting_sensor_native_value_handling_via_mqtt_message( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "test-topic", native_value) state = hass.states.get("sensor.test") @@ -147,7 +157,9 @@ async def test_setting_sensor_native_value_handling_via_mqtt_message( assert log == ("Invalid state message" in caplog.text) -async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, caplog): +async def test_setting_sensor_value_expires_availability_topic( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the expiration of the value.""" assert await async_setup_component( hass, @@ -164,6 +176,7 @@ async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE @@ -174,10 +187,12 @@ async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, mqtt_mock, caplog) + await expires_helper(hass, caplog) -async def test_setting_sensor_value_expires(hass, mqtt_mock, caplog): +async def test_setting_sensor_value_expires( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the expiration of the value.""" assert await async_setup_component( hass, @@ -194,15 +209,16 @@ async def test_setting_sensor_value_expires(hass, mqtt_mock, caplog): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() # State should be unavailable since expire_after is defined and > 0 state = hass.states.get("sensor.test") assert state.state == STATE_UNAVAILABLE - await expires_helper(hass, mqtt_mock, caplog) + await expires_helper(hass, caplog) -async def expires_helper(hass, mqtt_mock, caplog): +async def expires_helper(hass, caplog): """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) @@ -253,7 +269,9 @@ async def expires_helper(hass, mqtt_mock, caplog): assert state.state == STATE_UNAVAILABLE -async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_sensor_value_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of the value via MQTT with JSON payload.""" assert await async_setup_component( hass, @@ -269,6 +287,7 @@ async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "test-topic", '{ "val": "100" }') state = hass.states.get("sensor.test") @@ -277,7 +296,7 @@ async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock): async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_state( - hass, mqtt_mock + hass, mqtt_mock_entry_with_yaml_config ): """Test the setting of the value via MQTT with fall back to current state.""" assert await async_setup_component( @@ -294,6 +313,7 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message( hass, "test-topic", '{ "val": "valcontent", "par": "parcontent" }' @@ -308,7 +328,9 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st assert state.state == "valcontent-parcontent" -async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplog): +async def test_setting_sensor_last_reset_via_mqtt_message( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the setting of the last_reset property via MQTT.""" assert await async_setup_component( hass, @@ -325,6 +347,7 @@ async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplo }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") state = hass.states.get("sensor.test") @@ -338,7 +361,7 @@ async def test_setting_sensor_last_reset_via_mqtt_message(hass, mqtt_mock, caplo @pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) async def test_setting_sensor_bad_last_reset_via_mqtt_message( - hass, caplog, datestring, mqtt_mock + hass, caplog, datestring, mqtt_mock_entry_with_yaml_config ): """Test the setting of the last_reset property via MQTT.""" assert await async_setup_component( @@ -356,6 +379,7 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", datestring) state = hass.states.get("sensor.test") @@ -364,7 +388,7 @@ async def test_setting_sensor_bad_last_reset_via_mqtt_message( async def test_setting_sensor_empty_last_reset_via_mqtt_message( - hass, caplog, mqtt_mock + hass, caplog, mqtt_mock_entry_with_yaml_config ): """Test the setting of the last_reset property via MQTT.""" assert await async_setup_component( @@ -382,6 +406,7 @@ async def test_setting_sensor_empty_last_reset_via_mqtt_message( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "last-reset-topic", "") state = hass.states.get("sensor.test") @@ -389,7 +414,9 @@ async def test_setting_sensor_empty_last_reset_via_mqtt_message( assert "Ignoring empty last_reset message" in caplog.text -async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_sensor_last_reset_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of the value via MQTT with JSON payload.""" assert await async_setup_component( hass, @@ -407,6 +434,7 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message( hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' @@ -417,7 +445,7 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message(hass, mqtt_mock): @pytest.mark.parametrize("extra", [{}, {"last_reset_topic": "test-topic"}]) async def test_setting_sensor_last_reset_via_mqtt_json_message_2( - hass, mqtt_mock, caplog, extra + hass, mqtt_mock_entry_with_yaml_config, caplog, extra ): """Test the setting of the value via MQTT with JSON payload.""" assert await async_setup_component( @@ -439,6 +467,7 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message( hass, @@ -455,7 +484,7 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( ) -async def test_force_update_disabled(hass, mqtt_mock): +async def test_force_update_disabled(hass, mqtt_mock_entry_with_yaml_config): """Test force update option.""" assert await async_setup_component( hass, @@ -470,6 +499,7 @@ async def test_force_update_disabled(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() events = [] @@ -488,7 +518,7 @@ async def test_force_update_disabled(hass, mqtt_mock): assert len(events) == 1 -async def test_force_update_enabled(hass, mqtt_mock): +async def test_force_update_enabled(hass, mqtt_mock_entry_with_yaml_config): """Test force update option.""" assert await async_setup_component( hass, @@ -504,6 +534,7 @@ async def test_force_update_enabled(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() events = [] @@ -522,70 +553,80 @@ async def test_force_update_enabled(hass, mqtt_mock): assert len(events) == 2 -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_list_payload(hass, mqtt_mock): +async def test_default_availability_list_payload( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_list_payload_all(hass, mqtt_mock): +async def test_default_availability_list_payload_all( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_all( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_list_payload_any(hass, mqtt_mock): +async def test_default_availability_list_payload_any( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability by default payload with defined topic.""" await help_test_default_availability_list_payload_any( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_list_single(hass, mqtt_mock, caplog): +async def test_default_availability_list_single( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test availability list and availability_topic are mutually exclusive.""" await help_test_default_availability_list_single( - hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_availability(hass, mqtt_mock): +async def test_discovery_update_availability(hass, mqtt_mock_entry_no_yaml_config): """Test availability discovery update.""" await help_test_discovery_update_availability( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_invalid_device_class(hass, mqtt_mock): +async def test_invalid_device_class(hass, mqtt_mock_entry_no_yaml_config): """Test device_class option with invalid value.""" assert await async_setup_component( hass, @@ -600,12 +641,13 @@ async def test_invalid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test") assert state is None -async def test_valid_device_class(hass, mqtt_mock): +async def test_valid_device_class(hass, mqtt_mock_entry_with_yaml_config): """Test device_class option with valid values.""" assert await async_setup_component( hass, @@ -623,6 +665,7 @@ async def test_valid_device_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["device_class"] == "temperature" @@ -630,7 +673,7 @@ async def test_valid_device_class(hass, mqtt_mock): assert "device_class" not in state.attributes -async def test_invalid_state_class(hass, mqtt_mock): +async def test_invalid_state_class(hass, mqtt_mock_entry_no_yaml_config): """Test state_class option with invalid value.""" assert await async_setup_component( hass, @@ -645,12 +688,13 @@ async def test_invalid_state_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() state = hass.states.get("sensor.test") assert state is None -async def test_valid_state_class(hass, mqtt_mock): +async def test_valid_state_class(hass, mqtt_mock_entry_with_yaml_config): """Test state_class option with valid values.""" assert await async_setup_component( hass, @@ -668,6 +712,7 @@ async def test_valid_state_class(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("sensor.test_1") assert state.attributes["state_class"] == "measurement" @@ -675,49 +720,61 @@ async def test_valid_state_class(hass, mqtt_mock): assert "state_class" not in state.attributes -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG, MQTT_SENSOR_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + sensor.DOMAIN, + DEFAULT_CONFIG, + MQTT_SENSOR_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one sensor per unique_id.""" config = { sensor.DOMAIN: [ @@ -735,16 +792,22 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, sensor.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, config + ) -async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): +async def test_discovery_removal_sensor(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered sensor.""" data = '{ "name": "test", "state_topic": "test_topic" }' - await help_test_discovery_removal(hass, mqtt_mock, caplog, sensor.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, data + ) -async def test_discovery_update_sensor_topic_template(hass, mqtt_mock, caplog): +async def test_discovery_update_sensor_topic_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered sensor.""" config = {"name": "test", "state_topic": "test_topic"} config1 = copy.deepcopy(config) @@ -767,7 +830,7 @@ async def test_discovery_update_sensor_topic_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, config1, @@ -777,7 +840,9 @@ async def test_discovery_update_sensor_topic_template(hass, mqtt_mock, caplog): ) -async def test_discovery_update_sensor_template(hass, mqtt_mock, caplog): +async def test_discovery_update_sensor_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered sensor.""" config = {"name": "test", "state_topic": "test_topic"} config1 = copy.deepcopy(config) @@ -798,7 +863,7 @@ async def test_discovery_update_sensor_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, config1, @@ -808,71 +873,79 @@ async def test_discovery_update_sensor_template(hass, mqtt_mock, caplog): ) -async def test_discovery_update_unchanged_sensor(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_sensor( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered sensor.""" data1 = '{ "name": "Beer", "state_topic": "test_topic" }' with patch( "homeassistant.components.mqtt.sensor.MqttSensor.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, sensor.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + sensor.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer", "state_topic": "test_topic#" }' data2 = '{ "name": "Milk", "state_topic": "test_topic" }' await help_test_discovery_broken( - hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, sensor.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT sensor device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT sensor device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_hub(hass, mqtt_mock): +async def test_entity_device_info_with_hub(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT sensor device registry integration.""" + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) hub = registry.async_get_or_create( config_entry_id="123", @@ -899,53 +972,57 @@ async def test_entity_device_info_with_hub(hass, mqtt_mock): assert device.via_device_id == hub.id -async def test_entity_debug_info(hass, mqtt_mock): +async def test_entity_debug_info(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT sensor debug info.""" - await help_test_entity_debug_info(hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_debug_info( + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + ) -async def test_entity_debug_info_max_messages(hass, mqtt_mock): +async def test_entity_debug_info_max_messages(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT sensor debug info.""" await help_test_entity_debug_info_max_messages( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG, None + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG, None ) -async def test_entity_debug_info_remove(hass, mqtt_mock): +async def test_entity_debug_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT sensor debug info.""" await help_test_entity_debug_info_remove( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_update_entity_id(hass, mqtt_mock): +async def test_entity_debug_info_update_entity_id(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT sensor debug info.""" await help_test_entity_debug_info_update_entity_id( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_disabled_by_default(hass, mqtt_mock): +async def test_entity_disabled_by_default(hass, mqtt_mock_entry_no_yaml_config): """Test entity disabled by default.""" await help_test_entity_disabled_by_default( - hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG ) @pytest.mark.no_fail_on_log_exception -async def test_entity_category(hass, mqtt_mock): +async def test_entity_category(hass, mqtt_mock_entry_no_yaml_config): """Test entity category.""" - await help_test_entity_category(hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_category( + hass, mqtt_mock_entry_no_yaml_config, sensor.DOMAIN, DEFAULT_CONFIG + ) -async def test_value_template_with_entity_id(hass, mqtt_mock): +async def test_value_template_with_entity_id(hass, mqtt_mock_entry_with_yaml_config): """Test the access to attributes in value_template via the entity_id.""" assert await async_setup_component( hass, @@ -966,6 +1043,7 @@ async def test_value_template_with_entity_id(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "test-topic", "100") state = hass.states.get("sensor.test") @@ -973,11 +1051,13 @@ async def test_value_template_with_entity_id(hass, mqtt_mock): assert state.state == "101" -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = sensor.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -988,7 +1068,7 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): async def test_cleanup_triggers_and_restoring_state( - hass, mqtt_mock, caplog, tmp_path, freezer + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, freezer ): """Test cleanup old triggers at reloading and restoring the state.""" domain = sensor.DOMAIN @@ -1014,6 +1094,7 @@ async def test_cleanup_triggers_and_restoring_state( {domain: [config1, config2]}, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "test-topic1", "100") state = hass.states.get("sensor.test1") assert state.state == "38" # 100 °F -> 38 °C @@ -1053,7 +1134,7 @@ async def test_cleanup_triggers_and_restoring_state( async def test_skip_restoring_state_with_over_due_expire_trigger( - hass, mqtt_mock, caplog, freezer + hass, mqtt_mock_entry_with_yaml_config, caplog, freezer ): """Test restoring a state with over due expire timer.""" @@ -1081,6 +1162,7 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ): assert await async_setup_component(hass, domain, {domain: config3}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() assert "Skip state recovery after reload for sensor.test3" in caplog.text @@ -1092,12 +1174,18 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, sensor.DOMAIN, DEFAULT_CONFIG[sensor.DOMAIN], diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 197ed34b7e4..2db2060c133 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -70,7 +70,7 @@ async def async_turn_off(hass, entity_id=ENTITY_MATCH_ALL) -> None: await hass.services.async_call(siren.DOMAIN, SERVICE_TURN_OFF, data, blocking=True) -async def test_controlling_state_via_topic(hass, mqtt_mock): +async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -87,6 +87,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -103,7 +104,9 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending MQTT commands in optimistic mode.""" assert await async_setup_component( hass, @@ -120,6 +123,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_OFF @@ -143,7 +147,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, caplog): +async def test_controlling_state_via_topic_and_json_message( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test the controlling state via topic and JSON message.""" assert await async_setup_component( hass, @@ -161,6 +167,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, cap }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -181,7 +188,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock, cap async def test_controlling_state_and_attributes_with_json_message_without_template( - hass, mqtt_mock, caplog + hass, mqtt_mock_entry_with_yaml_config, caplog ): """Test the controlling state via topic and JSON message without a value template.""" assert await async_setup_component( @@ -200,6 +207,7 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -262,7 +270,9 @@ async def test_controlling_state_and_attributes_with_json_message_without_templa ) -async def test_filtering_not_supported_attributes_optimistic(hass, mqtt_mock): +async def test_filtering_not_supported_attributes_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test setting attributes with support flags optimistic.""" config = { "platform": "mqtt", @@ -285,6 +295,7 @@ async def test_filtering_not_supported_attributes_optimistic(hass, mqtt_mock): {siren.DOMAIN: [config1, config2, config3]}, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_OFF @@ -345,7 +356,9 @@ async def test_filtering_not_supported_attributes_optimistic(hass, mqtt_mock): assert state3.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.88 -async def test_filtering_not_supported_attributes_via_state(hass, mqtt_mock): +async def test_filtering_not_supported_attributes_via_state( + hass, mqtt_mock_entry_with_yaml_config +): """Test setting attributes with support flags via state.""" config = { "platform": "mqtt", @@ -371,6 +384,7 @@ async def test_filtering_not_supported_attributes_via_state(hass, mqtt_mock): {siren.DOMAIN: [config1, config2, config3]}, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state1 = hass.states.get("siren.test1") assert state1.state == STATE_UNKNOWN @@ -422,21 +436,23 @@ async def test_filtering_not_supported_attributes_via_state(hass, mqtt_mock): assert state3.attributes.get(siren.ATTR_VOLUME_LEVEL) == 0.88 -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" config = { siren.DOMAIN: { @@ -450,11 +466,17 @@ async def test_default_availability_payload(hass, mqtt_mock): } await help_test_default_availability_payload( - hass, mqtt_mock, siren.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + siren.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" config = { siren.DOMAIN: { @@ -468,11 +490,17 @@ async def test_custom_availability_payload(hass, mqtt_mock): } await help_test_custom_availability_payload( - hass, mqtt_mock, siren.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + siren.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_custom_state_payload(hass, mqtt_mock): +async def test_custom_state_payload(hass, mqtt_mock_entry_with_yaml_config): """Test the state payload.""" assert await async_setup_component( hass, @@ -491,6 +519,7 @@ async def test_custom_state_payload(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("siren.test") assert state.state == STATE_UNKNOWN @@ -507,49 +536,57 @@ async def test_custom_state_payload(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG, {} + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG, {} ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one siren per unique_id.""" config = { siren.DOMAIN: [ @@ -569,20 +606,26 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, siren.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, config + ) -async def test_discovery_removal_siren(hass, mqtt_mock, caplog): +async def test_discovery_removal_siren(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered siren.""" data = ( '{ "name": "test",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - await help_test_discovery_removal(hass, mqtt_mock, caplog, siren.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, data + ) -async def test_discovery_update_siren_topic_template(hass, mqtt_mock, caplog): +async def test_discovery_update_siren_topic_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered siren.""" config1 = copy.deepcopy(DEFAULT_CONFIG[siren.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[siren.DOMAIN]) @@ -607,7 +650,7 @@ async def test_discovery_update_siren_topic_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, config1, @@ -617,7 +660,9 @@ async def test_discovery_update_siren_topic_template(hass, mqtt_mock, caplog): ) -async def test_discovery_update_siren_template(hass, mqtt_mock, caplog): +async def test_discovery_update_siren_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered siren.""" config1 = copy.deepcopy(DEFAULT_CONFIG[siren.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[siren.DOMAIN]) @@ -640,7 +685,7 @@ async def test_discovery_update_siren_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, config1, @@ -650,7 +695,7 @@ async def test_discovery_update_siren_template(hass, mqtt_mock, caplog): ) -async def test_command_templates(hass, mqtt_mock, caplog): +async def test_command_templates(hass, mqtt_mock_entry_with_yaml_config, caplog): """Test siren with command templates optimistic.""" config1 = copy.deepcopy(DEFAULT_CONFIG[siren.DOMAIN]) config1["name"] = "Beer" @@ -669,6 +714,7 @@ async def test_command_templates(hass, mqtt_mock, caplog): {siren.DOMAIN: [config1, config2]}, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state1 = hass.states.get("siren.beer") assert state1.state == STATE_OFF @@ -729,7 +775,9 @@ async def test_command_templates(hass, mqtt_mock, caplog): mqtt_mock.reset_mock() -async def test_discovery_update_unchanged_siren(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_siren( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered siren.""" data1 = ( '{ "name": "Beer",' @@ -741,12 +789,17 @@ async def test_discovery_update_unchanged_siren(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.siren.MqttSiren.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, siren.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + siren.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = ( @@ -755,57 +808,57 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock, caplog, siren.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, siren.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT siren device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT siren device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, siren.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, siren.DOMAIN, DEFAULT_CONFIG, siren.SERVICE_TURN_ON, @@ -834,7 +887,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -849,7 +902,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -861,11 +914,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = siren.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -882,12 +937,18 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, siren.DOMAIN, DEFAULT_CONFIG[siren.DOMAIN], diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index c1017446eff..f20a881dda1 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -87,12 +87,13 @@ DEFAULT_CONFIG_2 = { } -async def test_default_supported_features(hass, mqtt_mock): +async def test_default_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test that the correct supported features.""" assert await async_setup_component( hass, vacuum.DOMAIN, {vacuum.DOMAIN: DEFAULT_CONFIG} ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( @@ -100,7 +101,7 @@ async def test_default_supported_features(hass, mqtt_mock): ) -async def test_all_commands(hass, mqtt_mock): +async def test_all_commands(hass, mqtt_mock_entry_with_yaml_config): """Test simple commands send to the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -109,6 +110,7 @@ async def test_all_commands(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -171,7 +173,9 @@ async def test_all_commands(hass, mqtt_mock): } -async def test_commands_without_supported_features(hass, mqtt_mock): +async def test_commands_without_supported_features( + hass, mqtt_mock_entry_with_yaml_config +): """Test commands which are not supported by the vacuum.""" config = deepcopy(DEFAULT_CONFIG) services = mqttvacuum.STRING_TO_SERVICE["status"] @@ -181,6 +185,7 @@ async def test_commands_without_supported_features(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() await hass.services.async_call( DOMAIN, SERVICE_START, {"entity_id": ENTITY_MATCH_ALL}, blocking=True @@ -228,7 +233,7 @@ async def test_commands_without_supported_features(hass, mqtt_mock): mqtt_mock.async_publish.assert_not_called() -async def test_status(hass, mqtt_mock): +async def test_status(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -237,6 +242,7 @@ async def test_status(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("vacuum.mqtttest") assert state.state == STATE_UNKNOWN @@ -272,7 +278,7 @@ async def test_status(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_no_fan_vacuum(hass, mqtt_mock): +async def test_no_fan_vacuum(hass, mqtt_mock_entry_with_yaml_config): """Test status updates from the vacuum when fan is not supported.""" config = deepcopy(DEFAULT_CONFIG) del config[mqttvacuum.CONF_FAN_SPEED_LIST] @@ -282,6 +288,7 @@ async def test_no_fan_vacuum(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() message = """{ "battery_level": 54, @@ -323,7 +330,7 @@ async def test_no_fan_vacuum(hass, mqtt_mock): @pytest.mark.no_fail_on_log_exception -async def test_status_invalid_json(hass, mqtt_mock): +async def test_status_invalid_json(hass, mqtt_mock_entry_with_yaml_config): """Test to make sure nothing breaks if the vacuum sends bad JSON.""" config = deepcopy(DEFAULT_CONFIG) config[mqttvacuum.CONF_SUPPORTED_FEATURES] = services_to_strings( @@ -332,83 +339,98 @@ async def test_status_invalid_json(hass, mqtt_mock): assert await async_setup_component(hass, vacuum.DOMAIN, {vacuum.DOMAIN: config}) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() async_fire_mqtt_message(hass, "vacuum/state", '{"asdfasas false}') state = hass.states.get("vacuum.mqtttest") assert state.state == STATE_UNKNOWN -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" await help_test_default_availability_payload( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" await help_test_custom_availability_payload( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2, MQTT_VACUUM_ATTRIBUTES_BLOCKED + hass, + mqtt_mock_entry_no_yaml_config, + vacuum.DOMAIN, + DEFAULT_CONFIG_2, + MQTT_VACUUM_ATTRIBUTES_BLOCKED, ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_json( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one vacuum per unique_id.""" config = { vacuum.DOMAIN: [ @@ -428,92 +450,103 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, vacuum.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, config + ) -async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): +async def test_discovery_removal_vacuum(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered vacuum.""" data = '{ "schema": "state", "name": "test", "command_topic": "test_topic"}' - await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data + ) -async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): +async def test_discovery_update_vacuum(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered vacuum.""" config1 = {"schema": "state", "name": "Beer", "command_topic": "test_topic"} config2 = {"schema": "state", "name": "Milk", "command_topic": "test_topic"} await help_test_discovery_update( - hass, mqtt_mock, caplog, vacuum.DOMAIN, config1, config2 + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, config1, config2 ) -async def test_discovery_update_unchanged_vacuum(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_vacuum( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered vacuum.""" data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic"}' with patch( "homeassistant.components.mqtt.vacuum.schema_state.MqttStateVacuum.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + vacuum.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "schema": "state", "name": "Beer", "command_topic": "test_topic#"}' data2 = '{ "schema": "state", "name": "Milk", "command_topic": "test_topic"}' await help_test_discovery_broken( - hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, vacuum.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT vacuum device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_with_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + hass, mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2 ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, vacuum.DOMAIN, DEFAULT_CONFIG_2, vacuum.SERVICE_START, @@ -564,7 +597,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -590,7 +623,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -602,11 +635,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = vacuum.DOMAIN config = DEFAULT_CONFIG - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -634,12 +669,18 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, vacuum.DOMAIN, DEFAULT_CONFIG, diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index e2ffc602ddd..7c1663b9c09 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -11,8 +11,9 @@ from homeassistant.core import callback from tests.common import async_fire_mqtt_message -async def test_subscribe_topics(hass, mqtt_mock, caplog): +async def test_subscribe_topics(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test subscription to topics.""" + await mqtt_mock_entry_no_yaml_config() calls1 = [] @callback @@ -59,8 +60,9 @@ async def test_subscribe_topics(hass, mqtt_mock, caplog): assert len(calls2) == 1 -async def test_modify_topics(hass, mqtt_mock, caplog): +async def test_modify_topics(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test modification of topics.""" + await mqtt_mock_entry_no_yaml_config() calls1 = [] @callback @@ -121,8 +123,9 @@ async def test_modify_topics(hass, mqtt_mock, caplog): assert len(calls2) == 1 -async def test_qos_encoding_default(hass, mqtt_mock, caplog): +async def test_qos_encoding_default(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test default qos and encoding.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() @callback def msg_callback(*args): @@ -136,11 +139,12 @@ async def test_qos_encoding_default(hass, mqtt_mock, caplog): {"test_topic1": {"topic": "test-topic1", "msg_callback": msg_callback}}, ) await async_subscribe_topics(hass, sub_state) - mqtt_mock.async_subscribe.assert_called_once_with("test-topic1", ANY, 0, "utf-8") + mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 0, "utf-8") -async def test_qos_encoding_custom(hass, mqtt_mock, caplog): +async def test_qos_encoding_custom(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test custom qos and encoding.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() @callback def msg_callback(*args): @@ -161,11 +165,12 @@ async def test_qos_encoding_custom(hass, mqtt_mock, caplog): }, ) await async_subscribe_topics(hass, sub_state) - mqtt_mock.async_subscribe.assert_called_once_with("test-topic1", ANY, 1, "utf-16") + mqtt_mock.async_subscribe.assert_called_with("test-topic1", ANY, 1, "utf-16") -async def test_no_change(hass, mqtt_mock, caplog): +async def test_no_change(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test subscription to topics without change.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() calls = [] diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 699f0de87f0..b217bf40c22 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -53,7 +53,7 @@ DEFAULT_CONFIG = { } -async def test_controlling_state_via_topic(hass, mqtt_mock): +async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( hass, @@ -71,6 +71,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -93,7 +94,9 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): +async def test_sending_mqtt_commands_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the sending MQTT commands in optimistic mode.""" fake_state = ha.State("switch.test", "on") @@ -116,6 +119,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_ON @@ -139,7 +143,9 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_sending_inital_state_and_optimistic(hass, mqtt_mock): +async def test_sending_inital_state_and_optimistic( + hass, mqtt_mock_entry_with_yaml_config +): """Test the initial state in optimistic mode.""" assert await async_setup_component( hass, @@ -153,13 +159,16 @@ async def test_sending_inital_state_and_optimistic(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN assert state.attributes.get(ATTR_ASSUMED_STATE) -async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): +async def test_controlling_state_via_topic_and_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the controlling state via topic and JSON message.""" assert await async_setup_component( hass, @@ -177,6 +186,7 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -197,21 +207,23 @@ async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock): assert state.state == STATE_UNKNOWN -async def test_availability_when_connection_lost(hass, mqtt_mock): +async def test_availability_when_connection_lost( + hass, mqtt_mock_entry_with_yaml_config +): """Test availability after MQTT disconnection.""" await help_test_availability_when_connection_lost( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_availability_without_topic(hass, mqtt_mock): +async def test_availability_without_topic(hass, mqtt_mock_entry_with_yaml_config): """Test availability without defined availability topic.""" await help_test_availability_without_topic( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_default_availability_payload(hass, mqtt_mock): +async def test_default_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by default payload with defined topic.""" config = { switch.DOMAIN: { @@ -225,11 +237,17 @@ async def test_default_availability_payload(hass, mqtt_mock): } await help_test_default_availability_payload( - hass, mqtt_mock, switch.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + switch.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_custom_availability_payload(hass, mqtt_mock): +async def test_custom_availability_payload(hass, mqtt_mock_entry_with_yaml_config): """Test availability by custom payload with defined topic.""" config = { switch.DOMAIN: { @@ -243,11 +261,17 @@ async def test_custom_availability_payload(hass, mqtt_mock): } await help_test_custom_availability_payload( - hass, mqtt_mock, switch.DOMAIN, config, True, "state-topic", "1" + hass, + mqtt_mock_entry_with_yaml_config, + switch.DOMAIN, + config, + True, + "state-topic", + "1", ) -async def test_custom_state_payload(hass, mqtt_mock): +async def test_custom_state_payload(hass, mqtt_mock_entry_with_yaml_config): """Test the state payload.""" assert await async_setup_component( hass, @@ -266,6 +290,7 @@ async def test_custom_state_payload(hass, mqtt_mock): }, ) await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() state = hass.states.get("switch.test") assert state.state == STATE_UNKNOWN @@ -282,49 +307,57 @@ async def test_custom_state_payload(hass, mqtt_mock): assert state.state == STATE_OFF -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_with_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_setting_blocked_attribute_via_mqtt_json_message(hass, mqtt_mock): +async def test_setting_blocked_attribute_via_mqtt_json_message( + hass, mqtt_mock_entry_no_yaml_config +): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_blocked_attribute_via_mqtt_json_message( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG, {} + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG, {} ) -async def test_setting_attribute_with_template(hass, mqtt_mock): +async def test_setting_attribute_with_template(hass, mqtt_mock_entry_with_yaml_config): """Test the setting of attribute via MQTT with JSON payload.""" await help_test_setting_attribute_with_template( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_not_dict( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock_entry_with_yaml_config, caplog +): """Test attributes get extracted from a JSON result.""" await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_discovery_update_attr(hass, mqtt_mock, caplog): +async def test_discovery_update_attr(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test update of discovered MQTTAttributes.""" await help_test_discovery_update_attr( - hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_unique_id(hass, mqtt_mock): +async def test_unique_id(hass, mqtt_mock_entry_with_yaml_config): """Test unique id option only creates one switch per unique_id.""" config = { switch.DOMAIN: [ @@ -344,20 +377,26 @@ async def test_unique_id(hass, mqtt_mock): }, ] } - await help_test_unique_id(hass, mqtt_mock, switch.DOMAIN, config) + await help_test_unique_id( + hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, config + ) -async def test_discovery_removal_switch(hass, mqtt_mock, caplog): +async def test_discovery_removal_switch(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of discovered switch.""" data = ( '{ "name": "test",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - await help_test_discovery_removal(hass, mqtt_mock, caplog, switch.DOMAIN, data) + await help_test_discovery_removal( + hass, mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, data + ) -async def test_discovery_update_switch_topic_template(hass, mqtt_mock, caplog): +async def test_discovery_update_switch_topic_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered switch.""" config1 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN]) @@ -382,7 +421,7 @@ async def test_discovery_update_switch_topic_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, config1, @@ -392,7 +431,9 @@ async def test_discovery_update_switch_topic_template(hass, mqtt_mock, caplog): ) -async def test_discovery_update_switch_template(hass, mqtt_mock, caplog): +async def test_discovery_update_switch_template( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered switch.""" config1 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN]) config2 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN]) @@ -415,7 +456,7 @@ async def test_discovery_update_switch_template(hass, mqtt_mock, caplog): await help_test_discovery_update( hass, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, config1, @@ -425,7 +466,9 @@ async def test_discovery_update_switch_template(hass, mqtt_mock, caplog): ) -async def test_discovery_update_unchanged_switch(hass, mqtt_mock, caplog): +async def test_discovery_update_unchanged_switch( + hass, mqtt_mock_entry_no_yaml_config, caplog +): """Test update of discovered switch.""" data1 = ( '{ "name": "Beer",' @@ -437,12 +480,17 @@ async def test_discovery_update_unchanged_switch(hass, mqtt_mock, caplog): "homeassistant.components.mqtt.switch.MqttSwitch.discovery_update" ) as discovery_update: await help_test_discovery_update_unchanged( - hass, mqtt_mock, caplog, switch.DOMAIN, data1, discovery_update + hass, + mqtt_mock_entry_no_yaml_config, + caplog, + switch.DOMAIN, + data1, + discovery_update, ) @pytest.mark.no_fail_on_log_exception -async def test_discovery_broken(hass, mqtt_mock, caplog): +async def test_discovery_broken(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test handling of bad discovery message.""" data1 = '{ "name": "Beer" }' data2 = ( @@ -451,56 +499,60 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "command_topic": "test_topic" }' ) await help_test_discovery_broken( - hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2 + hass, mqtt_mock_entry_no_yaml_config, caplog, switch.DOMAIN, data1, data2 ) -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT switch device registry integration.""" await help_test_entity_device_info_with_connection( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT switch device registry integration.""" await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" await help_test_entity_device_info_update( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_device_info_remove(hass, mqtt_mock): +async def test_entity_device_info_remove(hass, mqtt_mock_entry_no_yaml_config): """Test device registry remove.""" await help_test_entity_device_info_remove( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_subscriptions(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock_entry_with_yaml_config): """Test MQTT subscriptions are managed when entity_id is updated.""" await help_test_entity_id_update_subscriptions( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_with_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_id_update_discovery_update(hass, mqtt_mock): +async def test_entity_id_update_discovery_update(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT discovery update when entity_id is updated.""" await help_test_entity_id_update_discovery_update( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + hass, mqtt_mock_entry_no_yaml_config, switch.DOMAIN, DEFAULT_CONFIG ) -async def test_entity_debug_info_message(hass, mqtt_mock): +async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT debug info.""" await help_test_entity_debug_info_message( - hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG, switch.SERVICE_TURN_ON + hass, + mqtt_mock_entry_no_yaml_config, + switch.DOMAIN, + DEFAULT_CONFIG, + switch.SERVICE_TURN_ON, ) @@ -525,7 +577,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock): ) async def test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, service, topic, @@ -539,7 +591,7 @@ async def test_publishing_with_custom_encoding( await help_test_publishing_with_custom_encoding( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, domain, config, @@ -551,11 +603,13 @@ async def test_publishing_with_custom_encoding( ) -async def test_reloadable(hass, mqtt_mock, caplog, tmp_path): +async def test_reloadable(hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path): """Test reloading the MQTT platform.""" domain = switch.DOMAIN config = DEFAULT_CONFIG[domain] - await help_test_reloadable(hass, mqtt_mock, caplog, tmp_path, domain, config) + await help_test_reloadable( + hass, mqtt_mock_entry_with_yaml_config, caplog, tmp_path, domain, config + ) async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): @@ -572,12 +626,18 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): ], ) async def test_encoding_subscribable_topics( - hass, mqtt_mock, caplog, topic, value, attribute, attribute_value + hass, + mqtt_mock_entry_with_yaml_config, + caplog, + topic, + value, + attribute, + attribute_value, ): """Test handling of incoming encoded payload.""" await help_test_encoding_subscribable_topics( hass, - mqtt_mock, + mqtt_mock_entry_with_yaml_config, caplog, switch.DOMAIN, DEFAULT_CONFIG[switch.DOMAIN], diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 99e2fffc085..09be31011f2 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -62,8 +62,11 @@ def tag_mock(): @pytest.mark.no_fail_on_log_exception -async def test_discover_bad_tag(hass, device_reg, entity_reg, mqtt_mock, tag_mock): +async def test_discover_bad_tag( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config, tag_mock +): """Test bad discovery message.""" + await mqtt_mock_entry_no_yaml_config() config1 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) # Test sending bad data @@ -84,9 +87,10 @@ async def test_discover_bad_tag(hass, device_reg, entity_reg, mqtt_mock, tag_moc async def test_if_fires_on_mqtt_message_with_device( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning, with device.""" + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -100,9 +104,10 @@ async def test_if_fires_on_mqtt_message_with_device( async def test_if_fires_on_mqtt_message_without_device( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning, without device.""" + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(DEFAULT_CONFIG) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -115,9 +120,10 @@ async def test_if_fires_on_mqtt_message_without_device( async def test_if_fires_on_mqtt_message_with_template( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning, with device.""" + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(DEFAULT_CONFIG_JSON) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -130,8 +136,9 @@ async def test_if_fires_on_mqtt_message_with_template( tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) -async def test_strip_tag_id(hass, device_reg, mqtt_mock, tag_mock): +async def test_strip_tag_id(hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock): """Test strip whitespace from tag_id.""" + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(DEFAULT_CONFIG) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -144,9 +151,10 @@ async def test_strip_tag_id(hass, device_reg, mqtt_mock, tag_mock): async def test_if_fires_on_mqtt_message_after_update_with_device( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning after update.""" + await mqtt_mock_entry_no_yaml_config() config1 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) config1["some_future_option_1"] = "future_option_1" config2 = copy.deepcopy(DEFAULT_CONFIG_DEVICE) @@ -190,9 +198,10 @@ async def test_if_fires_on_mqtt_message_after_update_with_device( async def test_if_fires_on_mqtt_message_after_update_without_device( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning after update.""" + await mqtt_mock_entry_no_yaml_config() config1 = copy.deepcopy(DEFAULT_CONFIG) config2 = copy.deepcopy(DEFAULT_CONFIG) config2["topic"] = "foobar/tag_scanned2" @@ -233,9 +242,10 @@ async def test_if_fires_on_mqtt_message_after_update_without_device( async def test_if_fires_on_mqtt_message_after_update_with_template( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning after update.""" + await mqtt_mock_entry_no_yaml_config() config1 = copy.deepcopy(DEFAULT_CONFIG_JSON) config2 = copy.deepcopy(DEFAULT_CONFIG_JSON) config2["value_template"] = "{{ value_json.RDM6300.UID }}" @@ -277,8 +287,11 @@ async def test_if_fires_on_mqtt_message_after_update_with_template( tag_mock.assert_called_once_with(ANY, DEFAULT_TAG_ID, device_entry.id) -async def test_no_resubscribe_same_topic(hass, device_reg, mqtt_mock): +async def test_no_resubscribe_same_topic( + hass, device_reg, mqtt_mock_entry_no_yaml_config +): """Test subscription to topics without change.""" + mqtt_mock = await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -292,9 +305,10 @@ async def test_no_resubscribe_same_topic(hass, device_reg, mqtt_mock): async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning after removal.""" + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -325,9 +339,10 @@ async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_with_device( async def test_not_fires_on_mqtt_message_after_remove_by_mqtt_without_device( - hass, device_reg, mqtt_mock, tag_mock + hass, device_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test tag scanning not firing after removal.""" + await mqtt_mock_entry_no_yaml_config() config = copy.deepcopy(DEFAULT_CONFIG) async_fire_mqtt_message(hass, "homeassistant/tag/bla1/config", json.dumps(config)) @@ -360,11 +375,13 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( hass, hass_ws_client, device_reg, - mqtt_mock, + mqtt_mock_entry_no_yaml_config, tag_mock, ): """Test tag scanning after removal.""" assert await async_setup_component(hass, "config", {}) + await hass.async_block_till_done() + await mqtt_mock_entry_no_yaml_config() ws_client = await hass_ws_client(hass) config = copy.deepcopy(DEFAULT_CONFIG_DEVICE) @@ -397,8 +414,9 @@ async def test_not_fires_on_mqtt_message_after_remove_from_registry( tag_mock.assert_not_called() -async def test_entity_device_info_with_connection(hass, mqtt_mock): +async def test_entity_device_info_with_connection(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT device registry integration.""" + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) data = json.dumps( @@ -427,8 +445,9 @@ async def test_entity_device_info_with_connection(hass, mqtt_mock): assert device.sw_version == "0.1-beta" -async def test_entity_device_info_with_identifier(hass, mqtt_mock): +async def test_entity_device_info_with_identifier(hass, mqtt_mock_entry_no_yaml_config): """Test MQTT device registry integration.""" + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) data = json.dumps( @@ -455,8 +474,9 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): assert device.sw_version == "0.1-beta" -async def test_entity_device_info_update(hass, mqtt_mock): +async def test_entity_device_info_update(hass, mqtt_mock_entry_no_yaml_config): """Test device registry update.""" + await mqtt_mock_entry_no_yaml_config() registry = dr.async_get(hass) config = { @@ -489,9 +509,13 @@ async def test_entity_device_info_update(hass, mqtt_mock): assert device.name == "Milk" -async def test_cleanup_tag(hass, hass_ws_client, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_tag( + hass, hass_ws_client, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test tag discovery topic is cleaned when device is removed from registry.""" assert await async_setup_component(hass, "config", {}) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_no_yaml_config() ws_client = await hass_ws_client(hass) mqtt_entry = hass.config_entries.async_entries("mqtt")[0] @@ -566,8 +590,11 @@ async def test_cleanup_tag(hass, hass_ws_client, device_reg, entity_reg, mqtt_mo ) -async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test removal from device registry when tag is removed.""" + await mqtt_mock_entry_no_yaml_config() config = { "topic": "test-topic", "device": {"identifiers": ["helloworld"]}, @@ -590,9 +617,10 @@ async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): async def test_cleanup_device_several_tags( - hass, device_reg, entity_reg, mqtt_mock, tag_mock + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config, tag_mock ): """Test removal from device registry when the last tag is removed.""" + await mqtt_mock_entry_no_yaml_config() config1 = { "topic": "test-topic1", "device": {"identifiers": ["helloworld"]}, @@ -634,12 +662,13 @@ async def test_cleanup_device_several_tags( async def test_cleanup_device_with_entity_and_trigger_1( - hass, device_reg, entity_reg, mqtt_mock + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): """Test removal from device registry for device with tag, entity and trigger. Tag removed first, then trigger and entity. """ + await mqtt_mock_entry_no_yaml_config() config1 = { "topic": "test-topic", "device": {"identifiers": ["helloworld"]}, @@ -697,11 +726,14 @@ async def test_cleanup_device_with_entity_and_trigger_1( assert device_entry is None -async def test_cleanup_device_with_entity2(hass, device_reg, entity_reg, mqtt_mock): +async def test_cleanup_device_with_entity2( + hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config +): """Test removal from device registry for device with tag, entity and trigger. Trigger and entity removed first, then tag. """ + await mqtt_mock_entry_no_yaml_config() config1 = { "topic": "test-topic", "device": {"identifiers": ["helloworld"]}, diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index c2c77dcddd8..a4079558c34 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -18,9 +18,10 @@ def calls(hass): @pytest.fixture(autouse=True) -def setup_comp(hass, mqtt_mock): +async def setup_comp(hass, mqtt_mock_entry_no_yaml_config): """Initialize components.""" mock_component(hass, "group") + return await mqtt_mock_entry_no_yaml_config() async def test_if_fires_on_topic_match(hass, calls): @@ -213,7 +214,7 @@ async def test_if_not_fires_on_topic_but_no_payload_match(hass, calls): assert len(calls) == 0 -async def test_encoding_default(hass, calls, mqtt_mock): +async def test_encoding_default(hass, calls, setup_comp): """Test default encoding.""" assert await async_setup_component( hass, @@ -226,10 +227,10 @@ async def test_encoding_default(hass, calls, mqtt_mock): }, ) - mqtt_mock.async_subscribe.assert_called_once_with("test-topic", ANY, 0, "utf-8") + setup_comp.async_subscribe.assert_called_with("test-topic", ANY, 0, "utf-8") -async def test_encoding_custom(hass, calls, mqtt_mock): +async def test_encoding_custom(hass, calls, setup_comp): """Test default encoding.""" assert await async_setup_component( hass, @@ -242,4 +243,4 @@ async def test_encoding_custom(hass, calls, mqtt_mock): }, ) - mqtt_mock.async_subscribe.assert_called_once_with("test-topic", ANY, 0, None) + setup_comp.async_subscribe.assert_called_with("test-topic", ANY, 0, None) diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index d17484cc5e9..b0cba664250 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -26,7 +26,7 @@ LOCATION_MESSAGE_INCOMPLETE = {"longitude": 2.0} @pytest.fixture(autouse=True) -def setup_comp(hass, mqtt_mock): +async def setup_comp(hass, mqtt_mock_entry_with_yaml_config): """Initialize components.""" yaml_devices = hass.config.path(YAML_DEVICES) yield diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index c3b8704c754..b17a2bed457 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -47,7 +47,7 @@ async def assert_distance(hass, distance): assert state.attributes.get("distance") == distance -async def test_room_update(hass, mqtt_mock): +async def test_room_update(hass, mqtt_mock_entry_with_yaml_config): """Test the updating between rooms.""" assert await async_setup_component( hass, diff --git a/tests/components/tasmota/test_discovery.py b/tests/components/tasmota/test_discovery.py index 0b7e3726482..a97b877d819 100644 --- a/tests/components/tasmota/test_discovery.py +++ b/tests/components/tasmota/test_discovery.py @@ -1,7 +1,7 @@ """The tests for the MQTT discovery.""" import copy import json -from unittest.mock import patch +from unittest.mock import ANY, patch from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.components.tasmota.discovery import ALREADY_DISCOVERED @@ -19,9 +19,7 @@ async def test_subscribing_config_topic(hass, mqtt_mock, setup_tasmota): discovery_topic = DEFAULT_PREFIX assert mqtt_mock.async_subscribe.called - call_args = mqtt_mock.async_subscribe.mock_calls[0][1] - assert call_args[0] == discovery_topic + "/#" - assert call_args[2] == 0 + mqtt_mock.async_subscribe.assert_any_call(discovery_topic + "/#", ANY, 0, "utf-8") async def test_future_discovery_message(hass, mqtt_mock, caplog): diff --git a/tests/conftest.py b/tests/conftest.py index 8ea6e114e9a..97b1a959d2c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager import functools import logging import ssl @@ -547,8 +548,19 @@ def mqtt_client_mock(hass): @pytest.fixture -async def mqtt_mock(hass, mqtt_client_mock, mqtt_config): +async def mqtt_mock( + hass, + mqtt_client_mock, + mqtt_config, + mqtt_mock_entry_no_yaml_config, +): """Fixture to mock MQTT component.""" + return await mqtt_mock_entry_no_yaml_config() + + +@asynccontextmanager +async def _mqtt_mock_entry(hass, mqtt_client_mock, mqtt_config): + """Fixture to mock a delayed setup of the MQTT config entry.""" if mqtt_config is None: mqtt_config = {mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}} @@ -557,29 +569,79 @@ async def mqtt_mock(hass, mqtt_client_mock, mqtt_config): entry = MockConfigEntry( data=mqtt_config, domain=mqtt.DOMAIN, - title="Tasmota", + title="MQTT", ) - entry.add_to_hass(hass) - # Do not forward the entry setup to the components here - with patch("homeassistant.components.mqtt.PLATFORMS", []): - assert await hass.config_entries.async_setup(entry.entry_id) + + real_mqtt = mqtt.MQTT + real_mqtt_instance = None + mock_mqtt_instance = None + + async def _setup_mqtt_entry(setup_entry): + """Set up the MQTT config entry.""" + assert await setup_entry(hass, entry) + + # Assert that MQTT is setup + assert real_mqtt_instance is not None, "MQTT was not setup correctly" + mock_mqtt_instance.conf = real_mqtt_instance.conf # For diagnostics + mock_mqtt_instance._mqttc = mqtt_client_mock + + # connected set to True to get a more realistic behavior when subscribing + mock_mqtt_instance.connected = True + + hass.helpers.dispatcher.async_dispatcher_send(mqtt.MQTT_CONNECTED) await hass.async_block_till_done() - mqtt_component_mock = MagicMock( - return_value=hass.data["mqtt"], - spec_set=hass.data["mqtt"], - wraps=hass.data["mqtt"], - ) - mqtt_component_mock.conf = hass.data["mqtt"].conf # For diagnostics - mqtt_component_mock._mqttc = mqtt_client_mock - # connected set to True to get a more realistics behavior when subscribing - hass.data["mqtt"].connected = True + return mock_mqtt_instance - hass.data["mqtt"] = mqtt_component_mock - component = hass.data["mqtt"] - component.reset_mock() - return component + def create_mock_mqtt(*args, **kwargs): + """Create a mock based on mqtt.MQTT.""" + nonlocal mock_mqtt_instance + nonlocal real_mqtt_instance + real_mqtt_instance = real_mqtt(*args, **kwargs) + mock_mqtt_instance = MagicMock( + return_value=real_mqtt_instance, + spec_set=real_mqtt_instance, + wraps=real_mqtt_instance, + ) + return mock_mqtt_instance + + with patch("homeassistant.components.mqtt.MQTT", side_effect=create_mock_mqtt): + yield _setup_mqtt_entry + + +@pytest.fixture +async def mqtt_mock_entry_no_yaml_config(hass, mqtt_client_mock, mqtt_config): + """Set up an MQTT config entry without MQTT yaml config.""" + + async def _async_setup_config_entry(hass, entry): + """Help set up the config entry.""" + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + return True + + async def _setup_mqtt_entry(): + """Set up the MQTT config entry.""" + return await mqtt_mock_entry(_async_setup_config_entry) + + async with _mqtt_mock_entry(hass, mqtt_client_mock, mqtt_config) as mqtt_mock_entry: + yield _setup_mqtt_entry + + +@pytest.fixture +async def mqtt_mock_entry_with_yaml_config(hass, mqtt_client_mock, mqtt_config): + """Set up an MQTT config entry with MQTT yaml config.""" + + async def _async_do_not_setup_config_entry(hass, entry): + """Do nothing.""" + return True + + async def _setup_mqtt_entry(): + """Set up the MQTT config entry.""" + return await mqtt_mock_entry(_async_do_not_setup_config_entry) + + async with _mqtt_mock_entry(hass, mqtt_client_mock, mqtt_config) as mqtt_mock_entry: + yield _setup_mqtt_entry @pytest.fixture(autouse=True) From fc8727454aa11320a196aeca4ba109d7734bd86f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 2 Jun 2022 14:28:14 +0200 Subject: [PATCH 186/947] Use Mapping for async_step_reauth (p-s) (#72766) --- homeassistant/components/powerwall/config_flow.py | 3 ++- homeassistant/components/pvoutput/config_flow.py | 3 ++- homeassistant/components/renault/config_flow.py | 7 ++++--- homeassistant/components/ridwell/config_flow.py | 3 ++- homeassistant/components/samsungtv/config_flow.py | 4 ++-- homeassistant/components/sensibo/config_flow.py | 5 ++--- homeassistant/components/simplisafe/config_flow.py | 3 ++- homeassistant/components/sleepiq/config_flow.py | 5 +++-- homeassistant/components/steam_online/config_flow.py | 3 ++- homeassistant/components/surepetcare/config_flow.py | 3 ++- homeassistant/components/synology_dsm/config_flow.py | 7 ++++--- homeassistant/components/system_bridge/config_flow.py | 3 ++- 12 files changed, 29 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index 836aa46e2a4..b541c1b4bf7 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Tesla Powerwall integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -205,7 +206,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py index 4349a79593e..25cc68acc24 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the PVOutput integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pvo import PVOutput, PVOutputAuthenticationError, PVOutputError @@ -83,7 +84,7 @@ class PVOutputFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with PVOutput.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 47832cdbe93..539ba7549b3 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure Renault component.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from renault_api.const import AVAILABLE_LOCALES @@ -21,7 +22,7 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the Renault config flow.""" - self._original_data: dict[str, Any] | None = None + self._original_data: Mapping[str, Any] | None = None self.renault_config: dict[str, Any] = {} self.renault_hub: RenaultHub | None = None @@ -92,9 +93,9 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._original_data = user_input.copy() + self._original_data = data return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py index 405474f5875..2d6444fede9 100644 --- a/homeassistant/components/ridwell/config_flow.py +++ b/homeassistant/components/ridwell/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Ridwell integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import TYPE_CHECKING, Any from aioridwell import async_get_client @@ -80,7 +81,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_USERNAME: self._username, CONF_PASSWORD: self._password}, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._username = config[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index bfa2482f617..130b1d28d5f 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -1,9 +1,9 @@ """Config flow for Samsung TV.""" from __future__ import annotations +from collections.abc import Mapping from functools import partial import socket -from types import MappingProxyType from typing import Any from urllib.parse import urlparse @@ -525,7 +525,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) async def async_step_reauth( - self, data: MappingProxyType[str, Any] + self, data: Mapping[str, Any] ) -> data_entry_flow.FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index a3254a01839..a3214bdad56 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -1,6 +1,7 @@ """Adds config flow for Sensibo integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pysensibo.exceptions import AuthenticationError @@ -28,9 +29,7 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Sensibo.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 14afc743b23..3fcab1f3966 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from typing import Any import async_timeout @@ -97,7 +98,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler(config_entry) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth = True diff --git a/homeassistant/components/sleepiq/config_flow.py b/homeassistant/components/sleepiq/config_flow.py index 49f14eff0b9..c78daa76fbc 100644 --- a/homeassistant/components/sleepiq/config_flow.py +++ b/homeassistant/components/sleepiq/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure SleepIQ component.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -79,12 +80,12 @@ class SleepIQFlowHandler(ConfigFlow, domain=DOMAIN): last_step=True, ) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - return await self.async_step_reauth_confirm(user_input) + return await self.async_step_reauth_confirm(dict(data)) async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 338b0a80fb6..94b3e8d809c 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Steam integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import steam @@ -125,7 +126,7 @@ class SteamFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): import_config[CONF_ACCOUNT] = import_config[CONF_ACCOUNTS][0] return await self.async_step_user(import_config) - async def async_step_reauth(self, user_input: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index 30f20257e8c..b8b3d690a8b 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Sure Petcare integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -86,7 +87,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._username = config[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 256ad5eef8e..bc35caf300e 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Synology DSM integration.""" from __future__ import annotations +from collections.abc import Mapping from ipaddress import ip_address import logging from typing import Any @@ -120,7 +121,7 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): """Initialize the synology_dsm config flow.""" self.saved_user_input: dict[str, Any] = {} self.discovered_conf: dict[str, Any] = {} - self.reauth_conf: dict[str, Any] = {} + self.reauth_conf: Mapping[str, Any] = {} self.reauth_reason: str | None = None def _show_form( @@ -299,9 +300,9 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): user_input = {**self.discovered_conf, **user_input} return await self.async_validate_input_create_entry(user_input, step_id=step) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self.reauth_conf = data.copy() + self.reauth_conf = data return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/system_bridge/config_flow.py b/homeassistant/components/system_bridge/config_flow.py index 0c1241fcf2c..9d89cf83288 100644 --- a/homeassistant/components/system_bridge/config_flow.py +++ b/homeassistant/components/system_bridge/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping import logging from typing import Any @@ -202,7 +203,7 @@ class ConfigFlow( return await self.async_step_authenticate() - async def async_step_reauth(self, entry_data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._name = entry_data[CONF_HOST] self._input = { From b89cd37de8f1d8500473ca2db1baa70ff83e92c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Jun 2022 17:19:15 +0200 Subject: [PATCH 187/947] Remove dead code from template fan (#72917) --- homeassistant/components/template/fan.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 2c8d7247967..55f81b697bf 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -54,7 +54,6 @@ CONF_DIRECTION_TEMPLATE = "direction_template" CONF_ON_ACTION = "turn_on" CONF_OFF_ACTION = "turn_off" CONF_SET_PERCENTAGE_ACTION = "set_percentage" -CONF_SET_SPEED_ACTION = "set_speed" CONF_SET_OSCILLATING_ACTION = "set_oscillating" CONF_SET_DIRECTION_ACTION = "set_direction" CONF_SET_PRESET_MODE_ACTION = "set_preset_mode" @@ -153,12 +152,6 @@ class TemplateFan(TemplateEntity, FanEntity): self._on_script = Script(hass, config[CONF_ON_ACTION], friendly_name, DOMAIN) self._off_script = Script(hass, config[CONF_OFF_ACTION], friendly_name, DOMAIN) - self._set_speed_script = None - if set_speed_action := config.get(CONF_SET_SPEED_ACTION): - self._set_speed_script = Script( - hass, set_speed_action, friendly_name, DOMAIN - ) - self._set_percentage_script = None if set_percentage_action := config.get(CONF_SET_PERCENTAGE_ACTION): self._set_percentage_script = Script( From 9192d0e972a495d9d14e2a290a61851d980b7e1a Mon Sep 17 00:00:00 2001 From: Matrix Date: Thu, 2 Jun 2022 23:21:22 +0800 Subject: [PATCH 188/947] Bump yolink-api to 0.0.6 (#72903) * Bump yolink-api to 0.0.6 * update testcase --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/yolink/test_config_flow.py | 6 ++---- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index a89934154e9..7fb78a4974b 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -3,7 +3,7 @@ "name": "YoLink", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yolink", - "requirements": ["yolink-api==0.0.5"], + "requirements": ["yolink-api==0.0.6"], "dependencies": ["auth", "application_credentials"], "codeowners": ["@matrixd2"], "iot_class": "cloud_push" diff --git a/requirements_all.txt b/requirements_all.txt index 250551caa47..845af95f0b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2486,7 +2486,7 @@ yeelight==0.7.10 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.0.5 +yolink-api==0.0.6 # homeassistant.components.youless youless-api==0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e9aa909f3d..3458d0326e3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1638,7 +1638,7 @@ yalexs==1.1.25 yeelight==0.7.10 # homeassistant.components.yolink -yolink-api==0.0.5 +yolink-api==0.0.6 # homeassistant.components.youless youless-api==0.16 diff --git a/tests/components/yolink/test_config_flow.py b/tests/components/yolink/test_config_flow.py index 5d6bb8fd727..e224bc3e1d2 100644 --- a/tests/components/yolink/test_config_flow.py +++ b/tests/components/yolink/test_config_flow.py @@ -3,6 +3,8 @@ import asyncio from http import HTTPStatus from unittest.mock import patch +from yolink.const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components import application_credentials from homeassistant.core import HomeAssistant @@ -12,11 +14,7 @@ from tests.common import MockConfigEntry CLIENT_ID = "12345" CLIENT_SECRET = "6789" -YOLINK_HOST = "api.yosmart.com" -YOLINK_HTTP_HOST = f"http://{YOLINK_HOST}" DOMAIN = "yolink" -OAUTH2_AUTHORIZE = f"{YOLINK_HTTP_HOST}/oauth/v2/authorization.htm" -OAUTH2_TOKEN = f"{YOLINK_HTTP_HOST}/open/yolink/token" async def test_abort_if_no_configuration(hass): From f1a31d8d333f6c88b7e61719284accf38bea209e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 05:26:08 -1000 Subject: [PATCH 189/947] Add support for async_remove_config_entry_device to unifiprotect (#72742) * Add support for async_remove_config_entry_device to unifiprotect * tweaks * tweaks * more cleanups * more cleanups * fix unhelpful auto import * add coverage * fix mac formatting * collapse logic --- .../components/unifiprotect/__init__.py | 22 +++++- homeassistant/components/unifiprotect/data.py | 34 ++++++---- .../components/unifiprotect/entity.py | 6 +- .../components/unifiprotect/services.py | 16 +---- .../components/unifiprotect/utils.py | 38 +++++++++++ tests/components/unifiprotect/test_init.py | 68 ++++++++++++++++++- 6 files changed, 152 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index c28f2639e00..4ec11a899e3 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -35,9 +35,10 @@ from .const import ( OUTDATED_LOG_MESSAGE, PLATFORMS, ) -from .data import ProtectData +from .data import ProtectData, async_ufp_instance_for_config_entry_ids from .discovery import async_start_discovery from .services import async_cleanup_services, async_setup_services +from .utils import _async_unifi_mac_from_hass, async_get_devices _LOGGER = logging.getLogger(__name__) @@ -166,3 +167,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_cleanup_services(hass) return bool(unload_ok) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove ufp config entry from a device.""" + unifi_macs = { + _async_unifi_mac_from_hass(connection[1]) + for connection in device_entry.connections + if connection[0] == dr.CONNECTION_NETWORK_MAC + } + api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id}) + assert api is not None + return api.bootstrap.nvr.mac not in unifi_macs and not any( + device.mac in unifi_macs + for device in async_get_devices(api, DEVICES_THAT_ADOPT) + ) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 371c1c7831b..68c8873c17e 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -14,13 +14,14 @@ from pyunifiprotect.data import ( ModelType, WSSubscriptionMessage, ) -from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel +from pyunifiprotect.data.base import ProtectAdoptableDeviceModel from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES +from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN +from .utils import async_get_adoptable_devices_by_type, async_get_devices _LOGGER = logging.getLogger(__name__) @@ -58,13 +59,10 @@ class ProtectData: self, device_types: Iterable[ModelType] ) -> Generator[ProtectAdoptableDeviceModel, None, None]: """Get all devices matching types.""" - for device_type in device_types: - attr = f"{device_type.value}s" - devices: dict[str, ProtectAdoptableDeviceModel] = getattr( - self.api.bootstrap, attr - ) - yield from devices.values() + yield from async_get_adoptable_devices_by_type( + self.api, device_type + ).values() async def async_setup(self) -> None: """Subscribe and do the refresh.""" @@ -145,11 +143,8 @@ class ProtectData: return self.async_signal_device_id_update(self.api.bootstrap.nvr.id) - for device_type in DEVICES_THAT_ADOPT: - attr = f"{device_type.value}s" - devices: dict[str, ProtectDeviceModel] = getattr(self.api.bootstrap, attr) - for device_id in devices.keys(): - self.async_signal_device_id_update(device_id) + for device in async_get_devices(self.api, DEVICES_THAT_ADOPT): + self.async_signal_device_id_update(device.id) @callback def async_subscribe_device_id( @@ -188,3 +183,16 @@ class ProtectData: _LOGGER.debug("Updating device: %s", device_id) for update_callback in self._subscriptions[device_id]: update_callback() + + +@callback +def async_ufp_instance_for_config_entry_ids( + hass: HomeAssistant, config_entry_ids: set[str] +) -> ProtectApiClient | None: + """Find the UFP instance for the config entry ids.""" + domain_data = hass.data[DOMAIN] + for config_entry_id in config_entry_ids: + if config_entry_id in domain_data: + protect_data: ProtectData = domain_data[config_entry_id] + return protect_data.api + return None diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index f8ceaeec9e6..2911a861535 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData from .models import ProtectRequiredKeysMixin -from .utils import get_nested_attr +from .utils import async_get_adoptable_devices_by_type, get_nested_attr _LOGGER = logging.getLogger(__name__) @@ -153,7 +153,9 @@ class ProtectDeviceEntity(Entity): """Update Entity object from Protect device.""" if self.data.last_update_success: assert self.device.model - devices = getattr(self.data.api.bootstrap, f"{self.device.model.value}s") + devices = async_get_adoptable_devices_by_type( + self.data.api, self.device.model + ) self.device = devices[self.device.id] is_connected = ( diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index f8aa446f857..828aa9ecfd7 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -24,7 +24,7 @@ from homeassistant.helpers.service import async_extract_referenced_entity_ids from homeassistant.util.read_only_dict import ReadOnlyDict from .const import ATTR_MESSAGE, DOMAIN -from .data import ProtectData +from .data import async_ufp_instance_for_config_entry_ids SERVICE_ADD_DOORBELL_TEXT = "add_doorbell_text" SERVICE_REMOVE_DOORBELL_TEXT = "remove_doorbell_text" @@ -59,18 +59,6 @@ CHIME_PAIRED_SCHEMA = vol.All( ) -def _async_ufp_instance_for_config_entry_ids( - hass: HomeAssistant, config_entry_ids: set[str] -) -> ProtectApiClient | None: - """Find the UFP instance for the config entry ids.""" - domain_data = hass.data[DOMAIN] - for config_entry_id in config_entry_ids: - if config_entry_id in domain_data: - protect_data: ProtectData = domain_data[config_entry_id] - return protect_data.api - return None - - @callback def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiClient: device_registry = dr.async_get(hass) @@ -81,7 +69,7 @@ def _async_get_ufp_instance(hass: HomeAssistant, device_id: str) -> ProtectApiCl return _async_get_ufp_instance(hass, device_entry.via_device_id) config_entry_ids = device_entry.config_entries - if ufp_instance := _async_ufp_instance_for_config_entry_ids(hass, config_entry_ids): + if ufp_instance := async_ufp_instance_for_config_entry_ids(hass, config_entry_ids): return ufp_instance raise HomeAssistantError(f"No device found for device id: {device_id}") diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 559cfd37660..fffe987db0f 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -1,13 +1,19 @@ """UniFi Protect Integration utils.""" from __future__ import annotations +from collections.abc import Generator, Iterable import contextlib from enum import Enum import socket from typing import Any +from pyunifiprotect import ProtectApiClient +from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel + from homeassistant.core import HomeAssistant, callback +from .const import ModelType + def get_nested_attr(obj: Any, attr: str) -> Any: """Fetch a nested attribute.""" @@ -51,3 +57,35 @@ async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: None, ) return None + + +def async_get_devices_by_type( + api: ProtectApiClient, device_type: ModelType +) -> dict[str, ProtectDeviceModel]: + """Get devices by type.""" + devices: dict[str, ProtectDeviceModel] = getattr( + api.bootstrap, f"{device_type.value}s" + ) + return devices + + +def async_get_adoptable_devices_by_type( + api: ProtectApiClient, device_type: ModelType +) -> dict[str, ProtectAdoptableDeviceModel]: + """Get adoptable devices by type.""" + devices: dict[str, ProtectAdoptableDeviceModel] = getattr( + api.bootstrap, f"{device_type.value}s" + ) + return devices + + +@callback +def async_get_devices( + api: ProtectApiClient, model_type: Iterable[ModelType] +) -> Generator[ProtectDeviceModel, None, None]: + """Return all device by type.""" + return ( + device + for device_type in model_type + for device in async_get_devices_by_type(api, device_type).values() + ) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 95c2ee0b511..cf899d854fd 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -2,8 +2,10 @@ # pylint: disable=protected-access from __future__ import annotations +from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, patch +import aiohttp from pyunifiprotect import NotAuthorized, NvrError from pyunifiprotect.data import NVR, Light @@ -11,7 +13,8 @@ from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAI from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.setup import async_setup_component from . import _patch_discovery from .conftest import MockBootstrap, MockEntityFixture @@ -19,6 +22,22 @@ from .conftest import MockBootstrap, MockEntityFixture from tests.common import MockConfigEntry +async def remove_device( + ws_client: aiohttp.ClientWebSocketResponse, device_id: str, config_entry_id: str +) -> bool: + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + async def test_setup(hass: HomeAssistant, mock_entry: MockEntityFixture): """Test working setup of unifiprotect entry.""" @@ -321,3 +340,50 @@ async def test_migrate_reboot_button_fail( light = registry.async_get(f"{Platform.BUTTON}.test_light_1") assert light is not None assert light.unique_id == f"{light1.id}" + + +async def test_device_remove_devices( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_light: Light, + hass_ws_client: Callable[ + [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] + ], +) -> None: + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + light1 = mock_light.copy() + light1._api = mock_entry.api + light1.name = "Test Light 1" + light1.id = "lightid1" + light1.mac = "AABBCCDDEEFF" + + mock_entry.api.bootstrap.lights = { + light1.id: light1, + } + + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + light_entity_id = "light.test_light_1" + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + entry_id = mock_entry.entry.entry_id + + registry: er.EntityRegistry = er.async_get(hass) + entity = registry.entities[light_entity_id] + device_registry = dr.async_get(hass) + + live_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "e9:88:e7:b8:b4:40")}, + ) + assert ( + await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) + is True + ) From 1c38c20cacbcae68582f30fc799a0426cabfd46e Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 2 Jun 2022 11:27:12 -0400 Subject: [PATCH 190/947] Bump ZHA dependency zigpy from 0.45.1 to 0.46.0 (#72877) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 4f16b1c113e..023af7a8a0e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -9,7 +9,7 @@ "pyserial-asyncio==0.6", "zha-quirks==0.0.75", "zigpy-deconz==0.16.0", - "zigpy==0.45.1", + "zigpy==0.46.0", "zigpy-xbee==0.14.0", "zigpy-zigate==0.7.4", "zigpy-znp==0.7.0" diff --git a/requirements_all.txt b/requirements_all.txt index 845af95f0b5..81cdb84632d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ zigpy-zigate==0.7.4 zigpy-znp==0.7.0 # homeassistant.components.zha -zigpy==0.45.1 +zigpy==0.46.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3458d0326e3..f5b92cc28d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1662,7 +1662,7 @@ zigpy-zigate==0.7.4 zigpy-znp==0.7.0 # homeassistant.components.zha -zigpy==0.45.1 +zigpy==0.46.0 # homeassistant.components.zwave_js zwave-js-server-python==0.37.1 From d3b1896a0616b0cbb91b39fbbe5f086e0acc4d7b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 05:39:53 -1000 Subject: [PATCH 191/947] Only present history_stats state as unknown if the time is in the future (#72880) --- homeassistant/components/history_stats/data.py | 9 +++------ tests/components/history_stats/test_sensor.py | 2 +- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 8153557422d..5466498fc32 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -73,7 +73,8 @@ class HistoryStats: # History cannot tell the future self._history_current_period = [] self._previous_run_before_start = True - + self._state = HistoryStatsState(None, None, self._period) + return self._state # # We avoid querying the database if the below did NOT happen: # @@ -82,7 +83,7 @@ class HistoryStats: # - The period shrank in size # - The previous period ended before now # - elif ( + if ( not self._previous_run_before_start and current_period_start_timestamp == previous_period_start_timestamp and ( @@ -117,10 +118,6 @@ class HistoryStats: ) self._previous_run_before_start = False - if not self._history_current_period: - self._state = HistoryStatsState(None, None, self._period) - return self._state - hours_matched, match_count = self._async_compute_hours_and_changes( now_timestamp, current_period_start_timestamp, diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index b375a8f63c4..f824ee552ca 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -357,7 +357,7 @@ async def test_measure_multiple(hass, recorder_mock): await hass.async_block_till_done() assert hass.states.get("sensor.sensor1").state == "0.5" - assert hass.states.get("sensor.sensor2").state == STATE_UNKNOWN + assert hass.states.get("sensor.sensor2").state == "0.0" assert hass.states.get("sensor.sensor3").state == "2" assert hass.states.get("sensor.sensor4").state == "50.0" From 8c50c7fbd4a4638b4bfcd81bc5ba8866a7bd11ef Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 2 Jun 2022 08:40:13 -0700 Subject: [PATCH 192/947] Fix bug in caldav and avoid unnecessary copy of dataclass (#72922) --- homeassistant/components/caldav/calendar.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index c5b2b3a790a..17a8d5deb2f 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,7 +1,6 @@ """Support for WebDav Calendar.""" from __future__ import annotations -import copy from datetime import datetime, timedelta import logging import re @@ -143,15 +142,13 @@ class WebDavCalendarEntity(CalendarEntity): def update(self): """Update event data.""" self.data.update() - event = copy.deepcopy(self.data.event) - if event is None: - self._event = event - return - (summary, offset) = extract_offset(event.summary, OFFSET) - event.summary = summary - self._event = event + self._event = self.data.event self._attr_extra_state_attributes = { - "offset_reached": is_offset_reached(event.start_datetime_local, offset) + "offset_reached": is_offset_reached( + self._event.start_datetime_local, self.data.offset + ) + if self._event + else False } @@ -165,6 +162,7 @@ class WebDavCalendarData: self.include_all_day = include_all_day self.search = search self.event = None + self.offset = None async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime @@ -264,13 +262,15 @@ class WebDavCalendarData: return # Populate the entity attributes with the event values + (summary, offset) = extract_offset(vevent.summary.value, OFFSET) self.event = CalendarEvent( - summary=vevent.summary.value, + summary=summary, start=vevent.dtstart.value, end=self.get_end_date(vevent), location=self.get_attr_value(vevent, "location"), description=self.get_attr_value(vevent, "description"), ) + self.offset = offset @staticmethod def is_matching(vevent, search): From 8e4321af59308ebc62cfbedfff88800c4aa8164b Mon Sep 17 00:00:00 2001 From: nojocodex <76249511+nojocodex@users.noreply.github.com> Date: Thu, 2 Jun 2022 19:49:08 +0200 Subject: [PATCH 193/947] Fix logging & exit code reporting to S6 on HA shutdown (#72921) --- rootfs/etc/services.d/home-assistant/finish | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/rootfs/etc/services.d/home-assistant/finish b/rootfs/etc/services.d/home-assistant/finish index 115d8352618..057957a9c03 100755 --- a/rootfs/etc/services.d/home-assistant/finish +++ b/rootfs/etc/services.d/home-assistant/finish @@ -2,24 +2,24 @@ # ============================================================================== # Take down the S6 supervision tree when Home Assistant fails # ============================================================================== -declare RESTART_EXIT_CODE 100 -declare SIGNAL_EXIT_CODE 256 -declare SIGTERM 15 +declare RESTART_EXIT_CODE=100 +declare SIGNAL_EXIT_CODE=256 +declare SIGTERM=15 declare APP_EXIT_CODE=${1} -declare SYS_EXIT_CODE=${2+x} +declare SIGNAL_NO=${2} declare NEW_EXIT_CODE= -bashio::log.info "Home Assistant Core finish process exit code ${1}" +bashio::log.info "Home Assistant Core finish process exit code ${APP_EXIT_CODE}" if [[ ${APP_EXIT_CODE} -eq ${RESTART_EXIT_CODE} ]]; then exit 0 elif [[ ${APP_EXIT_CODE} -eq ${SIGNAL_EXIT_CODE} ]]; then - bashio::log.info "Home Assistant Core finish process received signal ${APP_EXIT_CODE}" + bashio::log.info "Home Assistant Core finish process received signal ${SIGNAL_NO}" - NEW_EXIT_CODE=$((128 + SYS_EXIT_CODE)) + NEW_EXIT_CODE=$((128 + SIGNAL_NO)) echo ${NEW_EXIT_CODE} > /run/s6-linux-init-container-results/exitcode - if [[ ${NEW_EXIT_CODE} -eq ${SIGTERM} ]]; then + if [[ ${SIGNAL_NO} -eq ${SIGTERM} ]]; then /run/s6/basedir/bin/halt fi else From b97d346df799d1c16e99dfead628d42cd1fc37ad Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 2 Jun 2022 20:32:31 +0200 Subject: [PATCH 194/947] Fix reload of MQTT yaml config (#72901) --- .../components/mqtt/alarm_control_panel.py | 18 +++--- .../components/mqtt/binary_sensor.py | 18 +++--- homeassistant/components/mqtt/button.py | 18 +++--- homeassistant/components/mqtt/camera.py | 18 +++--- homeassistant/components/mqtt/climate.py | 18 +++--- homeassistant/components/mqtt/cover.py | 18 +++--- homeassistant/components/mqtt/fan.py | 18 +++--- homeassistant/components/mqtt/humidifier.py | 21 +++---- .../components/mqtt/light/__init__.py | 18 +++--- homeassistant/components/mqtt/lock.py | 18 +++--- homeassistant/components/mqtt/mixins.py | 58 ++++++++++++++++--- homeassistant/components/mqtt/number.py | 18 +++--- homeassistant/components/mqtt/scene.py | 18 +++--- homeassistant/components/mqtt/select.py | 18 +++--- homeassistant/components/mqtt/sensor.py | 18 +++--- homeassistant/components/mqtt/siren.py | 18 +++--- homeassistant/components/mqtt/switch.py | 18 +++--- .../components/mqtt/vacuum/__init__.py | 18 +++--- tests/components/mqtt/test_binary_sensor.py | 2 +- tests/components/mqtt/test_common.py | 53 ++++++++++++----- tests/components/mqtt/test_sensor.py | 2 +- 21 files changed, 241 insertions(+), 183 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index c20fbb7c657..c0c6f9732d7 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -1,7 +1,6 @@ """This platform enables the possibility to control a MQTT alarm.""" from __future__ import annotations -import asyncio import functools import logging import re @@ -45,8 +44,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -133,7 +132,11 @@ async def async_setup_platform( """Set up MQTT alarm control panel configured under the alarm_control_panel key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, alarm.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + alarm.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -144,13 +147,8 @@ async def async_setup_entry( ) -> None: """Set up MQTT alarm control panel through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, alarm.DOMAIN, PLATFORM_SCHEMA_MODERN - ) - ) + config_entry.async_on_unload( + await async_setup_platform_discovery(hass, alarm.DOMAIN, PLATFORM_SCHEMA_MODERN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 1cb90d6c903..cec065e20f2 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -1,7 +1,6 @@ """Support for MQTT binary sensors.""" from __future__ import annotations -import asyncio from datetime import timedelta import functools import logging @@ -42,8 +41,8 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -88,7 +87,11 @@ async def async_setup_platform( """Set up MQTT binary sensor configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, binary_sensor.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + binary_sensor.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -99,12 +102,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT binary sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, binary_sensor.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, binary_sensor.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index b50856d20c1..afa9900db35 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -1,7 +1,6 @@ """Support for MQTT buttons.""" from __future__ import annotations -import asyncio import functools import voluptuous as vol @@ -26,8 +25,8 @@ from .const import ( from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -68,7 +67,11 @@ async def async_setup_platform( """Set up MQTT button configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, button.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + button.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -79,12 +82,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT button through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, button.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, button.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index ae38e07d17a..86db828b111 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -1,7 +1,6 @@ """Camera that loads a picture from an MQTT topic.""" from __future__ import annotations -import asyncio from base64 import b64decode import functools @@ -23,8 +22,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -66,7 +65,11 @@ async def async_setup_platform( """Set up MQTT camera configured under the camera platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, camera.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + camera.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -77,12 +80,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT camera through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, camera.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, camera.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 64b462359be..bdcc82f2c39 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -1,7 +1,6 @@ """Support for MQTT climate devices.""" from __future__ import annotations -import asyncio import functools import logging @@ -51,8 +50,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -377,7 +376,11 @@ async def async_setup_platform( """Set up MQTT climate configured under the fan platform key (deprecated).""" # The use of PLATFORM_SCHEMA is deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, climate.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + climate.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -388,12 +391,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT climate device through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, climate.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, climate.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 5814f3e43f7..325433817c0 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -1,7 +1,6 @@ """Support for MQTT cover devices.""" from __future__ import annotations -import asyncio import functools from json import JSONDecodeError, loads as json_loads import logging @@ -46,8 +45,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -227,7 +226,11 @@ async def async_setup_platform( """Set up MQTT covers configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, cover.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + cover.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -238,13 +241,8 @@ async def async_setup_entry( ) -> None: """Set up MQTT cover through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, cover.DOMAIN, PLATFORM_SCHEMA_MODERN - ) - ) + config_entry.async_on_unload( + await async_setup_platform_discovery(hass, cover.DOMAIN, PLATFORM_SCHEMA_MODERN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index f72b0bdf689..d0b4ff10692 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -1,7 +1,6 @@ """Support for MQTT fans.""" from __future__ import annotations -import asyncio import functools import logging import math @@ -50,8 +49,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -217,7 +216,11 @@ async def async_setup_platform( """Set up MQTT fans configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, fan.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + fan.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -228,13 +231,8 @@ async def async_setup_entry( ) -> None: """Set up MQTT fan through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, fan.DOMAIN, PLATFORM_SCHEMA_MODERN - ) - ) + config_entry.async_on_unload( + await async_setup_platform_discovery(hass, fan.DOMAIN, PLATFORM_SCHEMA_MODERN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 000a9b9700e..1c9ec5dc201 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -1,7 +1,6 @@ """Support for MQTT humidifiers.""" from __future__ import annotations -import asyncio import functools import logging @@ -46,8 +45,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -173,7 +172,11 @@ async def async_setup_platform( """Set up MQTT humidifier configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, humidifier.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + humidifier.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -184,14 +187,12 @@ async def async_setup_entry( ) -> None: """Set up MQTT humidifier through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, humidifier.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, humidifier.DOMAIN, PLATFORM_SCHEMA_MODERN ) - ) # setup for discovery + ) + # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry ) diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index ab2a3462615..158ea6ffa0d 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -1,7 +1,6 @@ """Support for MQTT lights.""" from __future__ import annotations -import asyncio import functools import voluptuous as vol @@ -14,8 +13,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from ..mixins import ( - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -97,7 +96,11 @@ async def async_setup_platform( """Set up MQTT light through configuration.yaml (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, light.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + light.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -108,13 +111,8 @@ async def async_setup_entry( ) -> None: """Set up MQTT lights configured under the light platform key (deprecated).""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, light.DOMAIN, PLATFORM_SCHEMA_MODERN - ) - ) + config_entry.async_on_unload( + await async_setup_platform_discovery(hass, light.DOMAIN, PLATFORM_SCHEMA_MODERN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 0cfd1d2b70f..862e76635f7 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -1,7 +1,6 @@ """Support for MQTT locks.""" from __future__ import annotations -import asyncio import functools import voluptuous as vol @@ -28,8 +27,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -88,7 +87,11 @@ async def async_setup_platform( """Set up MQTT locks configured under the lock platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, lock.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + lock.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -99,13 +102,8 @@ async def async_setup_entry( ) -> None: """Set up MQTT lock through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, lock.DOMAIN, PLATFORM_SCHEMA_MODERN - ) - ) + config_entry.async_on_unload( + await async_setup_platform_discovery(hass, lock.DOMAIN, PLATFORM_SCHEMA_MODERN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 694fae0b3c0..b0f17cc335b 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -2,6 +2,7 @@ from __future__ import annotations from abc import abstractmethod +import asyncio from collections.abc import Callable import json import logging @@ -27,10 +28,11 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, + discovery, entity_registry as er, ) from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED @@ -46,7 +48,10 @@ from homeassistant.helpers.entity import ( async_generate_entity_id, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.reload import ( + async_integration_yaml_config, + async_setup_reload_service, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import debug_info, subscription @@ -260,8 +265,44 @@ class SetupEntity(Protocol): """Define setup_entities type.""" +async def async_setup_platform_discovery( + hass: HomeAssistant, platform_domain: str, schema: vol.Schema +) -> CALLBACK_TYPE: + """Set up platform discovery for manual config.""" + + async def _async_discover_entities(event: Event | None) -> None: + """Discover entities for a platform.""" + if event: + # The platform has been reloaded + config_yaml = await async_integration_yaml_config(hass, DOMAIN) + if not config_yaml: + return + config_yaml = config_yaml.get(DOMAIN, {}) + else: + config_yaml = hass.data.get(DATA_MQTT_CONFIG, {}) + if not config_yaml: + return + if platform_domain not in config_yaml: + return + await asyncio.gather( + *( + discovery.async_load_platform(hass, platform_domain, DOMAIN, config, {}) + for config in await async_get_platform_config_from_yaml( + hass, platform_domain, schema, config_yaml + ) + ) + ) + + unsub = hass.bus.async_listen("event_mqtt_reloaded", _async_discover_entities) + await _async_discover_entities(None) + return unsub + + async def async_get_platform_config_from_yaml( - hass: HomeAssistant, domain: str, schema: vol.Schema + hass: HomeAssistant, + platform_domain: str, + schema: vol.Schema, + config_yaml: ConfigType = None, ) -> list[ConfigType]: """Return a list of validated configurations for the domain.""" @@ -275,12 +316,15 @@ async def async_get_platform_config_from_yaml( try: validated_config.append(schema(config_item)) except vol.MultipleInvalid as err: - async_log_exception(err, domain, config_item, hass) + async_log_exception(err, platform_domain, config_item, hass) return validated_config - config_yaml: ConfigType = hass.data.get(DATA_MQTT_CONFIG, {}) - if not (platform_configs := config_yaml.get(domain)): + if config_yaml is None: + config_yaml = hass.data.get(DATA_MQTT_CONFIG) + if not config_yaml: + return [] + if not (platform_configs := config_yaml.get(platform_domain)): return [] return async_validate_config(hass, platform_configs) @@ -310,7 +354,7 @@ async def async_setup_entry_helper(hass, domain, async_setup, schema): async def async_setup_platform_helper( hass: HomeAssistant, platform_domain: str, - config: ConfigType, + config: ConfigType | DiscoveryInfoType, async_add_entities: AddEntitiesCallback, async_setup_entities: SetupEntity, ) -> None: diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 6ea1f0959f6..1404dc86a3c 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -1,7 +1,6 @@ """Configure number in a device through MQTT topic.""" from __future__ import annotations -import asyncio import functools import logging @@ -41,8 +40,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -119,7 +118,11 @@ async def async_setup_platform( """Set up MQTT number configured under the number platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, number.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + number.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -130,12 +133,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT number through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, number.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, number.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index ce8f0b0a3e8..9c4a212bd8e 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -1,7 +1,6 @@ """Support for MQTT scenes.""" from __future__ import annotations -import asyncio import functools import voluptuous as vol @@ -23,8 +22,8 @@ from .mixins import ( CONF_OBJECT_ID, MQTT_AVAILABILITY_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -65,7 +64,11 @@ async def async_setup_platform( """Set up MQTT scene configured under the scene platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, scene.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + scene.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -76,13 +79,8 @@ async def async_setup_entry( ) -> None: """Set up MQTT scene through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, scene.DOMAIN, PLATFORM_SCHEMA_MODERN - ) - ) + config_entry.async_on_unload( + await async_setup_platform_discovery(hass, scene.DOMAIN, PLATFORM_SCHEMA_MODERN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 75e1b4e8efd..994c11653b7 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -1,7 +1,6 @@ """Configure select in a device through MQTT topic.""" from __future__ import annotations -import asyncio import functools import logging @@ -31,8 +30,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -80,7 +79,11 @@ async def async_setup_platform( """Set up MQTT select configured under the select platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, select.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + select.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -91,12 +94,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT select through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, select.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, select.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 4dd1ad4d95f..f9e0b5151bb 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -1,7 +1,6 @@ """Support for MQTT sensors.""" from __future__ import annotations -import asyncio from datetime import timedelta import functools import logging @@ -42,8 +41,8 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -133,7 +132,11 @@ async def async_setup_platform( """Set up MQTT sensors configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, sensor.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + sensor.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -144,12 +147,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, sensor.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, sensor.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 1ecf2c37dbf..fef2a4fb3dd 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -1,7 +1,6 @@ """Support for MQTT sirens.""" from __future__ import annotations -import asyncio import copy import functools import json @@ -52,8 +51,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -129,7 +128,11 @@ async def async_setup_platform( """Set up MQTT sirens configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, siren.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + siren.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -140,13 +143,8 @@ async def async_setup_entry( ) -> None: """Set up MQTT siren through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, siren.DOMAIN, PLATFORM_SCHEMA_MODERN - ) - ) + config_entry.async_on_unload( + await async_setup_platform_discovery(hass, siren.DOMAIN, PLATFORM_SCHEMA_MODERN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index c20ddfe5151..be7fc655e1e 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -1,7 +1,6 @@ """Support for MQTT switches.""" from __future__ import annotations -import asyncio import functools import voluptuous as vol @@ -38,8 +37,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -83,7 +82,11 @@ async def async_setup_platform( """Set up MQTT switch configured under the fan platform key (deprecated).""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, switch.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + switch.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -94,12 +97,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT switch through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, switch.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, switch.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 34205ab7780..206a15a024a 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -1,7 +1,6 @@ """Support for MQTT vacuums.""" from __future__ import annotations -import asyncio import functools import voluptuous as vol @@ -13,8 +12,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from ..mixins import ( - async_get_platform_config_from_yaml, async_setup_entry_helper, + async_setup_platform_discovery, async_setup_platform_helper, ) from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE @@ -77,7 +76,11 @@ async def async_setup_platform( """Set up MQTT vacuum through configuration.yaml.""" # Deprecated in HA Core 2022.6 await async_setup_platform_helper( - hass, vacuum.DOMAIN, config, async_add_entities, _async_setup_entity + hass, + vacuum.DOMAIN, + discovery_info or config, + async_add_entities, + _async_setup_entity, ) @@ -88,12 +91,9 @@ async def async_setup_entry( ) -> None: """Set up MQTT vacuum through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - await asyncio.gather( - *( - _async_setup_entity(hass, async_add_entities, config, config_entry) - for config in await async_get_platform_config_from_yaml( - hass, vacuum.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + config_entry.async_on_unload( + await async_setup_platform_discovery( + hass, vacuum.DOMAIN, PLATFORM_SCHEMA_MODERN ) ) # setup for discovery diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 37bb783d354..ebb1d78138f 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -1011,7 +1011,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") await help_test_reload_with_config( - hass, caplog, tmp_path, domain, [config1, config2] + hass, caplog, tmp_path, {domain: [config1, config2]} ) assert "Clean up expire after trigger for binary_sensor.test1" in caplog.text assert "Clean up expire after trigger for binary_sensor.test2" not in caplog.text diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 50cf7beb0e0..24482129f3d 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1651,10 +1651,10 @@ async def help_test_publishing_with_custom_encoding( mqtt_mock.async_publish.reset_mock() -async def help_test_reload_with_config(hass, caplog, tmp_path, domain, config): +async def help_test_reload_with_config(hass, caplog, tmp_path, config): """Test reloading with supplied config.""" new_yaml_config_file = tmp_path / "configuration.yaml" - new_yaml_config = yaml.dump({domain: config}) + new_yaml_config = yaml.dump(config) new_yaml_config_file.write_text(new_yaml_config) assert new_yaml_config_file.read_text() == new_yaml_config @@ -1679,16 +1679,27 @@ async def help_test_reloadable( old_config_1["name"] = "test_old_1" old_config_2 = copy.deepcopy(config) old_config_2["name"] = "test_old_2" + old_config_3 = copy.deepcopy(config) + old_config_3["name"] = "test_old_3" + old_config_3.pop("platform") + old_config_4 = copy.deepcopy(config) + old_config_4["name"] = "test_old_4" + old_config_4.pop("platform") - assert await async_setup_component( - hass, domain, {domain: [old_config_1, old_config_2]} - ) + old_config = { + domain: [old_config_1, old_config_2], + "mqtt": {domain: [old_config_3, old_config_4]}, + } + + assert await async_setup_component(hass, domain, old_config) await hass.async_block_till_done() await mqtt_mock_entry_with_yaml_config() assert hass.states.get(f"{domain}.test_old_1") assert hass.states.get(f"{domain}.test_old_2") - assert len(hass.states.async_all(domain)) == 2 + assert hass.states.get(f"{domain}.test_old_3") + assert hass.states.get(f"{domain}.test_old_4") + assert len(hass.states.async_all(domain)) == 4 # Create temporary fixture for configuration.yaml based on the supplied config and # test a reload with this new config @@ -1698,16 +1709,31 @@ async def help_test_reloadable( new_config_2["name"] = "test_new_2" new_config_3 = copy.deepcopy(config) new_config_3["name"] = "test_new_3" + new_config_3.pop("platform") + new_config_4 = copy.deepcopy(config) + new_config_4["name"] = "test_new_4" + new_config_4.pop("platform") + new_config_5 = copy.deepcopy(config) + new_config_5["name"] = "test_new_5" + new_config_6 = copy.deepcopy(config) + new_config_6["name"] = "test_new_6" + new_config_6.pop("platform") - await help_test_reload_with_config( - hass, caplog, tmp_path, domain, [new_config_1, new_config_2, new_config_3] - ) + new_config = { + domain: [new_config_1, new_config_2, new_config_5], + "mqtt": {domain: [new_config_3, new_config_4, new_config_6]}, + } - assert len(hass.states.async_all(domain)) == 3 + await help_test_reload_with_config(hass, caplog, tmp_path, new_config) + + assert len(hass.states.async_all(domain)) == 6 assert hass.states.get(f"{domain}.test_new_1") assert hass.states.get(f"{domain}.test_new_2") assert hass.states.get(f"{domain}.test_new_3") + assert hass.states.get(f"{domain}.test_new_4") + assert hass.states.get(f"{domain}.test_new_5") + assert hass.states.get(f"{domain}.test_new_6") async def help_test_reloadable_late(hass, caplog, tmp_path, domain, config): @@ -1752,9 +1778,10 @@ async def help_test_reloadable_late(hass, caplog, tmp_path, domain, config): new_config_3 = copy.deepcopy(config) new_config_3["name"] = "test_new_3" - await help_test_reload_with_config( - hass, caplog, tmp_path, domain, [new_config_1, new_config_2, new_config_3] - ) + new_config = { + domain: [new_config_1, new_config_2, new_config_3], + } + await help_test_reload_with_config(hass, caplog, tmp_path, new_config) assert len(hass.states.async_all(domain)) == 3 diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 894ecc32ecc..7081ae45993 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -1106,7 +1106,7 @@ async def test_cleanup_triggers_and_restoring_state( freezer.move_to("2022-02-02 12:01:10+01:00") await help_test_reload_with_config( - hass, caplog, tmp_path, domain, [config1, config2] + hass, caplog, tmp_path, {domain: [config1, config2]} ) await hass.async_block_till_done() From 9fbde245d01b9465c7a489962b2142f554a5dbf5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 11:54:06 -1000 Subject: [PATCH 195/947] Fix performance of logbook entity and devices queries with large MySQL databases (#72898) --- .../components/logbook/queries/common.py | 20 +++++++-- .../components/logbook/queries/devices.py | 32 +++++++++----- .../components/logbook/queries/entities.py | 39 ++++++++++------ .../logbook/queries/entities_and_devices.py | 44 ++++++++++++------- homeassistant/components/recorder/models.py | 2 + 5 files changed, 93 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 6049d6beb81..a7a4f84a59e 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -12,9 +12,11 @@ from sqlalchemy.sql.selectable import Select from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN from homeassistant.components.recorder.models import ( + EVENTS_CONTEXT_ID_INDEX, OLD_FORMAT_ATTRS_JSON, OLD_STATE, SHARED_ATTRS_JSON, + STATES_CONTEXT_ID_INDEX, EventData, Events, StateAttributes, @@ -121,9 +123,7 @@ def select_events_context_only() -> Select: By marking them as context_only we know they are only for linking context ids and we can avoid processing them. """ - return select(*EVENT_ROWS_NO_STATES, CONTEXT_ONLY).outerjoin( - EventData, (Events.data_id == EventData.data_id) - ) + return select(*EVENT_ROWS_NO_STATES, CONTEXT_ONLY) def select_states_context_only() -> Select: @@ -252,3 +252,17 @@ def _not_uom_attributes_matcher() -> ClauseList: return ~StateAttributes.shared_attrs.like( UNIT_OF_MEASUREMENT_JSON_LIKE ) | ~States.attributes.like(UNIT_OF_MEASUREMENT_JSON_LIKE) + + +def apply_states_context_hints(query: Query) -> Query: + """Force mysql to use the right index on large context_id selects.""" + return query.with_hint( + States, f"FORCE INDEX ({STATES_CONTEXT_ID_INDEX})", dialect_name="mysql" + ) + + +def apply_events_context_hints(query: Query) -> Query: + """Force mysql to use the right index on large context_id selects.""" + return query.with_hint( + Events, f"FORCE INDEX ({EVENTS_CONTEXT_ID_INDEX})", dialect_name="mysql" + ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 64a6477017e..88e9f50a42c 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -4,15 +4,22 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt -from sqlalchemy import lambda_stmt, select, union_all +from sqlalchemy import lambda_stmt, select from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import DEVICE_ID_IN_EVENT, Events, States +from homeassistant.components.recorder.models import ( + DEVICE_ID_IN_EVENT, + EventData, + Events, + States, +) from .common import ( + apply_events_context_hints, + apply_states_context_hints, select_events_context_id_subquery, select_events_context_only, select_events_without_states, @@ -27,13 +34,10 @@ def _select_device_id_context_ids_sub_query( json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple devices.""" - return select( - union_all( - select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quotable_device_ids) - ), - ).c.context_id + inner = select_events_context_id_subquery(start_day, end_day, event_types).where( + apply_event_device_id_matchers(json_quotable_device_ids) ) + return select(inner.c.context_id).group_by(inner.c.context_id) def _apply_devices_context_union( @@ -51,8 +55,16 @@ def _apply_devices_context_union( json_quotable_device_ids, ).cte() return query.union_all( - select_events_context_only().where(Events.context_id.in_(devices_cte.select())), - select_states_context_only().where(States.context_id.in_(devices_cte.select())), + apply_events_context_hints( + select_events_context_only() + .select_from(devices_cte) + .outerjoin(Events, devices_cte.c.context_id == Events.context_id) + ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + apply_states_context_hints( + select_states_context_only() + .select_from(devices_cte) + .outerjoin(States, devices_cte.c.context_id == States.context_id) + ), ) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 4fb211688f3..8de4a5eaf64 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -14,11 +14,14 @@ from homeassistant.components.recorder.models import ( ENTITY_ID_IN_EVENT, ENTITY_ID_LAST_UPDATED_INDEX, OLD_ENTITY_ID_IN_EVENT, + EventData, Events, States, ) from .common import ( + apply_events_context_hints, + apply_states_context_hints, apply_states_filters, select_events_context_id_subquery, select_events_context_only, @@ -36,16 +39,15 @@ def _select_entities_context_ids_sub_query( json_quotable_entity_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities.""" - return select( - union_all( - select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quotable_entity_ids) - ), - apply_entities_hints(select(States.context_id)) - .filter((States.last_updated > start_day) & (States.last_updated < end_day)) - .where(States.entity_id.in_(entity_ids)), - ).c.context_id + union = union_all( + select_events_context_id_subquery(start_day, end_day, event_types).where( + apply_event_entity_id_matchers(json_quotable_entity_ids) + ), + apply_entities_hints(select(States.context_id)) + .filter((States.last_updated > start_day) & (States.last_updated < end_day)) + .where(States.entity_id.in_(entity_ids)), ) + return select(union.c.context_id).group_by(union.c.context_id) def _apply_entities_context_union( @@ -64,14 +66,23 @@ def _apply_entities_context_union( entity_ids, json_quotable_entity_ids, ).cte() + # We used to optimize this to exclude rows we already in the union with + # a States.entity_id.not_in(entity_ids) but that made the + # query much slower on MySQL, and since we already filter them away + # in the python code anyways since they will have context_only + # set on them the impact is minimal. return query.union_all( states_query_for_entity_ids(start_day, end_day, entity_ids), - select_events_context_only().where( - Events.context_id.in_(entities_cte.select()) + apply_events_context_hints( + select_events_context_only() + .select_from(entities_cte) + .outerjoin(Events, entities_cte.c.context_id == Events.context_id) + ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + apply_states_context_hints( + select_states_context_only() + .select_from(entities_cte) + .outerjoin(States, entities_cte.c.context_id == States.context_id) ), - select_states_context_only() - .where(States.entity_id.not_in(entity_ids)) - .where(States.context_id.in_(entities_cte.select())), ) diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index d1c86ddbec5..1c4271422b7 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -10,9 +10,11 @@ from sqlalchemy.orm import Query from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import Events, States +from homeassistant.components.recorder.models import EventData, Events, States from .common import ( + apply_events_context_hints, + apply_states_context_hints, select_events_context_id_subquery, select_events_context_only, select_events_without_states, @@ -35,18 +37,17 @@ def _select_entities_device_id_context_ids_sub_query( json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities and multiple devices.""" - return select( - union_all( - select_events_context_id_subquery(start_day, end_day, event_types).where( - _apply_event_entity_id_device_id_matchers( - json_quotable_entity_ids, json_quotable_device_ids - ) - ), - apply_entities_hints(select(States.context_id)) - .filter((States.last_updated > start_day) & (States.last_updated < end_day)) - .where(States.entity_id.in_(entity_ids)), - ).c.context_id + union = union_all( + select_events_context_id_subquery(start_day, end_day, event_types).where( + _apply_event_entity_id_device_id_matchers( + json_quotable_entity_ids, json_quotable_device_ids + ) + ), + apply_entities_hints(select(States.context_id)) + .filter((States.last_updated > start_day) & (States.last_updated < end_day)) + .where(States.entity_id.in_(entity_ids)), ) + return select(union.c.context_id).group_by(union.c.context_id) def _apply_entities_devices_context_union( @@ -66,14 +67,23 @@ def _apply_entities_devices_context_union( json_quotable_entity_ids, json_quotable_device_ids, ).cte() + # We used to optimize this to exclude rows we already in the union with + # a States.entity_id.not_in(entity_ids) but that made the + # query much slower on MySQL, and since we already filter them away + # in the python code anyways since they will have context_only + # set on them the impact is minimal. return query.union_all( states_query_for_entity_ids(start_day, end_day, entity_ids), - select_events_context_only().where( - Events.context_id.in_(devices_entities_cte.select()) + apply_events_context_hints( + select_events_context_only() + .select_from(devices_entities_cte) + .outerjoin(Events, devices_entities_cte.c.context_id == Events.context_id) + ).outerjoin(EventData, (Events.data_id == EventData.data_id)), + apply_states_context_hints( + select_states_context_only() + .select_from(devices_entities_cte) + .outerjoin(States, devices_entities_cte.c.context_id == States.context_id) ), - select_states_context_only() - .where(States.entity_id.not_in(entity_ids)) - .where(States.context_id.in_(devices_entities_cte.select())), ) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 70c816c2af5..8db648f15a8 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -93,6 +93,8 @@ TABLES_TO_CHECK = [ LAST_UPDATED_INDEX = "ix_states_last_updated" ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated" +EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" +STATES_CONTEXT_ID_INDEX = "ix_states_context_id" EMPTY_JSON_OBJECT = "{}" From a4c3585448f582508cfa9b1167a5814c04125ad6 Mon Sep 17 00:00:00 2001 From: Khole Date: Thu, 2 Jun 2022 22:54:26 +0100 Subject: [PATCH 196/947] Fix Hive authentication (#72929) --- homeassistant/components/hive/__init__.py | 9 ++------- homeassistant/components/hive/config_flow.py | 1 + homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hive/test_config_flow.py | 15 +++++++++++++++ 6 files changed, 21 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 475bb95eeb1..52cf7f719e6 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -75,14 +75,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Hive from a config entry.""" - websession = aiohttp_client.async_get_clientsession(hass) + web_session = aiohttp_client.async_get_clientsession(hass) hive_config = dict(entry.data) - hive = Hive( - websession, - deviceGroupKey=hive_config["device_data"][0], - deviceKey=hive_config["device_data"][1], - devicePassword=hive_config["device_data"][2], - ) + hive = Hive(web_session) hive_config["options"] = {} hive_config["options"].update( diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 9c391f13294..c713a3011f4 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -102,6 +102,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): raise UnknownHiveError # Setup the config entry + await self.hive_auth.device_registration("Home Assistant") self.data["tokens"] = self.tokens self.data["device_data"] = await self.hive_auth.getDeviceData() if self.context["source"] == config_entries.SOURCE_REAUTH: diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 472adc137ba..d8cd56abe0b 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -3,7 +3,7 @@ "name": "Hive", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.5.4"], + "requirements": ["pyhiveapi==0.5.5"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/requirements_all.txt b/requirements_all.txt index 81cdb84632d..3abb93968bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1538,7 +1538,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.5.4 +pyhiveapi==0.5.5 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f5b92cc28d9..148ce2191ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1029,7 +1029,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.4 +pyhiveapi==0.5.5 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index bb567b0bdfc..51ceec43ad2 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -33,6 +33,9 @@ async def test_import_flow(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, ), patch( "homeassistant.components.hive.config_flow.Auth.getDeviceData", return_value=[ @@ -93,6 +96,9 @@ async def test_user_flow(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, ), patch( "homeassistant.components.hive.config_flow.Auth.getDeviceData", return_value=[ @@ -172,6 +178,9 @@ async def test_user_flow_2fa(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, ), patch( "homeassistant.components.hive.config_flow.Auth.getDeviceData", return_value=[ @@ -256,6 +265,9 @@ async def test_reauth_flow(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -361,6 +373,9 @@ async def test_user_flow_2fa_send_new_code(hass): "AccessToken": "mock-access-token", }, }, + ), patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, ), patch( "homeassistant.components.hive.config_flow.Auth.getDeviceData", return_value=[ From fbb08994f44ba98842b572a578a6b2da74ee1eff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 2 Jun 2022 16:15:04 -0700 Subject: [PATCH 197/947] Only sync when HA is started up as we already sync at startup (#72940) --- homeassistant/components/cloud/google_config.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index a0a68aaf84a..81f00b69b23 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -39,7 +39,6 @@ class CloudGoogleConfig(AbstractConfig): self._cur_entity_prefs = self._prefs.google_entity_configs self._cur_default_expose = self._prefs.google_default_expose self._sync_entities_lock = asyncio.Lock() - self._sync_on_started = False @property def enabled(self): @@ -224,7 +223,7 @@ class CloudGoogleConfig(AbstractConfig): self._cur_entity_prefs = prefs.google_entity_configs self._cur_default_expose = prefs.google_default_expose - if sync_entities: + if sync_entities and self.hass.is_running: await self.async_sync_entities_all() @callback From 43b802252a0f4ed33c5811004c65800700a98d18 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 3 Jun 2022 00:19:45 +0000 Subject: [PATCH 198/947] [ci skip] Translation update --- .../components/ialarm_xr/translations/et.json | 1 + homeassistant/components/knx/translations/fr.json | 2 +- homeassistant/components/plugwise/translations/ca.json | 4 ++-- homeassistant/components/plugwise/translations/et.json | 10 +++++++--- homeassistant/components/plugwise/translations/id.json | 3 ++- .../components/tankerkoenig/translations/el.json | 8 +++++++- .../components/tankerkoenig/translations/et.json | 8 +++++++- .../components/tankerkoenig/translations/id.json | 8 +++++++- .../components/totalconnect/translations/id.json | 1 + 9 files changed, 35 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ialarm_xr/translations/et.json b/homeassistant/components/ialarm_xr/translations/et.json index 97fc5aa5a29..3679dd47f2e 100644 --- a/homeassistant/components/ialarm_xr/translations/et.json +++ b/homeassistant/components/ialarm_xr/translations/et.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "\u00dchendamine nurjus", + "timeout": "\u00dchenduse ajal\u00f5pp", "unknown": "Ootamatu t\u00f5rge" }, "step": { diff --git a/homeassistant/components/knx/translations/fr.json b/homeassistant/components/knx/translations/fr.json index 803fd71b734..7a1e8e88698 100644 --- a/homeassistant/components/knx/translations/fr.json +++ b/homeassistant/components/knx/translations/fr.json @@ -102,7 +102,7 @@ "multicast_group": "Utilis\u00e9 pour le routage et la d\u00e9couverte. Valeur par d\u00e9faut\u00a0: `224.0.23.12`", "multicast_port": "Utilis\u00e9 pour le routage et la d\u00e9couverte. Valeur par d\u00e9faut\u00a0: `3671`", "rate_limit": "Nombre maximal de t\u00e9l\u00e9grammes sortants par seconde.\nValeur recommand\u00e9e\u00a0: entre 20 et 40", - "state_updater": "Active ou d\u00e9sactive globalement la lecture des \u00e9tats depuis le bus KNX. Lorsqu'elle est d\u00e9sactiv\u00e9e, Home Assistant ne r\u00e9cup\u00e8re pas activement les \u00e9tats depuis le bus KNX et les options d'entit\u00e9 `sync_state` n'ont aucun effet." + "state_updater": "Active ou d\u00e9sactive globalement la lecture des \u00e9tats depuis le bus KNX. Lorsqu'elle est d\u00e9sactiv\u00e9e, Home Assistant ne r\u00e9cup\u00e8re pas activement les \u00e9tats depuis le bus KNX. Peut \u00eatre remplac\u00e9 par les options d'entit\u00e9 `sync_state`." } }, "tunnel": { diff --git a/homeassistant/components/plugwise/translations/ca.json b/homeassistant/components/plugwise/translations/ca.json index bd7371210ee..1bf9efee843 100644 --- a/homeassistant/components/plugwise/translations/ca.json +++ b/homeassistant/components/plugwise/translations/ca.json @@ -19,8 +19,8 @@ "port": "Port", "username": "Usuari de Smile" }, - "description": "Producte:", - "title": "Tipus de Plugwise" + "description": "Introdueix", + "title": "Connexi\u00f3 amb Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/et.json b/homeassistant/components/plugwise/translations/et.json index f863dd30b19..2d50be06193 100644 --- a/homeassistant/components/plugwise/translations/et.json +++ b/homeassistant/components/plugwise/translations/et.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "\u00dchenduse t\u00fc\u00fcp" + "flow_type": "\u00dchenduse t\u00fc\u00fcp", + "host": "IP aadress", + "password": "Smile ID", + "port": "Port", + "username": "Smile kasutajanimi" }, - "description": "Toode:", - "title": "Plugwise t\u00fc\u00fcp" + "description": "Sisesta andmed", + "title": "Loo \u00fchendus Smile-ga" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/id.json b/homeassistant/components/plugwise/translations/id.json index 436514d1a82..22c871e4f83 100644 --- a/homeassistant/components/plugwise/translations/id.json +++ b/homeassistant/components/plugwise/translations/id.json @@ -16,7 +16,8 @@ "flow_type": "Jenis koneksi", "host": "Alamat IP", "password": "ID Smile", - "port": "Port" + "port": "Port", + "username": "Nama Pengguna Smile" }, "description": "Masukkan", "title": "Hubungkan ke Smile" diff --git a/homeassistant/components/tankerkoenig/translations/el.json b/homeassistant/components/tankerkoenig/translations/el.json index 078001974ab..82dc5b9019b 100644 --- a/homeassistant/components/tankerkoenig/translations/el.json +++ b/homeassistant/components/tankerkoenig/translations/el.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "\u0397 \u03c4\u03bf\u03c0\u03bf\u03b8\u03b5\u03c3\u03af\u03b1 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + "already_configured": "\u0397 \u03c4\u03bf\u03c0\u03bf\u03b8\u03b5\u03c3\u03af\u03b1 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "reauth_successful": "\u039f \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" }, "error": { "invalid_auth": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", "no_stations": "\u0394\u03b5\u03bd \u03ae\u03c4\u03b1\u03bd \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03b5\u03cd\u03c1\u03b5\u03c3\u03b7 \u03c3\u03c4\u03b1\u03b8\u03bc\u03bf\u03cd \u03b5\u03bd\u03c4\u03cc\u03c2 \u03b5\u03bc\u03b2\u03ad\u03bb\u03b5\u03b9\u03b1\u03c2." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Key" + } + }, "select_station": { "data": { "stations": "\u03a3\u03c4\u03b1\u03b8\u03bc\u03bf\u03af" diff --git a/homeassistant/components/tankerkoenig/translations/et.json b/homeassistant/components/tankerkoenig/translations/et.json index b2cd4b42e06..028bac46d44 100644 --- a/homeassistant/components/tankerkoenig/translations/et.json +++ b/homeassistant/components/tankerkoenig/translations/et.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Asukoht on juba m\u00e4\u00e4ratud" + "already_configured": "Asukoht on juba m\u00e4\u00e4ratud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "invalid_auth": "Tuvastamine nurjus", "no_stations": "Piirkonnas ei leitud \u00fchtegi tanklat" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API v\u00f5ti" + } + }, "select_station": { "data": { "stations": "Tanklad" diff --git a/homeassistant/components/tankerkoenig/translations/id.json b/homeassistant/components/tankerkoenig/translations/id.json index 8ac50c9760f..ed0e2e15104 100644 --- a/homeassistant/components/tankerkoenig/translations/id.json +++ b/homeassistant/components/tankerkoenig/translations/id.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Lokasi sudah dikonfigurasi" + "already_configured": "Lokasi sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "invalid_auth": "Autentikasi tidak valid", "no_stations": "Tidak dapat menemukan SPBU dalam jangkauan." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Kunci API" + } + }, "select_station": { "data": { "stations": "SPBU" diff --git a/homeassistant/components/totalconnect/translations/id.json b/homeassistant/components/totalconnect/translations/id.json index 08bce345e75..b8fc7cdd7df 100644 --- a/homeassistant/components/totalconnect/translations/id.json +++ b/homeassistant/components/totalconnect/translations/id.json @@ -35,6 +35,7 @@ "data": { "auto_bypass_low_battery": "Otomatis dilewatkan saat baterai lemah" }, + "description": "Otomatis melewatkan zona saat baterai lemah dilaporkan.", "title": "Opsi TotalConnect" } } From f52fa3599fe74bdaa1682b8f3825256510478313 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 17:51:27 -1000 Subject: [PATCH 199/947] Only create auto comfort entities for BAF devices that support them (#72948) --- homeassistant/components/baf/climate.py | 2 +- homeassistant/components/baf/manifest.json | 2 +- homeassistant/components/baf/number.py | 47 ++++++++++++---------- homeassistant/components/baf/sensor.py | 6 ++- homeassistant/components/baf/switch.py | 7 +++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 40 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/baf/climate.py b/homeassistant/components/baf/climate.py index f785d18e06f..d4ed4ac4337 100644 --- a/homeassistant/components/baf/climate.py +++ b/homeassistant/components/baf/climate.py @@ -26,7 +26,7 @@ async def async_setup_entry( ) -> None: """Set up BAF fan auto comfort.""" data: BAFData = hass.data[DOMAIN][entry.entry_id] - if data.device.has_fan: + if data.device.has_fan and data.device.has_auto_comfort: async_add_entities( [BAFAutoComfort(data.device, f"{data.device.name} Auto Comfort")] ) diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 9dfc35685e3..8143c35410e 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -3,7 +3,7 @@ "name": "Big Ass Fans", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", - "requirements": ["aiobafi6==0.3.0"], + "requirements": ["aiobafi6==0.5.0"], "codeowners": ["@bdraco", "@jfroy"], "iot_class": "local_push", "zeroconf": [ diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 84358e79669..32a3ea5e693 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -36,27 +36,7 @@ class BAFNumberDescription(NumberEntityDescription, BAFNumberDescriptionMixin): """Class describing BAF sensor entities.""" -FAN_NUMBER_DESCRIPTIONS = ( - BAFNumberDescription( - key="return_to_auto_timeout", - name="Return to Auto Timeout", - min_value=ONE_MIN_SECS, - max_value=HALF_DAY_SECS, - entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, - value_fn=lambda device: cast(Optional[int], device.return_to_auto_timeout), - mode=NumberMode.SLIDER, - ), - BAFNumberDescription( - key="motion_sense_timeout", - name="Motion Sense Timeout", - min_value=ONE_MIN_SECS, - max_value=ONE_DAY_SECS, - entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, - value_fn=lambda device: cast(Optional[int], device.motion_sense_timeout), - mode=NumberMode.SLIDER, - ), +AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", name="Auto Comfort Minimum Speed", @@ -86,6 +66,29 @@ FAN_NUMBER_DESCRIPTIONS = ( ), ) +FAN_NUMBER_DESCRIPTIONS = ( + BAFNumberDescription( + key="return_to_auto_timeout", + name="Return to Auto Timeout", + min_value=ONE_MIN_SECS, + max_value=HALF_DAY_SECS, + entity_category=EntityCategory.CONFIG, + unit_of_measurement=TIME_SECONDS, + value_fn=lambda device: cast(Optional[int], device.return_to_auto_timeout), + mode=NumberMode.SLIDER, + ), + BAFNumberDescription( + key="motion_sense_timeout", + name="Motion Sense Timeout", + min_value=ONE_MIN_SECS, + max_value=ONE_DAY_SECS, + entity_category=EntityCategory.CONFIG, + unit_of_measurement=TIME_SECONDS, + value_fn=lambda device: cast(Optional[int], device.motion_sense_timeout), + mode=NumberMode.SLIDER, + ), +) + LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", @@ -125,6 +128,8 @@ async def async_setup_entry( descriptions.extend(FAN_NUMBER_DESCRIPTIONS) if device.has_light: descriptions.extend(LIGHT_NUMBER_DESCRIPTIONS) + if device.has_auto_comfort: + descriptions.extend(AUTO_COMFORT_NUMBER_DESCRIPTIONS) async_add_entities(BAFNumber(device, description) for description in descriptions) diff --git a/homeassistant/components/baf/sensor.py b/homeassistant/components/baf/sensor.py index 0f4239962cf..7b93b22fe2f 100644 --- a/homeassistant/components/baf/sensor.py +++ b/homeassistant/components/baf/sensor.py @@ -39,7 +39,7 @@ class BAFSensorDescription( """Class describing BAF sensor entities.""" -BASE_SENSORS = ( +AUTO_COMFORT_SENSORS = ( BAFSensorDescription( key="temperature", name="Temperature", @@ -103,10 +103,12 @@ async def async_setup_entry( """Set up BAF fan sensors.""" data: BAFData = hass.data[DOMAIN][entry.entry_id] device = data.device - sensors_descriptions = list(BASE_SENSORS) + sensors_descriptions: list[BAFSensorDescription] = [] for description in DEFINED_ONLY_SENSORS: if getattr(device, description.key): sensors_descriptions.append(description) + if device.has_auto_comfort: + sensors_descriptions.extend(AUTO_COMFORT_SENSORS) if device.has_fan: sensors_descriptions.extend(FAN_SENSORS) async_add_entities( diff --git a/homeassistant/components/baf/switch.py b/homeassistant/components/baf/switch.py index 6cefa0db65d..44671e68458 100644 --- a/homeassistant/components/baf/switch.py +++ b/homeassistant/components/baf/switch.py @@ -48,13 +48,16 @@ BASE_SWITCHES = [ ), ] -FAN_SWITCHES = [ +AUTO_COMFORT_SWITCHES = [ BAFSwitchDescription( key="comfort_heat_assist_enable", name="Auto Comfort Heat Assist", entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(Optional[bool], device.comfort_heat_assist_enable), ), +] + +FAN_SWITCHES = [ BAFSwitchDescription( key="fan_beep_enable", name="Beep", @@ -120,6 +123,8 @@ async def async_setup_entry( descriptions.extend(FAN_SWITCHES) if device.has_light: descriptions.extend(LIGHT_SWITCHES) + if device.has_auto_comfort: + descriptions.extend(AUTO_COMFORT_SWITCHES) async_add_entities(BAFSwitch(device, description) for description in descriptions) diff --git a/requirements_all.txt b/requirements_all.txt index 3abb93968bc..35b82892119 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -122,7 +122,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.3.0 +aiobafi6==0.5.0 # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 148ce2191ba..a4753b643fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.3.0 +aiobafi6==0.5.0 # homeassistant.components.aws aiobotocore==2.1.0 From 5b314142254c826f718b5b79f7de8f3da6e7c676 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 17:52:53 -1000 Subject: [PATCH 200/947] Fix misalignments between sql based filtering with the entityfilter based filtering (#72936) --- homeassistant/components/recorder/filters.py | 110 +++- homeassistant/components/recorder/history.py | 6 +- tests/components/history/test_init.py | 19 +- tests/components/logbook/test_init.py | 22 +- .../test_filters_with_entityfilter.py | 516 ++++++++++++++++++ 5 files changed, 620 insertions(+), 53 deletions(-) create mode 100644 tests/components/recorder/test_filters_with_entityfilter.py diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 3077f7f57f3..835496c2d6e 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -88,14 +88,32 @@ class Filters: self.included_domains: Iterable[str] = [] self.included_entity_globs: Iterable[str] = [] + def __repr__(self) -> str: + """Return human readable excludes/includes.""" + return ( + f"" + ) + @property def has_config(self) -> bool: """Determine if there is any filter configuration.""" + return bool(self._have_exclude or self._have_include) + + @property + def _have_exclude(self) -> bool: return bool( self.excluded_entities or self.excluded_domains or self.excluded_entity_globs - or self.included_entities + ) + + @property + def _have_include(self) -> bool: + return bool( + self.included_entities or self.included_domains or self.included_entity_globs ) @@ -103,36 +121,67 @@ class Filters: def _generate_filter_for_columns( self, columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: - includes = [] - if self.included_domains: - includes.append(_domain_matcher(self.included_domains, columns, encoder)) - if self.included_entities: - includes.append(_entity_matcher(self.included_entities, columns, encoder)) - if self.included_entity_globs: - includes.append( - _globs_to_like(self.included_entity_globs, columns, encoder) - ) + """Generate a filter from pre-comuted sets and pattern lists. - excludes = [] - if self.excluded_domains: - excludes.append(_domain_matcher(self.excluded_domains, columns, encoder)) - if self.excluded_entities: - excludes.append(_entity_matcher(self.excluded_entities, columns, encoder)) - if self.excluded_entity_globs: - excludes.append( - _globs_to_like(self.excluded_entity_globs, columns, encoder) - ) + This must match exactly how homeassistant.helpers.entityfilter works. + """ + i_domains = _domain_matcher(self.included_domains, columns, encoder) + i_entities = _entity_matcher(self.included_entities, columns, encoder) + i_entity_globs = _globs_to_like(self.included_entity_globs, columns, encoder) + includes = [i_domains, i_entities, i_entity_globs] - if not includes and not excludes: + e_domains = _domain_matcher(self.excluded_domains, columns, encoder) + e_entities = _entity_matcher(self.excluded_entities, columns, encoder) + e_entity_globs = _globs_to_like(self.excluded_entity_globs, columns, encoder) + excludes = [e_domains, e_entities, e_entity_globs] + + have_exclude = self._have_exclude + have_include = self._have_include + + # Case 1 - no includes or excludes - pass all entities + if not have_include and not have_exclude: return None - if includes and not excludes: + # Case 2 - includes, no excludes - only include specified entities + if have_include and not have_exclude: return or_(*includes).self_group() - if not includes and excludes: + # Case 3 - excludes, no includes - only exclude specified entities + if not have_include and have_exclude: return not_(or_(*excludes).self_group()) - return or_(*includes).self_group() & not_(or_(*excludes).self_group()) + # Case 4 - both includes and excludes specified + # Case 4a - include domain or glob specified + # - if domain is included, pass if entity not excluded + # - if glob is included, pass if entity and domain not excluded + # - if domain and glob are not included, pass if entity is included + # note: if both include domain matches then exclude domains ignored. + # If glob matches then exclude domains and glob checked + if self.included_domains or self.included_entity_globs: + return or_( + (i_domains & ~(e_entities | e_entity_globs)), + ( + ~i_domains + & or_( + (i_entity_globs & ~(or_(*excludes))), + (~i_entity_globs & i_entities), + ) + ), + ).self_group() + + # Case 4b - exclude domain or glob specified, include has no domain or glob + # In this one case the traditional include logic is inverted. Even though an + # include is specified since its only a list of entity IDs its used only to + # expose specific entities excluded by domain or glob. Any entities not + # excluded are then presumed included. Logic is as follows + # - if domain or glob is excluded, pass if entity is included + # - if domain is not excluded, pass if entity not excluded by ID + if self.excluded_domains or self.excluded_entity_globs: + return (not_(or_(*excludes)) | i_entities).self_group() + + # Case 4c - neither include or exclude domain specified + # - Only pass if entity is included. Ignore entity excludes. + return i_entities def states_entity_filter(self) -> ClauseList: """Generate the entity filter query.""" @@ -158,29 +207,32 @@ def _globs_to_like( glob_strs: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: """Translate glob to sql.""" - return or_( + matchers = [ cast(column, Text()).like( encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\" ) for glob_str in glob_strs for column in columns - ) + ] + return or_(*matchers) if matchers else or_(False) def _entity_matcher( entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: - return or_( + matchers = [ cast(column, Text()).in_([encoder(entity_id) for entity_id in entity_ids]) for column in columns - ) + ] + return or_(*matchers) if matchers else or_(False) def _domain_matcher( domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: - return or_( + matchers = [ cast(column, Text()).like(encoder(f"{domain}.%")) for domain in domains for column in columns - ) + ] + return or_(*matchers) if matchers else or_(False) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 7e8e97eafd4..49796bd0158 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -237,7 +237,9 @@ def _significant_states_stmt( stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt += lambda q: q.filter(entity_filter) + stmt = stmt.add_criteria( + lambda q: q.filter(entity_filter), track_on=[filters] + ) stmt += lambda q: q.filter(States.last_updated > start_time) if end_time: @@ -529,7 +531,7 @@ def _get_states_for_all_stmt( stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt += lambda q: q.filter(entity_filter) + stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index cbc5e86c37e..9dc7af59a38 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -246,15 +246,11 @@ def test_get_significant_states_exclude(hass_history): def test_get_significant_states_exclude_include_entity(hass_history): """Test significant states when excluding domains and include entities. - We should not get back every thermostat and media player test changes. + We should not get back every thermostat change unless its specifically included """ hass = hass_history zero, four, states = record_states(hass) - del states["media_player.test2"] - del states["media_player.test3"] - del states["thermostat.test"] del states["thermostat.test2"] - del states["script.can_cancel_this_one"] config = history.CONFIG_SCHEMA( { @@ -340,14 +336,12 @@ def test_get_significant_states_include(hass_history): def test_get_significant_states_include_exclude_domain(hass_history): """Test if significant states when excluding and including domains. - We should not get back any changes since we include only the - media_player domain but also exclude it. + We should get back all the media_player domain changes + only since the include wins over the exclude but will + exclude everything else. """ hass = hass_history zero, four, states = record_states(hass) - del states["media_player.test"] - del states["media_player.test2"] - del states["media_player.test3"] del states["thermostat.test"] del states["thermostat.test2"] del states["script.can_cancel_this_one"] @@ -372,7 +366,6 @@ def test_get_significant_states_include_exclude_entity(hass_history): """ hass = hass_history zero, four, states = record_states(hass) - del states["media_player.test"] del states["media_player.test2"] del states["media_player.test3"] del states["thermostat.test"] @@ -394,12 +387,12 @@ def test_get_significant_states_include_exclude_entity(hass_history): def test_get_significant_states_include_exclude(hass_history): """Test if significant states when in/excluding domains and entities. - We should only get back changes of the media_player.test2 entity. + We should get back changes of the media_player.test2, media_player.test3, + and thermostat.test. """ hass = hass_history zero, four, states = record_states(hass) del states["media_player.test"] - del states["thermostat.test"] del states["thermostat.test2"] del states["script.can_cancel_this_one"] diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d33bbd5b8ac..651a00fb0cf 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -2037,7 +2037,7 @@ async def test_include_events_domain_glob(hass, hass_client, recorder_mock): _assert_entry(entries[3], name="included", entity_id=entity_id3) -async def test_include_exclude_events(hass, hass_client, recorder_mock): +async def test_include_exclude_events_no_globs(hass, hass_client, recorder_mock): """Test if events are filtered if include and exclude is configured.""" entity_id = "switch.bla" entity_id2 = "sensor.blu" @@ -2082,13 +2082,15 @@ async def test_include_exclude_events(hass, hass_client, recorder_mock): client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 4 + assert len(entries) == 6 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) - _assert_entry(entries[1], name="blu", entity_id=entity_id2, state="10") - _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="20") - _assert_entry(entries[3], name="keep", entity_id=entity_id4, state="10") + _assert_entry(entries[1], name="bla", entity_id=entity_id, state="10") + _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="10") + _assert_entry(entries[3], name="bla", entity_id=entity_id, state="20") + _assert_entry(entries[4], name="blu", entity_id=entity_id2, state="20") + _assert_entry(entries[5], name="keep", entity_id=entity_id4, state="10") async def test_include_exclude_events_with_glob_filters( @@ -2145,13 +2147,15 @@ async def test_include_exclude_events_with_glob_filters( client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 4 + assert len(entries) == 6 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) - _assert_entry(entries[1], name="blu", entity_id=entity_id2, state="10") - _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="20") - _assert_entry(entries[3], name="included", entity_id=entity_id4, state="30") + _assert_entry(entries[1], name="bla", entity_id=entity_id, state="10") + _assert_entry(entries[2], name="blu", entity_id=entity_id2, state="10") + _assert_entry(entries[3], name="bla", entity_id=entity_id, state="20") + _assert_entry(entries[4], name="blu", entity_id=entity_id2, state="20") + _assert_entry(entries[5], name="included", entity_id=entity_id4, state="30") async def test_empty_config(hass, hass_client, recorder_mock): diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py new file mode 100644 index 00000000000..0758d6fdc95 --- /dev/null +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -0,0 +1,516 @@ +"""The tests for the recorder filter matching the EntityFilter component.""" +import json + +from sqlalchemy import select +from sqlalchemy.engine.row import Row + +from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.filters import ( + Filters, + extract_include_exclude_filter_conf, + sqlalchemy_filter_from_include_exclude_conf, +) +from homeassistant.components.recorder.models import EventData, States +from homeassistant.components.recorder.util import session_scope +from homeassistant.const import ATTR_ENTITY_ID, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entityfilter import ( + CONF_DOMAINS, + CONF_ENTITIES, + CONF_ENTITY_GLOBS, + CONF_EXCLUDE, + CONF_INCLUDE, + convert_include_exclude_filter, +) + +from .common import async_wait_recording_done + + +async def _async_get_states_and_events_with_filter( + hass: HomeAssistant, sqlalchemy_filter: Filters, entity_ids: set[str] +) -> tuple[list[Row], list[Row]]: + """Get states from the database based on a filter.""" + for entity_id in entity_ids: + hass.states.async_set(entity_id, STATE_ON) + hass.bus.async_fire("any", {ATTR_ENTITY_ID: entity_id}) + + await async_wait_recording_done(hass) + + def _get_states_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(States.entity_id).filter( + sqlalchemy_filter.states_entity_filter() + ) + ).all() + + filtered_states_entity_ids = { + row[0] + for row in await get_instance(hass).async_add_executor_job( + _get_states_with_session + ) + } + + def _get_events_with_session(): + with session_scope(hass=hass) as session: + return session.execute( + select(EventData.shared_data).filter( + sqlalchemy_filter.events_entity_filter() + ) + ).all() + + filtered_events_entity_ids = set() + for row in await get_instance(hass).async_add_executor_job( + _get_events_with_session + ): + event_data = json.loads(row[0]) + if ATTR_ENTITY_ID not in event_data: + continue + filtered_events_entity_ids.add(json.loads(row[0])[ATTR_ENTITY_ID]) + + return filtered_states_entity_ids, filtered_events_entity_ids + + +async def test_included_and_excluded_simple_case_no_domains(hass, recorder_mock): + """Test filters with included and excluded without domains.""" + filter_accept = {"sensor.kitchen4", "switch.kitchen"} + filter_reject = { + "light.any", + "switch.other", + "cover.any", + "sensor.weather5", + "light.kitchen", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_no_globs(hass, recorder_mock): + """Test filters with included and excluded without globs.""" + filter_accept = {"switch.bla", "sensor.blu", "sensor.keep"} + filter_reject = {"sensor.bli"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["sensor", "homeassistant"], + CONF_ENTITIES: ["switch.bla"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["switch"], + CONF_ENTITIES: ["sensor.bli"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_without_underscores( + hass, recorder_mock +): + """Test filters with included and excluded without underscores.""" + filter_accept = {"light.any", "sensor.kitchen4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_simple_case_with_underscores(hass, recorder_mock): + """Test filters with included and excluded with underscores.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = {"switch.other", "cover.any", "sensor.weather_5", "light.kitchen"} + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["light"], + CONF_ENTITY_GLOBS: ["sensor.kitchen_*"], + CONF_ENTITIES: ["switch.kitchen"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["cover"], + CONF_ENTITY_GLOBS: ["sensor.weather_*"], + CONF_ENTITIES: ["light.kitchen"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + assert not entity_filter.explicitly_included("light.any") + assert not entity_filter.explicitly_included("switch.other") + assert entity_filter.explicitly_included("sensor.kitchen_4") + assert entity_filter.explicitly_included("switch.kitchen") + + assert not entity_filter.explicitly_excluded("light.any") + assert not entity_filter.explicitly_excluded("switch.other") + assert entity_filter.explicitly_excluded("sensor.weather_5") + assert entity_filter.explicitly_excluded("light.kitchen") + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_and_excluded_complex_case(hass, recorder_mock): + """Test filters with included and excluded with a complex filter.""" + filter_accept = {"light.any", "sensor.kitchen_4", "switch.kitchen"} + filter_reject = { + "camera.one", + "notify.any", + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["group.trackers"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: [ + "automation.update_readme", + "automation.update_utilities_cost", + "binary_sensor.iss", + ], + CONF_DOMAINS: [ + "camera", + "group", + "media_player", + "notify", + "scene", + "sun", + "zone", + ], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_included_entities_and_excluded_domain(hass, recorder_mock): + """Test filters with included entities and excluded domain.""" + filter_accept = { + "media_player.test", + "media_player.test3", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + filter_reject = { + "thermostat.test2", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test", "thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_domain_included_excluded(hass, recorder_mock): + """Test filters with the same domain included and excluded.""" + filter_accept = { + "media_player.test", + "media_player.test3", + } + filter_reject = { + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["media_player"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded(hass, recorder_mock): + """Test filters with the same entity included and excluded.""" + filter_accept = { + "media_player.test", + } + filter_reject = { + "media_player.test3", + "thermostat.test2", + "thermostat.test", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + CONF_EXCLUDE: { + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_mock): + """Test filters with domain and entities and the include domain wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + } + filter_reject = { + "thermostat.test2", + "zone.home", + "script.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_DOMAINS: ["media_player"], + CONF_ENTITIES: ["thermostat.test"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["thermostat"], + CONF_ENTITIES: ["media_player.test"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) From 3a8a8165845f31afdb7562489c9a538fd68381a0 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 2 Jun 2022 21:42:52 -0700 Subject: [PATCH 201/947] Cleanup nest config flow tests to use common setup fixtures (#72878) * Cleanup nest config flow tests to use common setup * Remove some conditionals in test setup --- tests/components/nest/common.py | 16 +- tests/components/nest/conftest.py | 25 +- tests/components/nest/test_config_flow_sdm.py | 367 +++++++----------- 3 files changed, 171 insertions(+), 237 deletions(-) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index c2a9c6db157..bf8af8db127 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -25,10 +25,15 @@ PlatformSetup = Callable[[], Awaitable[None]] _T = TypeVar("_T") YieldFixture = Generator[_T, None, None] +WEB_AUTH_DOMAIN = DOMAIN +APP_AUTH_DOMAIN = f"{DOMAIN}.installed" + PROJECT_ID = "some-project-id" CLIENT_ID = "some-client-id" CLIENT_SECRET = "some-client-secret" -SUBSCRIBER_ID = "projects/example/subscriptions/subscriber-id-9876" +CLOUD_PROJECT_ID = "cloud-id-9876" +SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" + CONFIG = { "nest": { @@ -79,10 +84,12 @@ TEST_CONFIG_YAML_ONLY = NestTestConfig( config=CONFIG, config_entry_data={ "sdm": {}, - "auth_implementation": "nest", "token": create_token_entry(), }, ) +TEST_CONFIGFLOW_YAML_ONLY = NestTestConfig( + config=TEST_CONFIG_YAML_ONLY.config, config_entry_data=None +) # Exercises mode where subscriber id is created in the config flow, but # all authentication is defined in configuration.yaml @@ -96,11 +103,14 @@ TEST_CONFIG_HYBRID = NestTestConfig( }, config_entry_data={ "sdm": {}, - "auth_implementation": "nest", "token": create_token_entry(), + "cloud_project_id": CLOUD_PROJECT_ID, "subscriber_id": SUBSCRIBER_ID, }, ) +TEST_CONFIGFLOW_HYBRID = NestTestConfig( + TEST_CONFIG_HYBRID.config, config_entry_data=None +) TEST_CONFIG_LEGACY = NestTestConfig( config={ diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index 9b060d38fbe..fafd04c3764 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -5,7 +5,7 @@ from collections.abc import Generator import copy import shutil from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import uuid import aiohttp @@ -24,6 +24,7 @@ from .common import ( SUBSCRIBER_ID, TEST_CONFIG_HYBRID, TEST_CONFIG_YAML_ONLY, + WEB_AUTH_DOMAIN, CreateDevice, FakeSubscriber, NestTestConfig, @@ -114,6 +115,17 @@ def subscriber() -> YieldFixture[FakeSubscriber]: yield subscriber +@pytest.fixture +def mock_subscriber() -> YieldFixture[AsyncMock]: + """Fixture for injecting errors into the subscriber.""" + mock_subscriber = AsyncMock(FakeSubscriber) + with patch( + "homeassistant.components.nest.api.GoogleNestSubscriber", + return_value=mock_subscriber, + ): + yield mock_subscriber + + @pytest.fixture async def device_manager(subscriber: FakeSubscriber) -> DeviceManager: """Set up the DeviceManager.""" @@ -170,6 +182,12 @@ def subscriber_id() -> str: return SUBSCRIBER_ID +@pytest.fixture +def auth_implementation() -> str | None: + """Fixture to let tests override the auth implementation in the config entry.""" + return WEB_AUTH_DOMAIN + + @pytest.fixture( params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_HYBRID], ids=["yaml-config-only", "hybrid-config"], @@ -195,7 +213,9 @@ def config( @pytest.fixture def config_entry( - subscriber_id: str | None, nest_test_config: NestTestConfig + subscriber_id: str | None, + auth_implementation: str | None, + nest_test_config: NestTestConfig, ) -> MockConfigEntry | None: """Fixture that sets up the ConfigEntry for the test.""" if nest_test_config.config_entry_data is None: @@ -206,6 +226,7 @@ def config_entry( data[CONF_SUBSCRIBER_ID] = subscriber_id else: del data[CONF_SUBSCRIBER_ID] + data["auth_implementation"] = auth_implementation return MockConfigEntry(domain=DOMAIN, data=data) diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index ab769d4b57c..ff55c1f518d 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -1,7 +1,6 @@ """Test the Google Nest Device Access config flow.""" -import copy -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from google_nest_sdm.exceptions import ( AuthException, @@ -11,35 +10,27 @@ from google_nest_sdm.exceptions import ( from google_nest_sdm.structure import Structure import pytest -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .common import FakeSubscriber, MockConfigEntry +from .common import ( + APP_AUTH_DOMAIN, + CLIENT_ID, + CLOUD_PROJECT_ID, + FAKE_TOKEN, + PROJECT_ID, + SUBSCRIBER_ID, + TEST_CONFIG_HYBRID, + TEST_CONFIG_YAML_ONLY, + TEST_CONFIGFLOW_HYBRID, + TEST_CONFIGFLOW_YAML_ONLY, + WEB_AUTH_DOMAIN, + MockConfigEntry, +) -CLIENT_ID = "1234" -CLIENT_SECRET = "5678" -PROJECT_ID = "project-id-4321" -SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" -CLOUD_PROJECT_ID = "cloud-id-9876" - -CONFIG = { - DOMAIN: { - "project_id": PROJECT_ID, - "subscriber_id": SUBSCRIBER_ID, - CONF_CLIENT_ID: CLIENT_ID, - CONF_CLIENT_SECRET: CLIENT_SECRET, - }, - "http": {"base_url": "https://example.com"}, -} - -ORIG_AUTH_DOMAIN = DOMAIN -WEB_AUTH_DOMAIN = DOMAIN -APP_AUTH_DOMAIN = f"{DOMAIN}.installed" WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" @@ -49,30 +40,6 @@ FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( ) -@pytest.fixture -def subscriber() -> FakeSubscriber: - """Create FakeSubscriber.""" - return FakeSubscriber() - - -def get_config_entry(hass): - """Return a single config entry.""" - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - return entries[0] - - -def create_config_entry(hass: HomeAssistant, data: dict) -> ConfigEntry: - """Create the ConfigEntry.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=data, - unique_id=DOMAIN, - ) - entry.add_to_hass(hass) - return entry - - class OAuthFixture: """Simulate the oauth flow used by the config flow.""" @@ -196,7 +163,9 @@ class OAuthFixture: def get_config_entry(self) -> ConfigEntry: """Get the config entry.""" - return get_config_entry(self.hass) + entries = self.hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + return entries[0] @pytest.fixture @@ -205,16 +174,10 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -async def async_setup_configflow(hass): - """Set up component so the pubsub subscriber is managed by config flow.""" - config = copy.deepcopy(CONFIG) - del config[DOMAIN]["subscriber_id"] # Create in config flow instead - return await setup.async_setup_component(hass, DOMAIN, config) - - -async def test_web_full_flow(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +async def test_web_full_flow(hass, oauth, setup_platform): """Check full flow.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -238,29 +201,15 @@ async def test_web_full_flow(hass, oauth): assert "subscriber_id" not in entry.data -async def test_web_reauth(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_YAML_ONLY]) +async def test_web_reauth(hass, oauth, setup_platform, config_entry): """Test Nest reauthentication.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() - old_entry = create_config_entry( - hass, - { - "auth_implementation": WEB_AUTH_DOMAIN, - "token": { - # Verify this is replaced at end of the test - "access_token": "some-revoked-token", - }, - "sdm": {}, - }, - ) + assert config_entry.data["token"].get("access_token") == FAKE_TOKEN - entry = get_config_entry(hass) - assert entry.data["token"] == { - "access_token": "some-revoked-token", - } - - result = await oauth.async_reauth(old_entry.data) + result = await oauth.async_reauth(config_entry.data) await oauth.async_oauth_web_flow(result) entry = await oauth.async_finish_setup(result) @@ -277,11 +226,9 @@ async def test_web_reauth(hass, oauth): assert "subscriber_id" not in entry.data # not updated -async def test_single_config_entry(hass): +async def test_single_config_entry(hass, setup_platform): """Test that only a single config entry is allowed.""" - create_config_entry(hass, {"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}}) - - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -290,18 +237,13 @@ async def test_single_config_entry(hass): assert result["reason"] == "single_instance_allowed" -async def test_unexpected_existing_config_entries(hass, oauth): +async def test_unexpected_existing_config_entries(hass, oauth, setup_platform): """Test Nest reauthentication with multiple existing config entries.""" # Note that this case will not happen in the future since only a single # instance is now allowed, but this may have been allowed in the past. # On reauth, only one entry is kept and the others are deleted. - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) - - old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} - ) - old_entry.add_to_hass(hass) + await setup_platform() old_entry = MockConfigEntry( domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} @@ -333,9 +275,9 @@ async def test_unexpected_existing_config_entries(hass, oauth): assert "subscriber_id" not in entry.data # not updated -async def test_reauth_missing_config_entry(hass): +async def test_reauth_missing_config_entry(hass, setup_platform): """Test the reauth flow invoked missing existing data.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() # Invoke the reauth flow with no existing data result = await hass.config_entries.flow.async_init( @@ -345,9 +287,10 @@ async def test_reauth_missing_config_entry(hass): assert result["reason"] == "missing_configuration" -async def test_app_full_flow(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +async def test_app_full_flow(hass, oauth, setup_platform): """Check full flow.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -370,24 +313,15 @@ async def test_app_full_flow(hass, oauth): assert "subscriber_id" not in entry.data -async def test_app_reauth(hass, oauth): +@pytest.mark.parametrize( + "nest_test_config,auth_implementation", [(TEST_CONFIG_YAML_ONLY, APP_AUTH_DOMAIN)] +) +async def test_app_reauth(hass, oauth, setup_platform, config_entry): """Test Nest reauthentication for Installed App Auth.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() - old_entry = create_config_entry( - hass, - { - "auth_implementation": APP_AUTH_DOMAIN, - "token": { - # Verify this is replaced at end of the test - "access_token": "some-revoked-token", - }, - "sdm": {}, - }, - ) - - result = await oauth.async_reauth(old_entry.data) + result = await oauth.async_reauth(config_entry.data) await oauth.async_oauth_app_flow(result) # Verify existing tokens are replaced @@ -404,9 +338,10 @@ async def test_app_reauth(hass, oauth): assert "subscriber_id" not in entry.data # not updated -async def test_pubsub_subscription(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_pubsub_subscription(hass, oauth, subscriber, setup_platform): """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -414,16 +349,11 @@ async def test_pubsub_subscription(hass, oauth, subscriber): result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) await oauth.async_oauth_app_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) assert entry.title == "OAuth for Apps" assert "token" in entry.data @@ -439,9 +369,12 @@ async def test_pubsub_subscription(hass, oauth, subscriber): assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_pubsub_subscription_strip_whitespace(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_pubsub_subscription_strip_whitespace( + hass, oauth, subscriber, setup_platform +): """Check that project id has whitespace stripped on entry.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -449,16 +382,11 @@ async def test_pubsub_subscription_strip_whitespace(hass, oauth, subscriber): result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) await oauth.async_oauth_app_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": " " + CLOUD_PROJECT_ID + " "} - ) - await hass.async_block_till_done() + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": " " + CLOUD_PROJECT_ID + " "} + ) assert entry.title == "OAuth for Apps" assert "token" in entry.data @@ -474,9 +402,12 @@ async def test_pubsub_subscription_strip_whitespace(hass, oauth, subscriber): assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_pubsub_subscription_auth_failure(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_pubsub_subscription_auth_failure( + hass, oauth, setup_platform, mock_subscriber +): """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -484,23 +415,22 @@ async def test_pubsub_subscription_auth_failure(hass, oauth): result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) await oauth.async_oauth_app_flow(result) result = await oauth.async_configure(result, {"code": "1234"}) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", - side_effect=AuthException(), - ): - await oauth.async_pubsub_flow(result) - result = await oauth.async_configure( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + + mock_subscriber.create_subscription.side_effect = AuthException() + + await oauth.async_pubsub_flow(result) + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) assert result["type"] == "abort" assert result["reason"] == "invalid_access_token" -async def test_pubsub_subscription_failure(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_pubsub_subscription_failure( + hass, oauth, setup_platform, mock_subscriber +): """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -509,14 +439,10 @@ async def test_pubsub_subscription_failure(hass, oauth): await oauth.async_oauth_app_flow(result) result = await oauth.async_configure(result, {"code": "1234"}) await oauth.async_pubsub_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", - side_effect=SubscriberException(), - ): - result = await oauth.async_configure( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + + mock_subscriber.create_subscription.side_effect = SubscriberException() + + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) assert result["type"] == "form" assert "errors" in result @@ -524,9 +450,12 @@ async def test_pubsub_subscription_failure(hass, oauth): assert result["errors"]["cloud_project_id"] == "subscriber_error" -async def test_pubsub_subscription_configuration_failure(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_pubsub_subscription_configuration_failure( + hass, oauth, setup_platform, mock_subscriber +): """Check flow that creates a pub/sub subscription.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -535,14 +464,9 @@ async def test_pubsub_subscription_configuration_failure(hass, oauth): await oauth.async_oauth_app_flow(result) result = await oauth.async_configure(result, {"code": "1234"}) await oauth.async_pubsub_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber.create_subscription", - side_effect=ConfigurationException(), - ): - result = await oauth.async_configure( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + + mock_subscriber.create_subscription.side_effect = ConfigurationException() + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) assert result["type"] == "form" assert "errors" in result @@ -550,9 +474,10 @@ async def test_pubsub_subscription_configuration_failure(hass, oauth): assert result["errors"]["cloud_project_id"] == "bad_project_id" -async def test_pubsub_with_wrong_project_id(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_pubsub_with_wrong_project_id(hass, oauth, setup_platform): """Test a possible common misconfiguration mixing up project ids.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -572,23 +497,16 @@ async def test_pubsub_with_wrong_project_id(hass, oauth): assert result["errors"]["cloud_project_id"] == "wrong_project_id" -async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): +@pytest.mark.parametrize( + "nest_test_config,auth_implementation", [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] +) +async def test_pubsub_subscriber_config_entry_reauth( + hass, oauth, setup_platform, subscriber, config_entry +): """Test the pubsub subscriber id is preserved during reauth.""" - assert await async_setup_configflow(hass) + await setup_platform() - old_entry = create_config_entry( - hass, - { - "auth_implementation": APP_AUTH_DOMAIN, - "subscriber_id": SUBSCRIBER_ID, - "cloud_project_id": CLOUD_PROJECT_ID, - "token": { - "access_token": "some-revoked-token", - }, - "sdm": {}, - }, - ) - result = await oauth.async_reauth(old_entry.data) + result = await oauth.async_reauth(config_entry.data) await oauth.async_oauth_app_flow(result) # Entering an updated access token refreshs the config entry. @@ -606,7 +524,8 @@ async def test_pubsub_subscriber_config_entry_reauth(hass, oauth, subscriber): assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_config_entry_title_from_home(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_config_entry_title_from_home(hass, oauth, setup_platform, subscriber): """Test that the Google Home name is used for the config entry title.""" device_manager = await subscriber.async_get_device_manager() @@ -623,7 +542,7 @@ async def test_config_entry_title_from_home(hass, oauth, subscriber): ) ) - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -631,16 +550,11 @@ async def test_config_entry_title_from_home(hass, oauth, subscriber): result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) await oauth.async_oauth_app_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) assert entry.title == "Example Home" assert "token" in entry.data @@ -648,7 +562,10 @@ async def test_config_entry_title_from_home(hass, oauth, subscriber): assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_config_entry_title_multiple_homes(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_config_entry_title_multiple_homes( + hass, oauth, setup_platform, subscriber +): """Test handling of multiple Google Homes authorized.""" device_manager = await subscriber.async_get_device_manager() @@ -677,7 +594,7 @@ async def test_config_entry_title_multiple_homes(hass, oauth, subscriber): ) ) - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -685,23 +602,18 @@ async def test_config_entry_title_multiple_homes(hass, oauth, subscriber): result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) await oauth.async_oauth_app_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() - + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) assert entry.title == "Example Home #1, Example Home #2" -async def test_title_failure_fallback(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_title_failure_fallback(hass, oauth, setup_platform, mock_subscriber): """Test exception handling when determining the structure names.""" - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -709,19 +621,13 @@ async def test_title_failure_fallback(hass, oauth): result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) await oauth.async_oauth_app_flow(result) - mock_subscriber = AsyncMock(FakeSubscriber) mock_subscriber.async_get_device_manager.side_effect = AuthException() - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=mock_subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) assert entry.title == "OAuth for Apps" assert "token" in entry.data @@ -729,7 +635,8 @@ async def test_title_failure_fallback(hass, oauth): assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -async def test_structure_missing_trait(hass, oauth, subscriber): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +async def test_structure_missing_trait(hass, oauth, setup_platform, subscriber): """Test handling the case where a structure has no name set.""" device_manager = await subscriber.async_get_device_manager() @@ -743,7 +650,7 @@ async def test_structure_missing_trait(hass, oauth, subscriber): ) ) - assert await async_setup_configflow(hass) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -751,16 +658,11 @@ async def test_structure_missing_trait(hass, oauth, subscriber): result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) await oauth.async_oauth_app_flow(result) - with patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - await hass.async_block_till_done() + result = await oauth.async_configure(result, {"code": "1234"}) + await oauth.async_pubsub_flow(result) + entry = await oauth.async_finish_setup( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) # Fallback to default name assert entry.title == "OAuth for Apps" @@ -778,9 +680,10 @@ async def test_dhcp_discovery_without_config(hass, oauth): assert result["reason"] == "missing_configuration" -async def test_dhcp_discovery(hass, oauth): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +async def test_dhcp_discovery(hass, oauth, setup_platform): """Discover via dhcp when config is present.""" - assert await setup.async_setup_component(hass, DOMAIN, CONFIG) + await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, From fe1c3d3be8cb947b8c60d5c0329425ab74f341ca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Jun 2022 08:00:47 +0200 Subject: [PATCH 202/947] Revert "Allow non-async functions in device automation (#72147)" (#72909) --- .../components/device_automation/__init__.py | 25 ++----------------- .../components/device_automation/action.py | 9 +++---- .../components/device_automation/condition.py | 9 +++---- .../components/device_automation/trigger.py | 9 +++---- 4 files changed, 14 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 99629f3dd23..0a1ec495e70 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -189,22 +189,6 @@ def _async_set_entity_device_automation_metadata( automation["metadata"]["secondary"] = bool(entry.entity_category or entry.hidden_by) -async def _async_get_automation_for_device( - hass: HomeAssistant, - platform: DeviceAutomationPlatformType, - function_name: str, - device_id: str, -) -> list[dict[str, Any]]: - """List device automations.""" - automations = getattr(platform, function_name)(hass, device_id) - if asyncio.iscoroutine(automations): - # Using a coroutine to get device automations is deprecated - # enable warning when core is fully migrated - # then remove in Home Assistant Core xxxx.xx - return await automations # type: ignore[no-any-return] - return automations # type: ignore[no-any-return] - - async def _async_get_device_automations_from_domain( hass: HomeAssistant, domain: str, @@ -224,7 +208,7 @@ async def _async_get_device_automations_from_domain( return await asyncio.gather( # type: ignore[no-any-return] *( - _async_get_automation_for_device(hass, platform, function_name, device_id) + getattr(platform, function_name)(hass, device_id) for device_id in device_ids ), return_exceptions=return_exceptions, @@ -310,12 +294,7 @@ async def _async_get_device_automation_capabilities( return {} try: - capabilities = getattr(platform, function_name)(hass, automation) - if asyncio.iscoroutine(capabilities): - # Using a coroutine to get device automation capabitilites is deprecated - # enable warning when core is fully migrated - # then remove in Home Assistant Core xxxx.xx - capabilities = await capabilities + capabilities = await getattr(platform, function_name)(hass, automation) except InvalidDeviceAutomationConfig: return {} diff --git a/homeassistant/components/device_automation/action.py b/homeassistant/components/device_automation/action.py index 5737fbc5bf3..081b6bb283a 100644 --- a/homeassistant/components/device_automation/action.py +++ b/homeassistant/components/device_automation/action.py @@ -1,7 +1,6 @@ """Device action validator.""" from __future__ import annotations -from collections.abc import Awaitable from typing import Any, Protocol, cast import voluptuous as vol @@ -36,14 +35,14 @@ class DeviceAutomationActionProtocol(Protocol): ) -> None: """Execute a device action.""" - def async_get_action_capabilities( + async def async_get_action_capabilities( self, hass: HomeAssistant, config: ConfigType - ) -> dict[str, vol.Schema] | Awaitable[dict[str, vol.Schema]]: + ) -> dict[str, vol.Schema]: """List action capabilities.""" - def async_get_actions( + async def async_get_actions( self, hass: HomeAssistant, device_id: str - ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]: + ) -> list[dict[str, Any]]: """List actions.""" diff --git a/homeassistant/components/device_automation/condition.py b/homeassistant/components/device_automation/condition.py index 1f1f8e94832..d656908f4be 100644 --- a/homeassistant/components/device_automation/condition.py +++ b/homeassistant/components/device_automation/condition.py @@ -1,7 +1,6 @@ """Validate device conditions.""" from __future__ import annotations -from collections.abc import Awaitable from typing import TYPE_CHECKING, Any, Protocol, cast import voluptuous as vol @@ -36,14 +35,14 @@ class DeviceAutomationConditionProtocol(Protocol): ) -> condition.ConditionCheckerType: """Evaluate state based on configuration.""" - def async_get_condition_capabilities( + async def async_get_condition_capabilities( self, hass: HomeAssistant, config: ConfigType - ) -> dict[str, vol.Schema] | Awaitable[dict[str, vol.Schema]]: + ) -> dict[str, vol.Schema]: """List condition capabilities.""" - def async_get_conditions( + async def async_get_conditions( self, hass: HomeAssistant, device_id: str - ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]: + ) -> list[dict[str, Any]]: """List conditions.""" diff --git a/homeassistant/components/device_automation/trigger.py b/homeassistant/components/device_automation/trigger.py index c5f42b3e813..eb39ec383af 100644 --- a/homeassistant/components/device_automation/trigger.py +++ b/homeassistant/components/device_automation/trigger.py @@ -1,7 +1,6 @@ """Offer device oriented automation.""" from __future__ import annotations -from collections.abc import Awaitable from typing import Any, Protocol, cast import voluptuous as vol @@ -46,14 +45,14 @@ class DeviceAutomationTriggerProtocol(Protocol): ) -> CALLBACK_TYPE: """Attach a trigger.""" - def async_get_trigger_capabilities( + async def async_get_trigger_capabilities( self, hass: HomeAssistant, config: ConfigType - ) -> dict[str, vol.Schema] | Awaitable[dict[str, vol.Schema]]: + ) -> dict[str, vol.Schema]: """List trigger capabilities.""" - def async_get_triggers( + async def async_get_triggers( self, hass: HomeAssistant, device_id: str - ) -> list[dict[str, Any]] | Awaitable[list[dict[str, Any]]]: + ) -> list[dict[str, Any]]: """List triggers.""" From f5c6ad24c4094e052c52f3252d535e4a5db3107f Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Fri, 3 Jun 2022 16:09:00 +1000 Subject: [PATCH 203/947] Bump aiolifx to 0.8.1 (#72897) Bump aiolifx version to support the latest LIFX devices LIFX added 22 new product definitions to their public product list at the end of January and those new products are defined in aiolifx v0.8.1, so bump the dependency version. Also switched to testing for relays instead of maintaining a seperate list of switch product IDs. Fixes #72894. Signed-off-by: Avi Miller --- homeassistant/components/lifx/light.py | 3 +-- homeassistant/components/lifx/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 31e973874d9..ea9bbeb91a2 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -73,7 +73,6 @@ MESSAGE_RETRIES = 3 UNAVAILABLE_GRACE = 90 FIX_MAC_FW = AwesomeVersion("3.70") -SWITCH_PRODUCT_IDS = [70, 71, 89] SERVICE_LIFX_SET_STATE = "set_state" @@ -403,7 +402,7 @@ class LIFXManager: # Get the product info first so that LIFX Switches # can be ignored. version_resp = await ack(bulb.get_version) - if version_resp and bulb.product in SWITCH_PRODUCT_IDS: + if version_resp and lifx_features(bulb)["relays"]: _LOGGER.debug( "Not connecting to LIFX Switch %s (%s)", str(bulb.mac_addr).replace(":", ""), diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index a7f266b6f7d..06e7b292ac6 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -3,7 +3,7 @@ "name": "LIFX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lifx", - "requirements": ["aiolifx==0.7.1", "aiolifx_effects==0.2.2"], + "requirements": ["aiolifx==0.8.1", "aiolifx_effects==0.2.2"], "dependencies": ["network"], "homekit": { "models": [ diff --git a/requirements_all.txt b/requirements_all.txt index 35b82892119..eaa63c401ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -181,7 +181,7 @@ aiokafka==0.6.0 aiokef==0.2.16 # homeassistant.components.lifx -aiolifx==0.7.1 +aiolifx==0.8.1 # homeassistant.components.lifx aiolifx_effects==0.2.2 From a28fa5377ac8f8291b6c80803491737438175358 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 2 Jun 2022 20:59:00 -1000 Subject: [PATCH 204/947] Remove unused code from logbook (#72950) --- homeassistant/components/logbook/processor.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index b3a43c2ca35..e5cc0f124b0 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -417,12 +417,6 @@ def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool: return False -def _row_event_data_extract(row: Row | EventAsRow, extractor: re.Pattern) -> str | None: - """Extract from event_data row.""" - result = extractor.search(row.shared_data or row.event_data or "") - return result.group(1) if result else None - - def _row_time_fired_isoformat(row: Row | EventAsRow) -> str: """Convert the row timed_fired to isoformat.""" return process_timestamp_to_utc_isoformat(row.time_fired or dt_util.utcnow()) From 8910d265d6cf15fed4e6e98b4344031019c1016d Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 3 Jun 2022 13:55:57 +0200 Subject: [PATCH 205/947] Keep track of a context for each listener (#72702) * Remove async_remove_listener This avoids the ambuigity as to what happens if same callback is added multiple times. * Keep track of a context for each listener This allow a update coordinator to adapt what data to request on update from the backing service based on which entities are enabled. * Clone list before calling callbacks The callbacks can end up unregistering and modifying the dict while iterating. * Only yield actual values * Add a test for update context * Factor out iteration of _listeners to helper * Verify context is passed to coordinator * Switch to Any as type instead of object * Remove function which use was dropped earliers The use was removed in 8bee25c938a123f0da7569b4e2753598d478b900 --- .../components/bmw_connected_drive/button.py | 2 +- .../bmw_connected_drive/coordinator.py | 5 -- .../components/modern_forms/__init__.py | 9 +-- .../components/moehlenhoff_alpha2/__init__.py | 10 ++- .../components/philips_js/__init__.py | 26 ++------ .../components/system_bridge/coordinator.py | 19 ++---- homeassistant/components/toon/coordinator.py | 7 +- homeassistant/components/toon/helpers.py | 4 +- homeassistant/components/wemo/wemo_device.py | 6 -- homeassistant/components/wled/coordinator.py | 7 +- homeassistant/components/wled/helpers.py | 4 +- .../yamaha_musiccast/media_player.py | 5 +- homeassistant/helpers/update_coordinator.py | 65 +++++++++++-------- tests/helpers/test_update_coordinator.py | 41 +++++++++--- 14 files changed, 95 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 9cec9a73ce7..baa7870ee8c 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -131,4 +131,4 @@ class BMWButton(BMWBaseEntity, ButtonEntity): # Always update HA states after a button was executed. # BMW remote services that change the vehicle's state update the local object # when executing the service, so only the HA state machine needs further updates. - self.coordinator.notify_listeners() + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 47d1f358686..1443a3e1e29 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -74,8 +74,3 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): if not refresh_token: data.pop(CONF_REFRESH_TOKEN) self.hass.config_entries.async_update_entry(self._entry, data=data) - - def notify_listeners(self) -> None: - """Notify all listeners to refresh HA state machine.""" - for update_callback in self._listeners: - update_callback() diff --git a/homeassistant/components/modern_forms/__init__.py b/homeassistant/components/modern_forms/__init__.py index af4f05a1536..ed4212d9444 100644 --- a/homeassistant/components/modern_forms/__init__.py +++ b/homeassistant/components/modern_forms/__init__.py @@ -74,12 +74,12 @@ def modernforms_exception_handler(func): async def handler(self, *args, **kwargs): try: await func(self, *args, **kwargs) - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ModernFormsConnectionError as error: _LOGGER.error("Error communicating with API: %s", error) self.coordinator.last_update_success = False - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ModernFormsError as error: _LOGGER.error("Invalid response from API: %s", error) @@ -108,11 +108,6 @@ class ModernFormsDataUpdateCoordinator(DataUpdateCoordinator[ModernFormsDeviceSt update_interval=SCAN_INTERVAL, ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() - async def _async_update_data(self) -> ModernFormsDevice: """Fetch data from Modern Forms.""" try: diff --git a/homeassistant/components/moehlenhoff_alpha2/__init__.py b/homeassistant/components/moehlenhoff_alpha2/__init__.py index 86306a56033..64bdfeb4e6d 100644 --- a/homeassistant/components/moehlenhoff_alpha2/__init__.py +++ b/homeassistant/components/moehlenhoff_alpha2/__init__.py @@ -83,8 +83,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): async def async_set_cooling(self, enabled: bool) -> None: """Enable or disable cooling mode.""" await self.base.set_cooling(enabled) - for update_callback in self._listeners: - update_callback() + self.async_update_listeners() async def async_set_target_temperature( self, heat_area_id: str, target_temperature: float @@ -117,8 +116,7 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): "Failed to set target temperature, communication error with alpha2 base" ) from http_err self.data["heat_areas"][heat_area_id].update(update_data) - for update_callback in self._listeners: - update_callback() + self.async_update_listeners() async def async_set_heat_area_mode( self, heat_area_id: str, heat_area_mode: int @@ -161,5 +159,5 @@ class Alpha2BaseCoordinator(DataUpdateCoordinator[dict[str, dict]]): self.data["heat_areas"][heat_area_id]["T_TARGET"] = self.data[ "heat_areas" ][heat_area_id]["T_HEAT_NIGHT"] - for update_callback in self._listeners: - update_callback() + + self.async_update_listeners() diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py index 9a317726768..29e92a6ffe3 100644 --- a/homeassistant/components/philips_js/__init__.py +++ b/homeassistant/components/philips_js/__init__.py @@ -19,14 +19,7 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import ( - CALLBACK_TYPE, - Context, - Event, - HassJob, - HomeAssistant, - callback, -) +from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -121,12 +114,7 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): self.options = options self._notify_future: asyncio.Task | None = None - @callback - def _update_listeners(): - for update_callback in self._listeners: - update_callback() - - self.turn_on = PluggableAction(_update_listeners) + self.turn_on = PluggableAction(self.async_update_listeners) super().__init__( hass, @@ -193,15 +181,9 @@ class PhilipsTVDataUpdateCoordinator(DataUpdateCoordinator[None]): self._notify_future = asyncio.create_task(self._notify_task()) @callback - def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: + def _unschedule_refresh(self) -> None: """Remove data update.""" - super().async_remove_listener(update_callback) - if not self._listeners: - self._async_notify_stop() - - @callback - def _async_stop_refresh(self, event: Event) -> None: - super()._async_stop_refresh(event) + super()._unschedule_refresh() self._async_notify_stop() @callback diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index 89a0c85c1d9..a7343116cde 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -75,11 +75,6 @@ class SystemBridgeDataUpdateCoordinator( hass, LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() - async def async_get_data( self, modules: list[str], @@ -113,7 +108,7 @@ class SystemBridgeDataUpdateCoordinator( self.unsub() self.unsub = None self.last_update_success = False - self.update_listeners() + self.async_update_listeners() except (ConnectionClosedException, ConnectionResetError) as exception: self.logger.info( "Websocket connection closed for %s. Will retry: %s", @@ -124,7 +119,7 @@ class SystemBridgeDataUpdateCoordinator( self.unsub() self.unsub = None self.last_update_success = False - self.update_listeners() + self.async_update_listeners() except ConnectionErrorException as exception: self.logger.warning( "Connection error occurred for %s. Will retry: %s", @@ -135,7 +130,7 @@ class SystemBridgeDataUpdateCoordinator( self.unsub() self.unsub = None self.last_update_success = False - self.update_listeners() + self.async_update_listeners() async def _setup_websocket(self) -> None: """Use WebSocket for updates.""" @@ -151,7 +146,7 @@ class SystemBridgeDataUpdateCoordinator( self.unsub() self.unsub = None self.last_update_success = False - self.update_listeners() + self.async_update_listeners() except ConnectionErrorException as exception: self.logger.warning( "Connection error occurred for %s. Will retry: %s", @@ -159,7 +154,7 @@ class SystemBridgeDataUpdateCoordinator( exception, ) self.last_update_success = False - self.update_listeners() + self.async_update_listeners() except asyncio.TimeoutError as exception: self.logger.warning( "Timed out waiting for %s. Will retry: %s", @@ -167,11 +162,11 @@ class SystemBridgeDataUpdateCoordinator( exception, ) self.last_update_success = False - self.update_listeners() + self.async_update_listeners() self.hass.async_create_task(self._listen_for_data()) self.last_update_success = True - self.update_listeners() + self.async_update_listeners() async def close_websocket(_) -> None: """Close WebSocket connection.""" diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py index 81c09931fbd..5819ff12743 100644 --- a/homeassistant/components/toon/coordinator.py +++ b/homeassistant/components/toon/coordinator.py @@ -47,11 +47,6 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() - async def register_webhook(self, event: Event | None = None) -> None: """Register a webhook with Toon to get live updates.""" if CONF_WEBHOOK_ID not in self.entry.data: @@ -128,7 +123,7 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]): try: await self.toon.update(data["updateDataSet"]) - self.update_listeners() + self.async_update_listeners() except ToonError as err: _LOGGER.error("Could not process data received from Toon webhook - %s", err) diff --git a/homeassistant/components/toon/helpers.py b/homeassistant/components/toon/helpers.py index 405ecc36d7f..4fb4daede65 100644 --- a/homeassistant/components/toon/helpers.py +++ b/homeassistant/components/toon/helpers.py @@ -16,12 +16,12 @@ def toon_exception_handler(func): async def handler(self, *args, **kwargs): try: await func(self, *args, **kwargs) - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ToonConnectionError as error: _LOGGER.error("Error communicating with API: %s", error) self.coordinator.last_update_success = False - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except ToonError as error: _LOGGER.error("Invalid response from API: %s", error) diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 8f5e6864059..1f3e07881c8 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -123,12 +123,6 @@ class DeviceCoordinator(DataUpdateCoordinator): except ActionException as err: raise UpdateFailed("WeMo update failed") from err - @callback - def async_update_listeners(self) -> None: - """Update all listeners.""" - for update_callback in self._listeners: - update_callback() - def _device_info(wemo: WeMoDevice) -> DeviceInfo: return DeviceInfo( diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index a4cbaade8ba..81017779fbb 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -54,11 +54,6 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): self.data is not None and len(self.data.state.segments) > 1 ) - def update_listeners(self) -> None: - """Call update on all listeners.""" - for update_callback in self._listeners: - update_callback() - @callback def _use_websocket(self) -> None: """Use WebSocket for updates, instead of polling.""" @@ -81,7 +76,7 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]): self.logger.info(err) except WLEDError as err: self.last_update_success = False - self.update_listeners() + self.async_update_listeners() self.logger.error(err) # Ensure we are disconnected diff --git a/homeassistant/components/wled/helpers.py b/homeassistant/components/wled/helpers.py index 66cd8b13b42..77e288bb34d 100644 --- a/homeassistant/components/wled/helpers.py +++ b/homeassistant/components/wled/helpers.py @@ -15,11 +15,11 @@ def wled_exception_handler(func): async def handler(self, *args, **kwargs): try: await func(self, *args, **kwargs) - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() except WLEDConnectionError as error: self.coordinator.last_update_success = False - self.coordinator.update_listeners() + self.coordinator.async_update_listeners() raise HomeAssistantError("Error communicating with WLED API") from error except WLEDError as error: diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py index 954942b2c6b..cee6253531b 100644 --- a/homeassistant/components/yamaha_musiccast/media_player.py +++ b/homeassistant/components/yamaha_musiccast/media_player.py @@ -106,7 +106,9 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): self.coordinator.musiccast.register_group_update_callback( self.update_all_mc_entities ) - self.coordinator.async_add_listener(self.async_schedule_check_client_list) + self.async_on_remove( + self.coordinator.async_add_listener(self.async_schedule_check_client_list) + ) async def async_will_remove_from_hass(self): """Entity being removed from hass.""" @@ -116,7 +118,6 @@ class MusicCastMediaPlayer(MusicCastDeviceEntity, MediaPlayerEntity): self.coordinator.musiccast.remove_group_update_callback( self.update_all_mc_entities ) - self.coordinator.async_remove_listener(self.async_schedule_check_client_list) @property def should_poll(self): diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index f7ad8e013cb..f671e1b973a 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Generator from datetime import datetime, timedelta import logging from time import monotonic @@ -13,7 +13,7 @@ import aiohttp import requests from homeassistant import config_entries -from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.util.dt import utcnow @@ -61,7 +61,7 @@ class DataUpdateCoordinator(Generic[_T]): # when it was already checked during setup. self.data: _T = None # type: ignore[assignment] - self._listeners: list[CALLBACK_TYPE] = [] + self._listeners: dict[CALLBACK_TYPE, tuple[CALLBACK_TYPE, object | None]] = {} self._job = HassJob(self._handle_refresh_interval) self._unsub_refresh: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None @@ -82,32 +82,46 @@ class DataUpdateCoordinator(Generic[_T]): self._debounced_refresh = request_refresh_debouncer @callback - def async_add_listener(self, update_callback: CALLBACK_TYPE) -> Callable[[], None]: + def async_add_listener( + self, update_callback: CALLBACK_TYPE, context: Any = None + ) -> Callable[[], None]: """Listen for data updates.""" schedule_refresh = not self._listeners - self._listeners.append(update_callback) + @callback + def remove_listener() -> None: + """Remove update listener.""" + self._listeners.pop(remove_listener) + if not self._listeners: + self._unschedule_refresh() + + self._listeners[remove_listener] = (update_callback, context) # This is the first listener, set up interval. if schedule_refresh: self._schedule_refresh() - @callback - def remove_listener() -> None: - """Remove update listener.""" - self.async_remove_listener(update_callback) - return remove_listener @callback - def async_remove_listener(self, update_callback: CALLBACK_TYPE) -> None: - """Remove data update.""" - self._listeners.remove(update_callback) + def async_update_listeners(self) -> None: + """Update all registered listeners.""" + for update_callback, _ in list(self._listeners.values()): + update_callback() - if not self._listeners and self._unsub_refresh: + @callback + def _unschedule_refresh(self) -> None: + """Unschedule any pending refresh since there is no longer any listeners.""" + if self._unsub_refresh: self._unsub_refresh() self._unsub_refresh = None + def async_contexts(self) -> Generator[Any, None, None]: + """Return all registered contexts.""" + yield from ( + context for _, context in self._listeners.values() if context is not None + ) + @callback def _schedule_refresh(self) -> None: """Schedule a refresh.""" @@ -266,8 +280,7 @@ class DataUpdateCoordinator(Generic[_T]): if not auth_failed and self._listeners and not self.hass.is_stopping: self._schedule_refresh() - for update_callback in self._listeners: - update_callback() + self.async_update_listeners() @callback def async_set_updated_data(self, data: _T) -> None: @@ -288,24 +301,18 @@ class DataUpdateCoordinator(Generic[_T]): if self._listeners: self._schedule_refresh() - for update_callback in self._listeners: - update_callback() - - @callback - def _async_stop_refresh(self, _: Event) -> None: - """Stop refreshing when Home Assistant is stopping.""" - self.update_interval = None - if self._unsub_refresh: - self._unsub_refresh() - self._unsub_refresh = None + self.async_update_listeners() class CoordinatorEntity(entity.Entity, Generic[_DataUpdateCoordinatorT]): """A class for entities using DataUpdateCoordinator.""" - def __init__(self, coordinator: _DataUpdateCoordinatorT) -> None: + def __init__( + self, coordinator: _DataUpdateCoordinatorT, context: Any = None + ) -> None: """Create the entity with a DataUpdateCoordinator.""" self.coordinator = coordinator + self.coordinator_context = context @property def should_poll(self) -> bool: @@ -321,7 +328,9 @@ class CoordinatorEntity(entity.Entity, Generic[_DataUpdateCoordinatorT]): """When entity is added to hass.""" await super().async_added_to_hass() self.async_on_remove( - self.coordinator.async_add_listener(self._handle_coordinator_update) + self.coordinator.async_add_listener( + self._handle_coordinator_update, self.coordinator_context + ) ) @callback diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 7023798f2b4..0d0970a4756 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -109,11 +109,29 @@ async def test_async_refresh(crd): await crd.async_refresh() assert updates == [2] - # Test unsubscribing through method - crd.async_add_listener(update_callback) - crd.async_remove_listener(update_callback) + +async def test_update_context(crd: update_coordinator.DataUpdateCoordinator[int]): + """Test update contexts for the update coordinator.""" await crd.async_refresh() - assert updates == [2] + assert not set(crd.async_contexts()) + + def update_callback1(): + pass + + def update_callback2(): + pass + + unsub1 = crd.async_add_listener(update_callback1, 1) + assert set(crd.async_contexts()) == {1} + + unsub2 = crd.async_add_listener(update_callback2, 2) + assert set(crd.async_contexts()) == {1, 2} + + unsub1() + assert set(crd.async_contexts()) == {2} + + unsub2() + assert not set(crd.async_contexts()) async def test_request_refresh(crd): @@ -191,7 +209,7 @@ async def test_update_interval(hass, crd): # Add subscriber update_callback = Mock() - crd.async_add_listener(update_callback) + unsub = crd.async_add_listener(update_callback) # Test twice we update with subscriber async_fire_time_changed(hass, utcnow() + crd.update_interval) @@ -203,7 +221,7 @@ async def test_update_interval(hass, crd): assert crd.data == 2 # Test removing listener - crd.async_remove_listener(update_callback) + unsub() async_fire_time_changed(hass, utcnow() + crd.update_interval) await hass.async_block_till_done() @@ -222,7 +240,7 @@ async def test_update_interval_not_present(hass, crd_without_update_interval): # Add subscriber update_callback = Mock() - crd.async_add_listener(update_callback) + unsub = crd.async_add_listener(update_callback) # Test twice we don't update with subscriber with no update interval async_fire_time_changed(hass, utcnow() + DEFAULT_UPDATE_INTERVAL) @@ -234,7 +252,7 @@ async def test_update_interval_not_present(hass, crd_without_update_interval): assert crd.data is None # Test removing listener - crd.async_remove_listener(update_callback) + unsub() async_fire_time_changed(hass, utcnow() + DEFAULT_UPDATE_INTERVAL) await hass.async_block_till_done() @@ -253,9 +271,10 @@ async def test_refresh_recover(crd, caplog): assert "Fetching test data recovered" in caplog.text -async def test_coordinator_entity(crd): +async def test_coordinator_entity(crd: update_coordinator.DataUpdateCoordinator[int]): """Test the CoordinatorEntity class.""" - entity = update_coordinator.CoordinatorEntity(crd) + context = object() + entity = update_coordinator.CoordinatorEntity(crd, context) assert entity.should_poll is False @@ -278,6 +297,8 @@ async def test_coordinator_entity(crd): await entity.async_update() assert entity.available is False + assert list(crd.async_contexts()) == [context] + async def test_async_set_updated_data(crd): """Test async_set_updated_data for update coordinator.""" From 52149c442ecc767d7164449700367df749d60bcd Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 3 Jun 2022 13:57:59 +0200 Subject: [PATCH 206/947] Bump pynetgear to 0.10.4 (#72965) bump pynetgear to 0.10.4 --- homeassistant/components/netgear/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index ae0824c82a5..f65f5aa6686 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -2,7 +2,7 @@ "domain": "netgear", "name": "NETGEAR", "documentation": "https://www.home-assistant.io/integrations/netgear", - "requirements": ["pynetgear==0.10.0"], + "requirements": ["pynetgear==0.10.4"], "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], "iot_class": "local_polling", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index eaa63c401ba..bb8f3f1cf9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1673,7 +1673,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.0 +pynetgear==0.10.4 # homeassistant.components.netio pynetio==0.1.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4753b643fb..a750e708354 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1131,7 +1131,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.0 +pynetgear==0.10.4 # homeassistant.components.nina pynina==0.1.8 From 88129dbe9180a5f5370343b2fd151c1f371599db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Beamonte?= Date: Fri, 3 Jun 2022 09:27:10 -0400 Subject: [PATCH 207/947] Allow `log` template function to return specified `default` on math domain error (#72960) Fix regression for logarithm template --- homeassistant/helpers/template.py | 14 +++++++------- tests/helpers/test_template.py | 2 ++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 1a8febf5ac2..053beab307e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -1369,19 +1369,19 @@ def multiply(value, amount, default=_SENTINEL): def logarithm(value, base=math.e, default=_SENTINEL): """Filter and function to get logarithm of the value with a specific base.""" - try: - value_float = float(value) - except (ValueError, TypeError): - if default is _SENTINEL: - raise_no_default("log", value) - return default try: base_float = float(base) except (ValueError, TypeError): if default is _SENTINEL: raise_no_default("log", base) return default - return math.log(value_float, base_float) + try: + value_float = float(value) + return math.log(value_float, base_float) + except (ValueError, TypeError): + if default is _SENTINEL: + raise_no_default("log", value) + return default def sine(value, default=_SENTINEL): diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index ddda17c20ac..a1fd3e73f59 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -447,6 +447,8 @@ def test_logarithm(hass): assert render(hass, "{{ 'no_number' | log(10, default=1) }}") == 1 assert render(hass, "{{ log('no_number', 10, 1) }}") == 1 assert render(hass, "{{ log('no_number', 10, default=1) }}") == 1 + assert render(hass, "{{ log(0, 10, 1) }}") == 1 + assert render(hass, "{{ log(0, 10, default=1) }}") == 1 def test_sine(hass): From beab6e2e5fcc5f9b254ab92c011ad39328072fbf Mon Sep 17 00:00:00 2001 From: w35l3y Date: Fri, 3 Jun 2022 10:32:22 -0300 Subject: [PATCH 208/947] Fix ended session when there isn't any response from the user (#72218) * Fix end session when there isn't any response This PR fixes #72153 * Added test case as requested https://github.com/home-assistant/core/pull/72218#discussion_r881584812 --- homeassistant/components/alexa/intent.py | 10 ++-- .../components/intent_script/__init__.py | 12 +++-- tests/components/alexa/test_intent.py | 7 ++- tests/components/intent_script/test_init.py | 48 +++++++++++++++++++ 4 files changed, 65 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 7352bbd995a..ef145a9ceb8 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -127,11 +127,6 @@ async def async_handle_message(hass, message): @HANDLERS.register("SessionEndedRequest") -async def async_handle_session_end(hass, message): - """Handle a session end request.""" - return None - - @HANDLERS.register("IntentRequest") @HANDLERS.register("LaunchRequest") async def async_handle_intent(hass, message): @@ -151,6 +146,11 @@ async def async_handle_intent(hass, message): intent_name = ( message.get("session", {}).get("application", {}).get("applicationId") ) + elif req["type"] == "SessionEndedRequest": + app_id = message.get("session", {}).get("application", {}).get("applicationId") + intent_name = f"{app_id}.{req['type']}" + alexa_response.variables["reason"] = req["reason"] + alexa_response.variables["error"] = req.get("error") else: intent_name = alexa_intent_info["name"] diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py index d14aaf5a68b..e8c5c580708 100644 --- a/homeassistant/components/intent_script/__init__.py +++ b/homeassistant/components/intent_script/__init__.py @@ -99,11 +99,13 @@ class ScriptIntentHandler(intent.IntentHandler): speech[CONF_TYPE], ) - if reprompt is not None and reprompt[CONF_TEXT].template: - response.async_set_reprompt( - reprompt[CONF_TEXT].async_render(slots, parse_result=False), - reprompt[CONF_TYPE], - ) + if reprompt is not None: + text_reprompt = reprompt[CONF_TEXT].async_render(slots, parse_result=False) + if text_reprompt: + response.async_set_reprompt( + text_reprompt, + reprompt[CONF_TYPE], + ) if card is not None: response.async_set_card( diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index f15fa860c7b..9c71bc32e4d 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -490,8 +490,11 @@ async def test_intent_session_ended_request(alexa_client): req = await _intent_req(alexa_client, data) assert req.status == HTTPStatus.OK - text = await req.text() - assert text == "" + data = await req.json() + assert ( + data["response"]["outputSpeech"]["text"] + == "This intent is not yet configured within Home Assistant." + ) async def test_intent_from_built_in_intent_library(alexa_client): diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py index 6f345522e63..39f865f4832 100644 --- a/tests/components/intent_script/test_init.py +++ b/tests/components/intent_script/test_init.py @@ -38,6 +38,8 @@ async def test_intent_script(hass): assert response.speech["plain"]["speech"] == "Good morning Paulus" + assert not (response.reprompt) + assert response.card["simple"]["title"] == "Hello Paulus" assert response.card["simple"]["content"] == "Content for Paulus" @@ -85,3 +87,49 @@ async def test_intent_script_wait_response(hass): assert response.card["simple"]["title"] == "Hello Paulus" assert response.card["simple"]["content"] == "Content for Paulus" + + +async def test_intent_script_falsy_reprompt(hass): + """Test intent scripts work.""" + calls = async_mock_service(hass, "test", "service") + + await async_setup_component( + hass, + "intent_script", + { + "intent_script": { + "HelloWorld": { + "action": { + "service": "test.service", + "data_template": {"hello": "{{ name }}"}, + }, + "card": { + "title": "Hello {{ name }}", + "content": "Content for {{ name }}", + }, + "speech": { + "type": "ssml", + "text": 'Good morning {{ name }}', + }, + "reprompt": {"text": "{{ null }}"}, + } + } + }, + ) + + response = await intent.async_handle( + hass, "test", "HelloWorld", {"name": {"value": "Paulus"}} + ) + + assert len(calls) == 1 + assert calls[0].data["hello"] == "Paulus" + + assert ( + response.speech["ssml"]["speech"] + == 'Good morning Paulus' + ) + + assert not (response.reprompt) + + assert response.card["simple"]["title"] == "Hello Paulus" + assert response.card["simple"]["content"] == "Content for Paulus" From 6cadd4f6657ee5f12b5a6d28f61455a3c94cefa0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Fri, 3 Jun 2022 15:33:43 +0200 Subject: [PATCH 209/947] MotionBlinds use device_name helper (#72438) * use device_name helper * fix typo * fix import * fix isort * add gateway_test * implement gateway test * correct test blind mac --- .../components/motion_blinds/cover.py | 6 +++--- .../components/motion_blinds/gateway.py | 9 ++++++++- .../components/motion_blinds/sensor.py | 19 ++++--------------- .../components/motion_blinds/test_gateway.py | 19 +++++++++++++++++++ 4 files changed, 34 insertions(+), 19 deletions(-) create mode 100644 tests/components/motion_blinds/test_gateway.py diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 7bac3a5fb20..23e7e3d834a 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -35,6 +35,7 @@ from .const import ( SERVICE_SET_ABSOLUTE_POSITION, UPDATE_INTERVAL_MOVING, ) +from .gateway import device_name _LOGGER = logging.getLogger(__name__) @@ -193,13 +194,12 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): if blind.device_type in DEVICE_TYPES_WIFI: via_device = () connections = {(dr.CONNECTION_NETWORK_MAC, blind.mac)} - name = blind.blind_type else: via_device = (DOMAIN, blind._gateway.mac) connections = {} - name = f"{blind.blind_type} {blind.mac[12:]}" sw_version = None + name = device_name(blind) self._attr_device_class = device_class self._attr_name = name self._attr_unique_id = blind.mac @@ -422,7 +422,7 @@ class MotionTDBUDevice(MotionPositionDevice): super().__init__(coordinator, blind, device_class, sw_version) self._motor = motor self._motor_key = motor[0] - self._attr_name = f"{blind.blind_type} {blind.mac[12:]} {motor}" + self._attr_name = f"{device_name(blind)} {motor}" self._attr_unique_id = f"{blind.mac}-{motor}" if self._motor not in ["Bottom", "Top", "Combined"]: diff --git a/homeassistant/components/motion_blinds/gateway.py b/homeassistant/components/motion_blinds/gateway.py index 218da9f625c..96a85246666 100644 --- a/homeassistant/components/motion_blinds/gateway.py +++ b/homeassistant/components/motion_blinds/gateway.py @@ -3,7 +3,7 @@ import contextlib import logging import socket -from motionblinds import AsyncMotionMulticast, MotionGateway +from motionblinds import DEVICE_TYPES_WIFI, AsyncMotionMulticast, MotionGateway from homeassistant.components import network @@ -12,6 +12,13 @@ from .const import DEFAULT_INTERFACE _LOGGER = logging.getLogger(__name__) +def device_name(blind): + """Construct common name part of a device.""" + if blind.device_type in DEVICE_TYPES_WIFI: + return blind.blind_type + return f"{blind.blind_type} {blind.mac[12:]}" + + class ConnectMotionGateway: """Class to async connect to a Motion Gateway.""" diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index ebaed95bcbf..3a6f775092e 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -10,6 +10,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_AVAILABLE, DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .gateway import device_name ATTR_BATTERY_VOLTAGE = "battery_voltage" TYPE_BLIND = "blind" @@ -54,14 +55,9 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): """Initialize the Motion Battery Sensor.""" super().__init__(coordinator) - if blind.device_type in DEVICE_TYPES_WIFI: - name = f"{blind.blind_type} battery" - else: - name = f"{blind.blind_type} {blind.mac[12:]} battery" - self._blind = blind self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, blind.mac)}) - self._attr_name = name + self._attr_name = f"{device_name(blind)} battery" self._attr_unique_id = f"{blind.mac}-battery" @property @@ -103,14 +99,9 @@ class MotionTDBUBatterySensor(MotionBatterySensor): """Initialize the Motion Battery Sensor.""" super().__init__(coordinator, blind) - if blind.device_type in DEVICE_TYPES_WIFI: - name = f"{blind.blind_type} {motor} battery" - else: - name = f"{blind.blind_type} {blind.mac[12:]} {motor} battery" - self._motor = motor self._attr_unique_id = f"{blind.mac}-{motor}-battery" - self._attr_name = name + self._attr_name = f"{device_name(blind)} {motor} battery" @property def native_value(self): @@ -144,10 +135,8 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): if device_type == TYPE_GATEWAY: name = "Motion gateway signal strength" - elif device.device_type in DEVICE_TYPES_WIFI: - name = f"{device.blind_type} signal strength" else: - name = f"{device.blind_type} {device.mac[12:]} signal strength" + name = f"{device_name(device)} signal strength" self._device = device self._device_type = device_type diff --git a/tests/components/motion_blinds/test_gateway.py b/tests/components/motion_blinds/test_gateway.py new file mode 100644 index 00000000000..66f42c3c444 --- /dev/null +++ b/tests/components/motion_blinds/test_gateway.py @@ -0,0 +1,19 @@ +"""Test the Motion Blinds config flow.""" +from unittest.mock import Mock + +from motionblinds import DEVICE_TYPES_WIFI, BlindType + +from homeassistant.components.motion_blinds.gateway import device_name + +TEST_BLIND_MAC = "abcdefghujkl0001" + + +async def test_device_name(hass): + """test_device_name.""" + blind = Mock() + blind.blind_type = BlindType.RollerBlind.name + blind.mac = TEST_BLIND_MAC + assert device_name(blind) == "RollerBlind 0001" + + blind.device_type = DEVICE_TYPES_WIFI[0] + assert device_name(blind) == "RollerBlind" From a9d45d656d057ce91d19ab268f7d1296aed367ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Jun 2022 03:49:17 -1000 Subject: [PATCH 210/947] Add to codeowners for logbook so I get notifications (#72964) - Adding explictly will get through my filters and I want to watch this one for at least this release --- CODEOWNERS | 4 ++-- homeassistant/components/logbook/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index db9c0ff69d8..fe8ce9a46ad 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -582,8 +582,8 @@ build.json @home-assistant/supervisor /tests/components/local_ip/ @issacg /homeassistant/components/lock/ @home-assistant/core /tests/components/lock/ @home-assistant/core -/homeassistant/components/logbook/ @home-assistant/core -/tests/components/logbook/ @home-assistant/core +/homeassistant/components/logbook/ @home-assistant/core @bdraco +/tests/components/logbook/ @home-assistant/core @bdraco /homeassistant/components/logger/ @home-assistant/core /tests/components/logger/ @home-assistant/core /homeassistant/components/logi_circle/ @evanjd diff --git a/homeassistant/components/logbook/manifest.json b/homeassistant/components/logbook/manifest.json index 66c0348a2ac..26a45e74439 100644 --- a/homeassistant/components/logbook/manifest.json +++ b/homeassistant/components/logbook/manifest.json @@ -3,6 +3,6 @@ "name": "Logbook", "documentation": "https://www.home-assistant.io/integrations/logbook", "dependencies": ["frontend", "http", "recorder"], - "codeowners": ["@home-assistant/core"], + "codeowners": ["@home-assistant/core", "@bdraco"], "quality_scale": "internal" } From 6da409d6e5cd2a6264d6d7b8c1988d00c47cea4b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 3 Jun 2022 17:12:38 +0200 Subject: [PATCH 211/947] Remove unused constant from auth (#72953) --- homeassistant/components/auth/__init__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 27fe511182d..897ca037c98 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -150,7 +150,6 @@ from homeassistant.util import dt as dt_util from . import indieauth, login_flow, mfa_setup_flow DOMAIN = "auth" -RESULT_TYPE_CREDENTIALS = "credentials" @bind_hass From 18c26148023050f87b82a1644e2f59f2e076145e Mon Sep 17 00:00:00 2001 From: shbatm Date: Fri, 3 Jun 2022 11:53:23 -0500 Subject: [PATCH 212/947] Check ISY994 climate for unknown humidity value on Z-Wave Thermostat (#72990) Check ISY994 climate for unknown humidity on Z-Wave Thermostat Update to #72670 to compare the property value and not the parent object. Should actually fix #72628 --- homeassistant/components/isy994/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/isy994/climate.py b/homeassistant/components/isy994/climate.py index d68395f14da..9f4c52258a7 100644 --- a/homeassistant/components/isy994/climate.py +++ b/homeassistant/components/isy994/climate.py @@ -117,7 +117,7 @@ class ISYThermostatEntity(ISYNodeEntity, ClimateEntity): """Return the current humidity.""" if not (humidity := self._node.aux_properties.get(PROP_HUMIDITY)): return None - if humidity == ISY_VALUE_UNKNOWN: + if humidity.value == ISY_VALUE_UNKNOWN: return None return int(humidity.value) From 5ee2f4f438f8acb119308738639169138b15662c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Jun 2022 21:11:57 +0200 Subject: [PATCH 213/947] Sensibo Set temperature improvement (#72992) --- homeassistant/components/sensibo/climate.py | 25 ++++++------- tests/components/sensibo/fixtures/data.json | 8 ++--- tests/components/sensibo/test_climate.py | 39 +++++++++++++++------ 3 files changed, 43 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 4b0e797a5b7..c7967d05be0 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -1,6 +1,7 @@ """Support for Sensibo wifi-enabled home thermostats.""" from __future__ import annotations +from bisect import bisect_left from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -54,6 +55,14 @@ AC_STATE_TO_DATA = { } +def _find_valid_target_temp(target: int, valid_targets: list[int]) -> int: + if target <= valid_targets[0]: + return valid_targets[0] + if target >= valid_targets[-1]: + return valid_targets[-1] + return valid_targets[bisect_left(valid_targets, target)] + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -203,20 +212,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): if temperature == self.target_temperature: return - if temperature not in self.device_data.temp_list: - # Requested temperature is not supported. - if temperature > self.device_data.temp_list[-1]: - temperature = self.device_data.temp_list[-1] - - elif temperature < self.device_data.temp_list[0]: - temperature = self.device_data.temp_list[0] - - else: - raise ValueError( - f"Target temperature has to be one off {str(self.device_data.temp_list)}" - ) - - await self._async_set_ac_state_property("targetTemperature", int(temperature)) + new_temp = _find_valid_target_temp(temperature, self.device_data.temp_list) + await self._async_set_ac_state_property("targetTemperature", new_temp) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" diff --git a/tests/components/sensibo/fixtures/data.json b/tests/components/sensibo/fixtures/data.json index c787ea5592c..6c44b44821f 100644 --- a/tests/components/sensibo/fixtures/data.json +++ b/tests/components/sensibo/fixtures/data.json @@ -251,7 +251,7 @@ }, "C": { "isNative": true, - "values": [18, 19, 20] + "values": [10, 16, 17, 18, 19, 20] } }, "fanLevels": ["quiet", "low", "medium"], @@ -267,7 +267,7 @@ }, "C": { "isNative": true, - "values": [17, 18, 19, 20] + "values": [10, 16, 17, 18, 19, 20] } }, "fanLevels": ["quiet", "low", "medium"], @@ -283,7 +283,7 @@ }, "C": { "isNative": true, - "values": [18, 19, 20] + "values": [10, 16, 17, 18, 19, 20] } }, "swing": ["stopped", "fixedTop", "fixedMiddleTop"], @@ -298,7 +298,7 @@ }, "C": { "isNative": true, - "values": [18, 19, 20, 21] + "values": [10, 16, 17, 18, 19, 20] } }, "fanLevels": ["quiet", "low", "medium"], diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 79813727c15..ead4fb02d88 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -20,7 +20,10 @@ from homeassistant.components.climate.const import ( SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, ) -from homeassistant.components.sensibo.climate import SERVICE_ASSUME_STATE +from homeassistant.components.sensibo.climate import ( + SERVICE_ASSUME_STATE, + _find_valid_target_temp, +) from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -37,6 +40,21 @@ from homeassistant.util import dt from tests.common import async_fire_time_changed +async def test_climate_find_valid_targets(): + """Test function to return temperature from valid targets.""" + + valid_targets = [10, 16, 17, 18, 19, 20] + + assert _find_valid_target_temp(7, valid_targets) == 10 + assert _find_valid_target_temp(10, valid_targets) == 10 + assert _find_valid_target_temp(11, valid_targets) == 16 + assert _find_valid_target_temp(15, valid_targets) == 16 + assert _find_valid_target_temp(16, valid_targets) == 16 + assert _find_valid_target_temp(18.5, valid_targets) == 19 + assert _find_valid_target_temp(20, valid_targets) == 20 + assert _find_valid_target_temp(25, valid_targets) == 20 + + async def test_climate( hass: HomeAssistant, load_int: ConfigEntry, get_data: SensiboData ) -> None: @@ -55,7 +73,7 @@ async def test_climate( "fan_only", "off", ], - "min_temp": 17, + "min_temp": 10, "max_temp": 20, "target_temp_step": 1, "fan_modes": ["quiet", "low", "medium"], @@ -244,23 +262,22 @@ async def test_climate_temperatures( await hass.async_block_till_done() state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 17 + assert state2.attributes["temperature"] == 16 with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", return_value={"result": {"status": "Success"}}, ): - with pytest.raises(ValueError): - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 18.5}, - blocking=True, - ) + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: state1.entity_id, ATTR_TEMPERATURE: 18.5}, + blocking=True, + ) await hass.async_block_till_done() state2 = hass.states.get("climate.hallway") - assert state2.attributes["temperature"] == 17 + assert state2.attributes["temperature"] == 19 with patch( "homeassistant.components.sensibo.util.SensiboClient.async_set_ac_state_property", From 8d0dd1fe8cdf25956b378d78cac1e4e152c5a413 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 3 Jun 2022 21:24:04 +0200 Subject: [PATCH 214/947] Config flow for scrape integration (#70476) --- CODEOWNERS | 4 +- homeassistant/components/scrape/__init__.py | 63 +++++ .../components/scrape/config_flow.py | 122 ++++++++++ homeassistant/components/scrape/const.py | 13 ++ homeassistant/components/scrape/manifest.json | 3 +- homeassistant/components/scrape/sensor.py | 101 ++++---- homeassistant/components/scrape/strings.json | 73 ++++++ .../components/scrape/translations/en.json | 73 ++++++ homeassistant/generated/config_flows.py | 1 + tests/components/scrape/__init__.py | 40 +++- tests/components/scrape/test_config_flow.py | 194 ++++++++++++++++ tests/components/scrape/test_init.py | 89 +++++++ tests/components/scrape/test_sensor.py | 219 ++++++++++-------- 13 files changed, 853 insertions(+), 142 deletions(-) create mode 100644 homeassistant/components/scrape/config_flow.py create mode 100644 homeassistant/components/scrape/const.py create mode 100644 homeassistant/components/scrape/strings.json create mode 100644 homeassistant/components/scrape/translations/en.json create mode 100644 tests/components/scrape/test_config_flow.py create mode 100644 tests/components/scrape/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index fe8ce9a46ad..e2e9fc27b5c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -887,8 +887,8 @@ build.json @home-assistant/supervisor /homeassistant/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core /homeassistant/components/schluter/ @prairieapps -/homeassistant/components/scrape/ @fabaff -/tests/components/scrape/ @fabaff +/homeassistant/components/scrape/ @fabaff @gjohansson-ST +/tests/components/scrape/ @fabaff @gjohansson-ST /homeassistant/components/screenlogic/ @dieselrabbit @bdraco /tests/components/screenlogic/ @dieselrabbit @bdraco /homeassistant/components/script/ @home-assistant/core diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index f9222c126b5..684be76b80d 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -1 +1,64 @@ """The scrape component.""" +from __future__ import annotations + +import httpx + +from homeassistant.components.rest.data import RestData +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_AUTHENTICATION, + CONF_HEADERS, + CONF_PASSWORD, + CONF_RESOURCE, + CONF_USERNAME, + CONF_VERIFY_SSL, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Scrape from a config entry.""" + + resource: str = entry.options[CONF_RESOURCE] + method: str = "GET" + payload: str | None = None + headers: str | None = entry.options.get(CONF_HEADERS) + verify_ssl: bool = entry.options[CONF_VERIFY_SSL] + username: str | None = entry.options.get(CONF_USERNAME) + password: str | None = entry.options.get(CONF_PASSWORD) + + auth: httpx.DigestAuth | tuple[str, str] | None = None + if username and password: + if entry.options.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + auth = httpx.DigestAuth(username, password) + else: + auth = (username, password) + + rest = RestData(hass, method, resource, auth, headers, None, payload, verify_ssl) + await rest.async_update() + + if rest.data is None: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = rest + + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener for options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Scrape config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/scrape/config_flow.py b/homeassistant/components/scrape/config_flow.py new file mode 100644 index 00000000000..a32e371a487 --- /dev/null +++ b/homeassistant/components/scrape/config_flow.py @@ -0,0 +1,122 @@ +"""Adds config flow for Scrape integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + SensorDeviceClass, + SensorStateClass, +) +from homeassistant.const import ( + CONF_ATTRIBUTE, + CONF_AUTHENTICATION, + CONF_DEVICE_CLASS, + CONF_HEADERS, + CONF_NAME, + CONF_PASSWORD, + CONF_RESOURCE, + CONF_UNIT_OF_MEASUREMENT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, + HTTP_BASIC_AUTHENTICATION, + HTTP_DIGEST_AUTHENTICATION, +) +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaConfigFlowHandler, + SchemaFlowFormStep, + SchemaFlowMenuStep, + SchemaOptionsFlowHandler, +) +from homeassistant.helpers.selector import ( + BooleanSelector, + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + ObjectSelector, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TemplateSelector, + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN + +SCHEMA_SETUP = { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): TextSelector(), + vol.Required(CONF_RESOURCE): TextSelector( + TextSelectorConfig(type=TextSelectorType.URL) + ), + vol.Required(CONF_SELECT): TextSelector(), +} + +SCHEMA_OPT = { + vol.Optional(CONF_ATTRIBUTE): TextSelector(), + vol.Optional(CONF_INDEX, default=0): NumberSelector( + NumberSelectorConfig(min=0, step=1, mode=NumberSelectorMode.BOX) + ), + vol.Optional(CONF_AUTHENTICATION): SelectSelector( + SelectSelectorConfig( + options=[HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_USERNAME): TextSelector(), + vol.Optional(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + vol.Optional(CONF_HEADERS): ObjectSelector(), + vol.Optional(CONF_UNIT_OF_MEASUREMENT): TextSelector(), + vol.Optional(CONF_DEVICE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in SensorDeviceClass], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_STATE_CLASS): SelectSelector( + SelectSelectorConfig( + options=[e.value for e in SensorStateClass], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(), + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): BooleanSelector(), +} + +DATA_SCHEMA = vol.Schema({**SCHEMA_SETUP, **SCHEMA_OPT}) +DATA_SCHEMA_OPT = vol.Schema({**SCHEMA_OPT}) + +CONFIG_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "user": SchemaFlowFormStep(DATA_SCHEMA), + "import": SchemaFlowFormStep(DATA_SCHEMA), +} +OPTIONS_FLOW: dict[str, SchemaFlowFormStep | SchemaFlowMenuStep] = { + "init": SchemaFlowFormStep(DATA_SCHEMA_OPT), +} + + +class ScrapeConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Scrape.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return options[CONF_NAME] + + def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: + """Check for duplicate records.""" + data: dict[str, Any] = dict(options) + self._async_abort_entries_match(data) + + +class ScrapeOptionsFlowHandler(SchemaOptionsFlowHandler): + """Handle a config flow for Scrape.""" diff --git a/homeassistant/components/scrape/const.py b/homeassistant/components/scrape/const.py new file mode 100644 index 00000000000..88eb661d29a --- /dev/null +++ b/homeassistant/components/scrape/const.py @@ -0,0 +1,13 @@ +"""Constants for Scrape integration.""" +from __future__ import annotations + +from homeassistant.const import Platform + +DOMAIN = "scrape" +DEFAULT_NAME = "Web scrape" +DEFAULT_VERIFY_SSL = True + +PLATFORMS = [Platform.SENSOR] + +CONF_SELECT = "select" +CONF_INDEX = "index" diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index b1ccbb354a9..631af2e6051 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -4,6 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/scrape", "requirements": ["beautifulsoup4==4.11.1", "lxml==4.8.0"], "after_dependencies": ["rest"], - "codeowners": ["@fabaff"], + "codeowners": ["@fabaff", "@gjohansson-ST"], + "config_flow": true, "iot_class": "cloud_polling" } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index e15f7c5ba97..4fc08cba571 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -5,7 +5,6 @@ import logging from typing import Any from bs4 import BeautifulSoup -import httpx import voluptuous as vol from homeassistant.components.rest.data import RestData @@ -16,7 +15,9 @@ from homeassistant.components.sensor import ( STATE_CLASSES_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( + CONF_ATTRIBUTE, CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_HEADERS, @@ -31,26 +32,24 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN + _LOGGER = logging.getLogger(__name__) -CONF_ATTR = "attribute" -CONF_SELECT = "select" -CONF_INDEX = "index" - -DEFAULT_NAME = "Web scrape" -DEFAULT_VERIFY_SSL = True +ICON = "mdi:web" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCE): cv.string, vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_ATTR): cv.string, + vol.Optional(CONF_ATTRIBUTE): cv.string, vol.Optional(CONF_INDEX, default=0): cv.positive_int, vol.Optional(CONF_AUTHENTICATION): vol.In( [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] @@ -62,7 +61,7 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_VALUE_TEMPLATE): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, } ) @@ -75,37 +74,47 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Web scrape sensor.""" - name: str = config[CONF_NAME] - resource: str = config[CONF_RESOURCE] - method: str = "GET" - payload: str | None = None - headers: str | None = config.get(CONF_HEADERS) - verify_ssl: bool = config[CONF_VERIFY_SSL] - select: str | None = config.get(CONF_SELECT) - attr: str | None = config.get(CONF_ATTR) - index: int = config[CONF_INDEX] - unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) - device_class: str | None = config.get(CONF_DEVICE_CLASS) - state_class: str | None = config.get(CONF_STATE_CLASS) - username: str | None = config.get(CONF_USERNAME) - password: str | None = config.get(CONF_PASSWORD) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) + _LOGGER.warning( + # Config flow added in Home Assistant Core 2022.7, remove import flow in 2022.9 + "Loading Scrape via platform setup has been deprecated in Home Assistant 2022.7 " + "Your configuration has been automatically imported and you can " + "remove it from your configuration.yaml" + ) + if config.get(CONF_VALUE_TEMPLATE): + template: Template = Template(config[CONF_VALUE_TEMPLATE]) + template.ensure_valid() + config[CONF_VALUE_TEMPLATE] = template.template + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Scrape sensor entry.""" + name: str = entry.options[CONF_NAME] + resource: str = entry.options[CONF_RESOURCE] + select: str | None = entry.options.get(CONF_SELECT) + attr: str | None = entry.options.get(CONF_ATTRIBUTE) + index: int = int(entry.options[CONF_INDEX]) + unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) + device_class: str | None = entry.options.get(CONF_DEVICE_CLASS) + state_class: str | None = entry.options.get(CONF_STATE_CLASS) + value_template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) + entry_id: str = entry.entry_id + + val_template: Template | None = None if value_template is not None: - value_template.hass = hass + val_template = Template(value_template, hass) - auth: httpx.DigestAuth | tuple[str, str] | None = None - if username and password: - if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = httpx.DigestAuth(username, password) - else: - auth = (username, password) - - rest = RestData(hass, method, resource, auth, headers, None, payload, verify_ssl) - await rest.async_update() - - if rest.data is None: - raise PlatformNotReady + rest = hass.data.setdefault(DOMAIN, {})[entry.entry_id] async_add_entities( [ @@ -115,10 +124,12 @@ async def async_setup_platform( select, attr, index, - value_template, + val_template, unit, device_class, state_class, + entry_id, + resource, ) ], True, @@ -128,6 +139,8 @@ async def async_setup_platform( class ScrapeSensor(SensorEntity): """Representation of a web scrape sensor.""" + _attr_icon = ICON + def __init__( self, rest: RestData, @@ -139,6 +152,8 @@ class ScrapeSensor(SensorEntity): unit: str | None, device_class: str | None, state_class: str | None, + entry_id: str, + resource: str, ) -> None: """Initialize a web scrape sensor.""" self.rest = rest @@ -151,6 +166,14 @@ class ScrapeSensor(SensorEntity): self._attr_native_unit_of_measurement = unit self._attr_device_class = device_class self._attr_state_class = state_class + self._attr_unique_id = entry_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Scrape", + name=name, + configuration_url=resource, + ) def _extract_value(self) -> Any: """Parse the html extraction in the executor.""" diff --git a/homeassistant/components/scrape/strings.json b/homeassistant/components/scrape/strings.json new file mode 100644 index 00000000000..f328423f5b6 --- /dev/null +++ b/homeassistant/components/scrape/strings.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "resource": "Resource", + "select": "Select", + "attribute": "Attribute", + "index": "Index", + "value_template": "Value Template", + "unit_of_measurement": "Unit of Measurement", + "device_class": "Device Class", + "state_class": "State Class", + "authentication": "Authentication", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "headers": "Headers" + }, + "data_description": { + "resource": "The URL to the website that contains the value", + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", + "attribute": "Get value of an attribute on the selected tag", + "index": "Defines which of the elements returned by the CSS selector to use", + "value_template": "Defines a template to get the state of the sensor", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "state_class": "The state_class of the sensor", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed", + "headers": "Headers to use for the web request" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "name": "[%key:component::scrape::config::step::user::data::name%]", + "resource": "[%key:component::scrape::config::step::user::data::resource%]", + "select": "[%key:component::scrape::config::step::user::data::select%]", + "attribute": "[%key:component::scrape::config::step::user::data::attribute%]", + "index": "[%key:component::scrape::config::step::user::data::index%]", + "value_template": "[%key:component::scrape::config::step::user::data::value_template%]", + "unit_of_measurement": "[%key:component::scrape::config::step::user::data::unit_of_measurement%]", + "device_class": "[%key:component::scrape::config::step::user::data::device_class%]", + "state_class": "[%key:component::scrape::config::step::user::data::state_class%]", + "authentication": "[%key:component::scrape::config::step::user::data::authentication%]", + "verify_ssl": "[%key:component::scrape::config::step::user::data::verify_ssl%]", + "username": "[%key:component::scrape::config::step::user::data::username%]", + "password": "[%key:component::scrape::config::step::user::data::password%]", + "headers": "[%key:component::scrape::config::step::user::data::headers%]" + }, + "data_description": { + "resource": "[%key:component::scrape::config::step::user::data_description::resource%]", + "select": "[%key:component::scrape::config::step::user::data_description::select%]", + "attribute": "[%key:component::scrape::config::step::user::data_description::attribute%]", + "index": "[%key:component::scrape::config::step::user::data_description::index%]", + "value_template": "[%key:component::scrape::config::step::user::data_description::value_template%]", + "device_class": "[%key:component::scrape::config::step::user::data_description::device_class%]", + "state_class": "[%key:component::scrape::config::step::user::data_description::state_class%]", + "authentication": "[%key:component::scrape::config::step::user::data_description::authentication%]", + "verify_ssl": "[%key:component::scrape::config::step::user::data_description::verify_ssl%]", + "headers": "[%key:component::scrape::config::step::user::data_description::headers%]" + } + } + } + } +} diff --git a/homeassistant/components/scrape/translations/en.json b/homeassistant/components/scrape/translations/en.json new file mode 100644 index 00000000000..20831f5251a --- /dev/null +++ b/homeassistant/components/scrape/translations/en.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "step": { + "user": { + "data": { + "attribute": "Attribute", + "authentication": "Authentication", + "device_class": "Device Class", + "headers": "Headers", + "index": "Index", + "name": "Name", + "password": "Password", + "resource": "Resource", + "select": "Select", + "state_class": "State Class", + "unit_of_measurement": "Unit of Measurement", + "username": "Username", + "value_template": "Value Template", + "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "attribute": "Get value of an attribute on the selected tag", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "headers": "Headers to use for the web request", + "index": "Defines which of the elements returned by the CSS selector to use", + "resource": "The URL to the website that contains the value", + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", + "state_class": "The state_class of the sensor", + "value_template": "Defines a template to get the state of the sensor", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribute", + "authentication": "Authentication", + "device_class": "Device Class", + "headers": "Headers", + "index": "Index", + "name": "Name", + "password": "Password", + "resource": "Resource", + "select": "Select", + "state_class": "State Class", + "unit_of_measurement": "Unit of Measurement", + "username": "Username", + "value_template": "Value Template", + "verify_ssl": "Verify SSL certificate" + }, + "data_description": { + "attribute": "Get value of an attribute on the selected tag", + "authentication": "Type of the HTTP authentication. Either basic or digest", + "device_class": "The type/class of the sensor to set the icon in the frontend", + "headers": "Headers to use for the web request", + "index": "Defines which of the elements returned by the CSS selector to use", + "resource": "The URL to the website that contains the value", + "select": "Defines what tag to search for. Check Beautifulsoup CSS selectors for details", + "state_class": "The state_class of the sensor", + "value_template": "Defines a template to get the state of the sensor", + "verify_ssl": "Enables/disables verification of SSL/TLS certificate, for example if it is self-signed" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e9ba5971e07..e1e2938c9ff 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -298,6 +298,7 @@ FLOWS = { "ruckus_unleashed", "sabnzbd", "samsungtv", + "scrape", "screenlogic", "season", "sense", diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 0ba9266a79d..37abb061e75 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -2,6 +2,42 @@ from __future__ import annotations from typing import Any +from unittest.mock import patch + +from homeassistant.components.scrape.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def init_integration( + hass: HomeAssistant, + config: dict[str, Any], + data: str, + entry_id: str = "1", + source: str = SOURCE_USER, +) -> MockConfigEntry: + """Set up the Scrape integration in Home Assistant.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + source=source, + data={}, + options=config, + entry_id=entry_id, + ) + + config_entry.add_to_hass(hass) + mocker = MockRestData(data) + with patch( + "homeassistant.components.scrape.RestData", + return_value=mocker, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry def return_config( @@ -25,6 +61,8 @@ def return_config( "resource": "https://www.home-assistant.io", "select": select, "name": name, + "index": 0, + "verify_ssl": True, } if attribute: config["attribute"] = attribute @@ -38,7 +76,7 @@ def return_config( config["device_class"] = device_class if state_class: config["state_class"] = state_class - if authentication: + if username: config["authentication"] = authentication config["username"] = username config["password"] = password diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py new file mode 100644 index 00000000000..287004b1dd3 --- /dev/null +++ b/tests/components/scrape/test_config_flow.py @@ -0,0 +1,194 @@ +"""Test the Scrape config flow.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN +from homeassistant.const import ( + CONF_NAME, + CONF_RESOURCE, + CONF_VALUE_TEMPLATE, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import MockRestData + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=MockRestData("test_scrape_sensor"), + ), patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_NAME: "Release", + CONF_SELECT: ".current-version h1", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Release" + assert result2["options"] == { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0.0, + "verify_ssl": True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=MockRestData("test_scrape_sensor"), + ), patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_NAME: "Release", + CONF_SELECT: ".current-version h1", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Release" + assert result2["options"] == { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, + }, + ).add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=MockRestData("test_scrape_sensor"), + ), patch( + "homeassistant.components.scrape.async_setup_entry", + return_value=True, + ): + result3 = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_RESOURCE: "https://www.home-assistant.io", + CONF_NAME: "Release", + CONF_SELECT: ".current-version h1", + CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", + CONF_INDEX: 0, + CONF_VERIFY_SSL: True, + }, + ) + await hass.async_block_till_done() + + assert result3["type"] == RESULT_TYPE_ABORT + assert result3["reason"] == "already_configured" + + +async def test_options_form(hass: HomeAssistant) -> None: + """Test we get the form in options.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, + }, + entry_id="1", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(entry.entry_id) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "value_template": "{{ value.split(':')[1] }}", + "index": 1.0, + "verify_ssl": True, + }, + ) + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["data"] == { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 1.0, + "verify_ssl": True, + } + entry_check = hass.config_entries.async_get_entry("1") + assert entry_check.state == config_entries.ConfigEntryState.LOADED + assert entry_check.update_listeners is not None diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py new file mode 100644 index 00000000000..021790e65c3 --- /dev/null +++ b/tests/components/scrape/test_init.py @@ -0,0 +1,89 @@ +"""Test Scrape component setup process.""" +from __future__ import annotations + +from unittest.mock import patch + +from homeassistant.components.scrape.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import MockRestData + +from tests.common import MockConfigEntry + +TEST_CONFIG = { + "resource": "https://www.home-assistant.io", + "name": "Release", + "select": ".current-version h1", + "value_template": "{{ value.split(':')[1] }}", + "index": 0, + "verify_ssl": True, +} + + +async def test_setup_entry(hass: HomeAssistant) -> None: + """Test setup entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options=TEST_CONFIG, + title="Release", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.release") + assert state + + +async def test_setup_entry_no_data_fails(hass: HomeAssistant) -> None: + """Test setup entry no data fails.""" + entry = MockConfigEntry( + domain=DOMAIN, data={}, options=TEST_CONFIG, title="Release", entry_id="1" + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor_no_data"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.ha_version") + assert state is None + entry = hass.config_entries.async_get_entry("1") + assert entry.state == ConfigEntryState.SETUP_RETRY + + +async def test_remove_entry(hass: HomeAssistant) -> None: + """Test remove entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options=TEST_CONFIG, + title="Release", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.scrape.RestData", + return_value=MockRestData("test_scrape_sensor"), + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.release") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.release") + assert not state diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index aaf156208ef..cd4e27e88a2 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -3,10 +3,15 @@ from __future__ import annotations from unittest.mock import patch +import pytest + from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor.const import CONF_STATE_CLASS +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, + CONF_NAME, + CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS, @@ -15,22 +20,20 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -from . import MockRestData, return_config +from . import MockRestData, init_integration, return_config + +from tests.common import MockConfigEntry DOMAIN = "scrape" async def test_scrape_sensor(hass: HomeAssistant) -> None: """Test Scrape sensor minimal.""" - config = {"sensor": return_config(select=".current-version h1", name="HA version")} - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select=".current-version h1", name="HA version"), + "test_scrape_sensor", + ) state = hass.states.get("sensor.ha_version") assert state.state == "Current Version: 2021.12.10" @@ -38,21 +41,15 @@ async def test_scrape_sensor(hass: HomeAssistant) -> None: async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: """Test Scrape sensor with value template.""" - config = { - "sensor": return_config( + await init_integration( + hass, + return_config( select=".current-version h1", name="HA version", template="{{ value.split(':')[1] }}", - ) - } - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + ), + "test_scrape_sensor", + ) state = hass.states.get("sensor.ha_version") assert state.state == "2021.12.10" @@ -60,24 +57,18 @@ async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: """Test Scrape sensor for unit of measurement, device class and state class.""" - config = { - "sensor": return_config( + await init_integration( + hass, + return_config( select=".current-temp h3", name="Current Temp", template="{{ value.split(':')[1] }}", uom="°C", device_class="temperature", state_class="measurement", - ) - } - - mocker = MockRestData("test_scrape_uom_and_classes") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + ), + "test_scrape_uom_and_classes", + ) state = hass.states.get("sensor.current_temp") assert state.state == "22.1" @@ -88,31 +79,28 @@ async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: """Test Scrape sensor with authentication.""" - config = { - "sensor": [ - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - return_config( - select=".return", - name="Auth page2", - username="user@secret.com", - password="12345678", - ), - ] - } - - mocker = MockRestData("test_scrape_sensor_authentication") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config( + select=".return", + name="Auth page", + username="user@secret.com", + password="12345678", + authentication="digest", + ), + "test_scrape_sensor_authentication", + ) + await init_integration( + hass, + return_config( + select=".return", + name="Auth page2", + username="user@secret.com", + password="12345678", + ), + "test_scrape_sensor_authentication", + entry_id="2", + ) state = hass.states.get("sensor.auth_page") assert state.state == "secret text" @@ -122,15 +110,11 @@ async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: async def test_scrape_sensor_no_data(hass: HomeAssistant) -> None: """Test Scrape sensor fails on no data.""" - config = {"sensor": return_config(select=".current-version h1", name="HA version")} - - mocker = MockRestData("test_scrape_sensor_no_data") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select=".current-version h1", name="HA version"), + "test_scrape_sensor_no_data", + ) state = hass.states.get("sensor.ha_version") assert state is None @@ -138,14 +122,21 @@ async def test_scrape_sensor_no_data(hass: HomeAssistant) -> None: async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: """Test Scrape sensor no data on refresh.""" - config = {"sensor": return_config(select=".current-version h1", name="HA version")} + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options=return_config(select=".current-version h1", name="HA version"), + entry_id="1", + ) + config_entry.add_to_hass(hass) mocker = MockRestData("test_scrape_sensor") with patch( - "homeassistant.components.scrape.sensor.RestData", + "homeassistant.components.scrape.RestData", return_value=mocker, ): - assert await async_setup_component(hass, "sensor", config) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") @@ -162,20 +153,17 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: """Test Scrape sensor with attribute and tag.""" - config = { - "sensor": [ - return_config(select="div", name="HA class", index=1, attribute="class"), - return_config(select="template", name="HA template"), - ] - } - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select="div", name="HA class", index=1, attribute="class"), + "test_scrape_sensor", + ) + await init_integration( + hass, + return_config(select="template", name="HA template"), + "test_scrape_sensor", + entry_id="2", + ) state = hass.states.get("sensor.ha_class") assert state.state == "['links']" @@ -185,22 +173,55 @@ async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: async def test_scrape_sensor_errors(hass: HomeAssistant) -> None: """Test Scrape sensor handle errors.""" - config = { - "sensor": [ - return_config(select="div", name="HA class", index=5, attribute="class"), - return_config(select="div", name="HA class2", attribute="classes"), - ] - } - - mocker = MockRestData("test_scrape_sensor") - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=mocker, - ): - assert await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await init_integration( + hass, + return_config(select="div", name="HA class", index=5, attribute="class"), + "test_scrape_sensor", + ) + await init_integration( + hass, + return_config(select="div", name="HA class2", attribute="classes"), + "test_scrape_sensor", + entry_id="2", + ) state = hass.states.get("sensor.ha_class") assert state.state == STATE_UNKNOWN state2 = hass.states.get("sensor.ha_class2") assert state2.state == STATE_UNKNOWN + + +async def test_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: + """Test the Scrape sensor import.""" + config = { + "sensor": { + "platform": "scrape", + "resource": "https://www.home-assistant.io", + "select": ".current-version h1", + "name": "HA Version", + "index": 0, + "verify_ssl": True, + "value_template": "{{ value.split(':')[1] }}", + } + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.scrape.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + assert ( + "Loading Scrape via platform setup has been deprecated in Home Assistant" + in caplog.text + ) + + assert hass.config_entries.async_entries(DOMAIN) + options = hass.config_entries.async_entries(DOMAIN)[0].options + assert options[CONF_NAME] == "HA Version" + assert options[CONF_RESOURCE] == "https://www.home-assistant.io" + + state = hass.states.get("sensor.ha_version") + assert state.state == "2021.12.10" From 91df2db9e012d81b382623f171caddde24dd73f0 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Fri, 3 Jun 2022 21:59:10 +0200 Subject: [PATCH 215/947] Bump bimmer_connected to 0.9.4 (#72973) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 75ac3e982e8..cd6daa83705 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.3"], + "requirements": ["bimmer_connected==0.9.4"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index bb8f3f1cf9b..7d736fb0d16 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -394,7 +394,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.3 +bimmer_connected==0.9.4 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a750e708354..2b4454dd459 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.3 +bimmer_connected==0.9.4 # homeassistant.components.blebox blebox_uniapi==1.3.3 From 8e8fa0399e1d4b799c6978d381a2e187624deff4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 3 Jun 2022 10:04:46 -1000 Subject: [PATCH 216/947] Fix statistics_during_period being incorrectly cached (#72947) --- .../components/recorder/statistics.py | 7 +- tests/components/history/test_init.py | 136 ++++++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 4bed39fee4a..39fcb954ee9 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -984,7 +984,6 @@ def _reduce_statistics_per_month( def _statistics_during_period_stmt( start_time: datetime, end_time: datetime | None, - statistic_ids: list[str] | None, metadata_ids: list[int] | None, table: type[Statistics | StatisticsShortTerm], ) -> StatementLambdaElement: @@ -1002,7 +1001,7 @@ def _statistics_during_period_stmt( if end_time is not None: stmt += lambda q: q.filter(table.start < end_time) - if statistic_ids is not None: + if metadata_ids: stmt += lambda q: q.filter(table.metadata_id.in_(metadata_ids)) stmt += lambda q: q.order_by(table.metadata_id, table.start) @@ -1038,9 +1037,7 @@ def statistics_during_period( else: table = Statistics - stmt = _statistics_during_period_stmt( - start_time, end_time, statistic_ids, metadata_ids, table - ) + stmt = _statistics_during_period_stmt(start_time, end_time, metadata_ids, table) stats = execute_stmt_lambda_element(session, stmt) if not stats: diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 9dc7af59a38..8c0a80719a8 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from pytest import approx @@ -928,6 +929,141 @@ async def test_statistics_during_period( } +@pytest.mark.parametrize( + "units, attributes, state, value", + [ + (IMPERIAL_SYSTEM, POWER_SENSOR_ATTRIBUTES, 10, 10000), + (METRIC_SYSTEM, POWER_SENSOR_ATTRIBUTES, 10, 10000), + (IMPERIAL_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, 10, 50), + (METRIC_SYSTEM, TEMPERATURE_SENSOR_ATTRIBUTES, 10, 10), + (IMPERIAL_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, 1000, 14.503774389728312), + (METRIC_SYSTEM, PRESSURE_SENSOR_ATTRIBUTES, 1000, 100000), + ], +) +async def test_statistics_during_period_in_the_past( + hass, hass_ws_client, recorder_mock, units, attributes, state, value +): + """Test statistics_during_period in the past.""" + hass.config.set_time_zone("UTC") + now = dt_util.utcnow().replace() + + hass.config.units = units + await async_setup_component(hass, "history", {}) + await async_setup_component(hass, "sensor", {}) + await async_recorder_block_till_done(hass) + + past = now - timedelta(days=3) + + with freeze_time(past): + hass.states.async_set("sensor.test", state, attributes=attributes) + await async_wait_recording_done(hass) + + sensor_state = hass.states.get("sensor.test") + assert sensor_state.last_updated == past + + stats_top_of_hour = past.replace(minute=0, second=0, microsecond=0) + stats_start = past.replace(minute=55) + do_adhoc_statistics(hass, start=stats_start) + await async_wait_recording_done(hass) + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "end_time": now.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "hour", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + await client.send_json( + { + "id": 2, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + past = now - timedelta(days=3) + await client.send_json( + { + "id": 3, + "type": "history/statistics_during_period", + "start_time": past.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "sensor.test": [ + { + "statistic_id": "sensor.test", + "start": stats_start.isoformat(), + "end": (stats_start + timedelta(minutes=5)).isoformat(), + "mean": approx(value), + "min": approx(value), + "max": approx(value), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + start_of_day = stats_top_of_hour.replace(hour=0, minute=0) + await client.send_json( + { + "id": 4, + "type": "history/statistics_during_period", + "start_time": stats_top_of_hour.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "day", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == { + "sensor.test": [ + { + "statistic_id": "sensor.test", + "start": start_of_day.isoformat(), + "end": (start_of_day + timedelta(days=1)).isoformat(), + "mean": approx(value), + "min": approx(value), + "max": approx(value), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + + await client.send_json( + { + "id": 5, + "type": "history/statistics_during_period", + "start_time": now.isoformat(), + "statistic_ids": ["sensor.test"], + "period": "5minute", + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {} + + async def test_statistics_during_period_bad_start_time( hass, hass_ws_client, recorder_mock ): From cc807b4d597daaaadc92df4a93c6e30da4f570c6 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 3 Jun 2022 22:05:37 +0200 Subject: [PATCH 217/947] fjaraskupan: Don't filter anything in backend (#72988) --- homeassistant/components/fjaraskupan/__init__.py | 4 ++-- homeassistant/components/fjaraskupan/config_flow.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/fjaraskupan/__init__.py b/homeassistant/components/fjaraskupan/__init__.py index ec4528bc079..4c4f19403a6 100644 --- a/homeassistant/components/fjaraskupan/__init__.py +++ b/homeassistant/components/fjaraskupan/__init__.py @@ -9,7 +9,7 @@ import logging from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from fjaraskupan import DEVICE_NAME, Device, State, device_filter +from fjaraskupan import Device, State, device_filter from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -90,7 +90,7 @@ class EntryState: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Fjäråskupan from a config entry.""" - scanner = BleakScanner(filters={"Pattern": DEVICE_NAME, "DuplicateData": True}) + scanner = BleakScanner(filters={"DuplicateData": True}) state = EntryState(scanner, {}) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/fjaraskupan/config_flow.py b/homeassistant/components/fjaraskupan/config_flow.py index 3af34c0eef6..ffac366500b 100644 --- a/homeassistant/components/fjaraskupan/config_flow.py +++ b/homeassistant/components/fjaraskupan/config_flow.py @@ -7,7 +7,7 @@ import async_timeout from bleak import BleakScanner from bleak.backends.device import BLEDevice from bleak.backends.scanner import AdvertisementData -from fjaraskupan import DEVICE_NAME, device_filter +from fjaraskupan import device_filter from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_flow import register_discovery_flow @@ -28,7 +28,7 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: async with BleakScanner( detection_callback=detection, - filters={"Pattern": DEVICE_NAME, "DuplicateData": True}, + filters={"DuplicateData": True}, ): try: async with async_timeout.timeout(CONST_WAIT_TIME): From 14030991cfe8e3aef96aaaa7cf9200ea92fb0b63 Mon Sep 17 00:00:00 2001 From: iAutom8 <20862130+iAutom8@users.noreply.github.com> Date: Fri, 3 Jun 2022 14:57:01 -0700 Subject: [PATCH 218/947] Add ViCare additional temperature sensors (#72792) --- homeassistant/components/vicare/sensor.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 60a39b454a2..06ed618ec86 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -77,6 +77,22 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), + ViCareSensorEntityDescription( + key="boiler_supply_temperature", + name="Boiler Supply Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getBoilerCommonSupplyTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + ViCareSensorEntityDescription( + key="hotwater_out_temperature", + name="Hot Water Out Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + value_getter=lambda api: api.getDomesticHotWaterOutletTemperature(), + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), ViCareSensorEntityDescription( key="hotwater_gas_consumption_today", name="Hot water gas consumption today", From 04b2223f06d9ec0e7b2930d54ec2b02f15de782c Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 3 Jun 2022 17:03:21 -0500 Subject: [PATCH 219/947] Provide Sonos media position if duration not available (#73001) --- homeassistant/components/sonos/media.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/media.py b/homeassistant/components/sonos/media.py index e3d8f043d4b..9608356ba64 100644 --- a/homeassistant/components/sonos/media.py +++ b/homeassistant/components/sonos/media.py @@ -205,13 +205,15 @@ class SonosMedia: self, position_info: dict[str, int], force_update: bool = False ) -> None: """Update state when playing music tracks.""" - if (duration := position_info.get(DURATION_SECONDS)) == 0: + duration = position_info.get(DURATION_SECONDS) + current_position = position_info.get(POSITION_SECONDS) + + if not (duration or current_position): self.clear_position() return should_update = force_update self.duration = duration - current_position = position_info.get(POSITION_SECONDS) # player started reporting position? if current_position is not None and self.position is None: From bdc41bf22a368174380f07b0c82fd6ae8c690463 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 3 Jun 2022 16:33:12 -0700 Subject: [PATCH 220/947] Fix google calendar bug where expired tokens are not refreshed (#72994) --- homeassistant/components/google/api.py | 7 +++++-- tests/components/google/conftest.py | 6 ++++-- tests/components/google/test_config_flow.py | 14 ++++++++------ 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 4bb9de5d581..a4cda1ff41a 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -5,7 +5,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable import datetime import logging -import time from typing import Any, cast import aiohttp @@ -50,12 +49,16 @@ class DeviceAuth(AuthImplementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" creds: Credentials = external_data[DEVICE_AUTH_CREDS] + delta = creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt.utcnow() + _LOGGER.debug( + "Token expires at %s (in %s)", creds.token_expiry, delta.total_seconds() + ) return { "access_token": creds.access_token, "refresh_token": creds.refresh_token, "scope": " ".join(creds.scopes), "token_type": "Bearer", - "expires_in": creds.token_expiry.timestamp() - time.time(), + "expires_in": delta.total_seconds(), } diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index b5566450913..68176493445 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -16,7 +16,6 @@ from homeassistant.components.google import CONF_TRACK_NEW, DOMAIN from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util.dt import utcnow from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -136,7 +135,10 @@ def token_scopes() -> list[str]: @pytest.fixture def token_expiry() -> datetime.datetime: """Expiration time for credentials used in the test.""" - return utcnow() + datetime.timedelta(days=7) + # OAuth library returns an offset-naive timestamp + return datetime.datetime.fromtimestamp( + datetime.datetime.utcnow().timestamp() + ) + datetime.timedelta(hours=1) @pytest.fixture diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index 8ac017fcba4..a346b02e6c2 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError +from freezegun.api import FrozenDateTimeFactory from oauth2client.client import ( FlowExchangeError, OAuth2Credentials, @@ -94,11 +95,13 @@ async def fire_alarm(hass, point_in_time): await hass.async_block_till_done() +@pytest.mark.freeze_time("2022-06-03 15:19:59-00:00") async def test_full_flow_yaml_creds( hass: HomeAssistant, mock_code_flow: Mock, mock_exchange: Mock, component_setup: ComponentSetup, + freezer: FrozenDateTimeFactory, ) -> None: """Test successful creds setup.""" assert await component_setup() @@ -115,8 +118,8 @@ async def test_full_flow_yaml_creds( "homeassistant.components.google.async_setup_entry", return_value=True ) as mock_setup: # Run one tick to invoke the credential exchange check - now = utcnow() - await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + freezer.tick(CODE_CHECK_ALARM_TIMEDELTA) + await fire_alarm(hass, datetime.datetime.utcnow()) await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"] @@ -127,12 +130,11 @@ async def test_full_flow_yaml_creds( assert "data" in result data = result["data"] assert "token" in data - assert 0 < data["token"]["expires_in"] < 8 * 86400 assert ( - datetime.datetime.now().timestamp() - <= data["token"]["expires_at"] - < (datetime.datetime.now() + datetime.timedelta(days=8)).timestamp() + data["token"]["expires_in"] + == 60 * 60 - CODE_CHECK_ALARM_TIMEDELTA.total_seconds() ) + assert data["token"]["expires_at"] == 1654273199.0 data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { From 636f650563a81b23409fa17c12e938ed592bd6d6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 4 Jun 2022 00:23:28 +0000 Subject: [PATCH 221/947] [ci skip] Translation update --- .../components/deconz/translations/ca.json | 2 +- .../hvv_departures/translations/ca.json | 2 +- .../lovelace/translations/pt-BR.json | 2 +- .../components/scrape/translations/ca.json | 73 +++++++++++++++++++ .../components/scrape/translations/id.json | 23 ++++++ .../components/scrape/translations/pt-BR.json | 73 +++++++++++++++++++ .../tankerkoenig/translations/hu.json | 8 +- 7 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/scrape/translations/ca.json create mode 100644 homeassistant/components/scrape/translations/id.json create mode 100644 homeassistant/components/scrape/translations/pt-BR.json diff --git a/homeassistant/components/deconz/translations/ca.json b/homeassistant/components/deconz/translations/ca.json index f4659557503..c9f599429b4 100644 --- a/homeassistant/components/deconz/translations/ca.json +++ b/homeassistant/components/deconz/translations/ca.json @@ -57,7 +57,7 @@ "side_3": "cara 3", "side_4": "cara 4", "side_5": "cara 5", - "side_6": "cara 6", + "side_6": "Cara 6", "top_buttons": "Botons superiors", "turn_off": "Desactiva", "turn_on": "Activa" diff --git a/homeassistant/components/hvv_departures/translations/ca.json b/homeassistant/components/hvv_departures/translations/ca.json index fad60206c1c..05da353f879 100644 --- a/homeassistant/components/hvv_departures/translations/ca.json +++ b/homeassistant/components/hvv_departures/translations/ca.json @@ -36,7 +36,7 @@ "init": { "data": { "filter": "Selecciona l\u00ednies", - "offset": "\u00d2fset (minuts)", + "offset": "Desfasament (minuts)", "real_time": "Utilitza dades en temps real" }, "description": "Canvia les opcions d'aquest sensor de sortides", diff --git a/homeassistant/components/lovelace/translations/pt-BR.json b/homeassistant/components/lovelace/translations/pt-BR.json index 2ff25d17161..dd8cc7cc32d 100644 --- a/homeassistant/components/lovelace/translations/pt-BR.json +++ b/homeassistant/components/lovelace/translations/pt-BR.json @@ -1,7 +1,7 @@ { "system_health": { "info": { - "dashboards": "Pain\u00e9is", + "dashboards": "Dashboards", "mode": "Modo", "resources": "Recursos", "views": "Visualiza\u00e7\u00f5es" diff --git a/homeassistant/components/scrape/translations/ca.json b/homeassistant/components/scrape/translations/ca.json new file mode 100644 index 00000000000..ff6a0dba168 --- /dev/null +++ b/homeassistant/components/scrape/translations/ca.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat" + }, + "step": { + "user": { + "data": { + "attribute": "Atribut", + "authentication": "Autenticaci\u00f3", + "device_class": "Classe de dispositiu", + "headers": "Cap\u00e7aleres", + "index": "\u00cdndex", + "name": "Nom", + "password": "Contrasenya", + "resource": "Recurs", + "select": "Selecciona", + "state_class": "Classe d'estat", + "unit_of_measurement": "Unitat de mesura", + "username": "Nom d'usuari", + "value_template": "Plantilla de valor", + "verify_ssl": "Verifica el certificat SSL" + }, + "data_description": { + "attribute": "Obt\u00e9 el valor d'un atribut de l'etiqueta seleccionada", + "authentication": "Tipus d'autenticaci\u00f3 HTTP. O b\u00e0sica o 'digest'", + "device_class": "Tipus/classe del sensor per configurar-ne la icona a la interf\u00edcie", + "headers": "Cap\u00e7aleres a utilitzar per a la sol\u00b7licitud web", + "index": "Defineix quins dels elements retornats pel selector CSS utilitzar", + "resource": "URL del lloc web que cont\u00e9 el valor", + "select": "Defineix quina etiqueta s'ha de buscar. Consulta els selectors CSS de Beautifulsoup per m\u00e9s informaci\u00f3", + "state_class": "La state_class del sensor", + "value_template": "Defineix una plantilla per obtenir l'estat del sensor", + "verify_ssl": "Activa/desactiva la verificaci\u00f3 del certificat SSL/TLS, per exemple, si est\u00e0 autosignat" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atribut", + "authentication": "Autenticaci\u00f3", + "device_class": "Classe de dispositiu", + "headers": "Cap\u00e7aleres", + "index": "\u00cdndex", + "name": "Nom", + "password": "Contrasenya", + "resource": "Recurs", + "select": "Selecciona", + "state_class": "Classe d'estat", + "unit_of_measurement": "Unitat de mesura", + "username": "Nom d'usuari", + "value_template": "Plantilla de valor", + "verify_ssl": "Verifica el certificat SSL" + }, + "data_description": { + "attribute": "Obt\u00e9 el valor d'un atribut de l'etiqueta seleccionada", + "authentication": "Tipus d'autenticaci\u00f3 HTTP. O b\u00e0sica o 'digest'", + "device_class": "Tipus/classe del sensor per configurar-ne la icona a la interf\u00edcie", + "headers": "Cap\u00e7aleres a utilitzar per a la sol\u00b7licitud web", + "index": "Defineix quins dels elements retornats pel selector CSS utilitzar", + "resource": "URL del lloc web que cont\u00e9 el valor", + "select": "Defineix quina etiqueta s'ha de buscar. Consulta els selectors CSS de Beautifulsoup per m\u00e9s informaci\u00f3", + "state_class": "La state_class del sensor", + "value_template": "Defineix una plantilla per obtenir l'estat del sensor", + "verify_ssl": "Activa/desactiva la verificaci\u00f3 del certificat SSL/TLS, per exemple, si est\u00e0 autosignat" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/id.json b/homeassistant/components/scrape/translations/id.json new file mode 100644 index 00000000000..d83b07ac5c3 --- /dev/null +++ b/homeassistant/components/scrape/translations/id.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi" + }, + "step": { + "user": { + "data": { + "resource": "Sumber daya" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "resource": "Sumber daya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/pt-BR.json b/homeassistant/components/scrape/translations/pt-BR.json new file mode 100644 index 00000000000..24bbfe1fece --- /dev/null +++ b/homeassistant/components/scrape/translations/pt-BR.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "A conta j\u00e1 est\u00e1 configurada" + }, + "step": { + "user": { + "data": { + "attribute": "Atributo", + "authentication": "Autentica\u00e7\u00e3o", + "device_class": "Classe do dispositivo", + "headers": "Cabe\u00e7alhos", + "index": "\u00cdndice", + "name": "Nome", + "password": "Senha", + "resource": "Recurso", + "select": "Selecionar", + "state_class": "Classe de estado", + "unit_of_measurement": "Unidade de medida", + "username": "Nome de usu\u00e1rio", + "value_template": "Modelo de valor", + "verify_ssl": "Verificar certificado SSL" + }, + "data_description": { + "attribute": "Obter valor de um atributo na tag selecionada", + "authentication": "Tipo de autentica\u00e7\u00e3o HTTP. b\u00e1sica ou digerida", + "device_class": "O tipo/classe do sensor para definir o \u00edcone na frontend", + "headers": "Cabe\u00e7alhos a serem usados para a solicita\u00e7\u00e3o da web", + "index": "Define qual dos elementos retornados pelo seletor CSS usar", + "resource": "A URL para o site que cont\u00e9m o valor", + "select": "Define qual tag pesquisar. Verifique os seletores CSS da Beautiful Soup para obter detalhes", + "state_class": "O classe de estado do sensor", + "value_template": "Define um modelo para obter o estado do sensor", + "verify_ssl": "Ativa/desativa a verifica\u00e7\u00e3o do certificado SSL/TLS, por exemplo, se for autoassinado" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atributo", + "authentication": "Autentica\u00e7\u00e3o", + "device_class": "Classe do dispositivo", + "headers": "Cabe\u00e7alhos", + "index": "\u00cdndice", + "name": "Nome", + "password": "Senha", + "resource": "Recurso", + "select": "Selecionar", + "state_class": "Classe de estado", + "unit_of_measurement": "Unidade de medida", + "username": "Nome de usu\u00e1rio", + "value_template": "Modelo de valor", + "verify_ssl": "Verificar SSL" + }, + "data_description": { + "attribute": "Obter valor de um atributo na tag selecionada", + "authentication": "Tipo de autentica\u00e7\u00e3o HTTP. Ou b\u00e1sico ou digerido", + "device_class": "O tipo/classe do sensor para definir o \u00edcone no frontend", + "headers": "Cabe\u00e7alhos a serem usados para a solicita\u00e7\u00e3o da web", + "index": "Define qual dos elementos retornados pelo seletor CSS usar", + "resource": "A URL para o site que cont\u00e9m o valor", + "select": "Define qual tag pesquisar. Verifique os seletores CSS do Beautifulsoup para obter detalhes", + "state_class": "O classe de estado do sensor", + "value_template": "Define um modelo para obter o estado do sensor", + "verify_ssl": "Ativa/desativa a verifica\u00e7\u00e3o do certificado SSL/TLS, por exemplo, se for autoassinado" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tankerkoenig/translations/hu.json b/homeassistant/components/tankerkoenig/translations/hu.json index 369f336f96f..502ccd6fd9c 100644 --- a/homeassistant/components/tankerkoenig/translations/hu.json +++ b/homeassistant/components/tankerkoenig/translations/hu.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "A hely m\u00e1r konfigur\u00e1lva van" + "already_configured": "A hely m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", "no_stations": "Nem tal\u00e1lhat\u00f3 \u00e1llom\u00e1s a hat\u00f3t\u00e1vols\u00e1gon bel\u00fcl." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API kulcs" + } + }, "select_station": { "data": { "stations": "\u00c1llom\u00e1sok" From b5fe4e8474ca93cbae0de83752c8f421898ca80a Mon Sep 17 00:00:00 2001 From: Clifford Roche Date: Sat, 4 Jun 2022 00:56:37 -0400 Subject: [PATCH 222/947] Bump greeclimate to 1.2.0 (#73008) --- homeassistant/components/gree/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gree/manifest.json b/homeassistant/components/gree/manifest.json index 23a5c654abc..1b2c8dd6a2a 100644 --- a/homeassistant/components/gree/manifest.json +++ b/homeassistant/components/gree/manifest.json @@ -3,7 +3,7 @@ "name": "Gree Climate", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/gree", - "requirements": ["greeclimate==1.1.1"], + "requirements": ["greeclimate==1.2.0"], "codeowners": ["@cmroche"], "iot_class": "local_polling", "loggers": ["greeclimate"] diff --git a/requirements_all.txt b/requirements_all.txt index 7d736fb0d16..bad7eac7e6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -756,7 +756,7 @@ gpiozero==1.6.2 gps3==0.33.3 # homeassistant.components.gree -greeclimate==1.1.1 +greeclimate==1.2.0 # homeassistant.components.greeneye_monitor greeneye_monitor==3.0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b4454dd459..54028fac054 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -541,7 +541,7 @@ google-nest-sdm==2.0.0 googlemaps==2.5.1 # homeassistant.components.gree -greeclimate==1.1.1 +greeclimate==1.2.0 # homeassistant.components.greeneye_monitor greeneye_monitor==3.0.3 From 9d933e732b84a8cef7c54dddb9cecbc395ebae20 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 3 Jun 2022 21:56:53 -0700 Subject: [PATCH 223/947] Remove google scan_for_calendars service and simplify platform setup (#73010) * Remove google scan_for_calendars service and simplify platform setup * Update invalid calendar yaml test --- homeassistant/components/google/__init__.py | 65 +--------- homeassistant/components/google/calendar.py | 113 +++++++++--------- homeassistant/components/google/const.py | 2 - homeassistant/components/google/services.yaml | 3 - tests/components/google/test_init.py | 68 ++--------- 5 files changed, 67 insertions(+), 184 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 2a40bfe7043..1ddb44e570b 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -1,7 +1,6 @@ """Support for Google - Calendar Event Devices.""" from __future__ import annotations -import asyncio from collections.abc import Mapping from datetime import datetime, timedelta import logging @@ -9,8 +8,7 @@ from typing import Any import aiohttp from gcal_sync.api import GoogleCalendarService -from gcal_sync.exceptions import ApiException -from gcal_sync.model import Calendar, DateOrDatetime, Event +from gcal_sync.model import DateOrDatetime, Event from oauth2client.file import Storage import voluptuous as vol from voluptuous.error import Error as VoluptuousError @@ -31,15 +29,10 @@ from homeassistant.const import ( CONF_OFFSET, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ( - ConfigEntryAuthFailed, - ConfigEntryNotReady, - HomeAssistantError, -) +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.typing import ConfigType @@ -49,7 +42,6 @@ from .const import ( DATA_CONFIG, DATA_SERVICE, DEVICE_AUTH_IMPL, - DISCOVER_CALENDAR, DOMAIN, FeatureAccess, ) @@ -86,7 +78,6 @@ NOTIFICATION_ID = "google_calendar_notification" NOTIFICATION_TITLE = "Google Calendar Setup" GROUP_NAME_ALL_CALENDARS = "Google Calendar Sensors" -SERVICE_SCAN_CALENDARS = "scan_for_calendars" SERVICE_ADD_EVENT = "add_event" YAML_DEVICES = f"{DOMAIN}_calendars.yaml" @@ -248,7 +239,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) hass.data[DOMAIN][DATA_SERVICE] = calendar_service - await async_setup_services(hass, calendar_service) # Only expose the add event service if we have the correct permissions if get_feature_access(hass, entry) is FeatureAccess.read_write: await async_setup_add_event_service(hass, calendar_service) @@ -278,57 +268,6 @@ async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: await hass.config_entries.async_reload(entry.entry_id) -async def async_setup_services( - hass: HomeAssistant, - calendar_service: GoogleCalendarService, -) -> None: - """Set up the service listeners.""" - - calendars = await hass.async_add_executor_job( - load_config, hass.config.path(YAML_DEVICES) - ) - calendars_file_lock = asyncio.Lock() - - async def _found_calendar(calendar_item: Calendar) -> None: - calendar = get_calendar_info( - hass, - calendar_item.dict(exclude_unset=True), - ) - calendar_id = calendar_item.id - # If the google_calendars.yaml file already exists, populate it for - # backwards compatibility, but otherwise do not create it if it does - # not exist. - if calendars: - if calendar_id not in calendars: - calendars[calendar_id] = calendar - async with calendars_file_lock: - await hass.async_add_executor_job( - update_config, hass.config.path(YAML_DEVICES), calendar - ) - else: - # Prefer entity/name information from yaml, overriding api - calendar = calendars[calendar_id] - async_dispatcher_send(hass, DISCOVER_CALENDAR, calendar) - - created_calendars = set() - - async def _scan_for_calendars(call: ServiceCall) -> None: - """Scan for new calendars.""" - try: - result = await calendar_service.async_list_calendars() - except ApiException as err: - raise HomeAssistantError(str(err)) from err - tasks = [] - for calendar_item in result.items: - if calendar_item.id in created_calendars: - continue - created_calendars.add(calendar_item.id) - tasks.append(_found_calendar(calendar_item)) - await asyncio.gather(*tasks) - - hass.services.async_register(DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars) - - async def async_setup_add_event_service( hass: HomeAssistant, calendar_service: GoogleCalendarService, diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index ba4368fefae..78661ed792f 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -20,24 +20,24 @@ from homeassistant.components.calendar import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET -from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, PlatformNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle from . import ( - CONF_CAL_ID, CONF_IGNORE_AVAILABILITY, CONF_SEARCH, CONF_TRACK, DATA_SERVICE, DEFAULT_CONF_OFFSET, DOMAIN, - SERVICE_SCAN_CALENDARS, + YAML_DEVICES, + get_calendar_info, + load_config, + update_config, ) -from .const import DISCOVER_CALENDAR _LOGGER = logging.getLogger(__name__) @@ -59,66 +59,63 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the google calendar platform.""" - - @callback - def async_discover(discovery_info: dict[str, Any]) -> None: - _async_setup_entities( - hass, - entry, - async_add_entities, - discovery_info, - ) - - entry.async_on_unload( - async_dispatcher_connect(hass, DISCOVER_CALENDAR, async_discover) - ) - - # Look for any new calendars + calendar_service = hass.data[DOMAIN][DATA_SERVICE] try: - await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, blocking=True) - except HomeAssistantError as err: - # This can happen if there's a connection error during setup. + result = await calendar_service.async_list_calendars() + except ApiException as err: raise PlatformNotReady(str(err)) from err - -@callback -def _async_setup_entities( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, - disc_info: dict[str, Any], -) -> None: - calendar_service = hass.data[DOMAIN][DATA_SERVICE] + # Yaml configuration may override objects from the API + calendars = await hass.async_add_executor_job( + load_config, hass.config.path(YAML_DEVICES) + ) + new_calendars = [] entities = [] - num_entities = len(disc_info[CONF_ENTITIES]) - for data in disc_info[CONF_ENTITIES]: - entity_enabled = data.get(CONF_TRACK, True) - if not entity_enabled: - _LOGGER.warning( - "The 'track' option in google_calendars.yaml has been deprecated. The setting " - "has been imported to the UI, and should now be removed from google_calendars.yaml" - ) - entity_name = data[CONF_DEVICE_ID] - entity_id = generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass) - calendar_id = disc_info[CONF_CAL_ID] - if num_entities > 1: - # The google_calendars.yaml file lets users add multiple entities for - # the same calendar id and needs additional disambiguation - unique_id = f"{calendar_id}-{entity_name}" + for calendar_item in result.items: + calendar_id = calendar_item.id + if calendars and calendar_id in calendars: + calendar_info = calendars[calendar_id] else: - unique_id = calendar_id - entity = GoogleCalendarEntity( - calendar_service, - disc_info[CONF_CAL_ID], - data, - entity_id, - unique_id, - entity_enabled, - ) - entities.append(entity) + calendar_info = get_calendar_info( + hass, calendar_item.dict(exclude_unset=True) + ) + new_calendars.append(calendar_info) + + # Yaml calendar config may map one calendar to multiple entities with extra options like + # offsets or search criteria. + num_entities = len(calendar_info[CONF_ENTITIES]) + for data in calendar_info[CONF_ENTITIES]: + entity_enabled = data.get(CONF_TRACK, True) + if not entity_enabled: + _LOGGER.warning( + "The 'track' option in google_calendars.yaml has been deprecated. The setting " + "has been imported to the UI, and should now be removed from google_calendars.yaml" + ) + entity_name = data[CONF_DEVICE_ID] + entities.append( + GoogleCalendarEntity( + calendar_service, + calendar_id, + data, + generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass), + # The google_calendars.yaml file lets users add multiple entities for + # the same calendar id and needs additional disambiguation + f"{calendar_id}-{entity_name}" if num_entities > 1 else calendar_id, + entity_enabled, + ) + ) async_add_entities(entities, True) + if calendars and new_calendars: + + def append_calendars_to_config() -> None: + path = hass.config.path(YAML_DEVICES) + for calendar in new_calendars: + update_config(path, calendar) + + await hass.async_add_executor_job(append_calendars_to_config) + class GoogleCalendarEntity(CalendarEntity): """A calendar event device.""" diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index c01ff1ea48b..fba9b01b600 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -11,8 +11,6 @@ DATA_CALENDARS = "calendars" DATA_SERVICE = "service" DATA_CONFIG = "config" -DISCOVER_CALENDAR = "google_discover_calendar" - class FeatureAccess(Enum): """Class to represent different access scopes.""" diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index 21df763374f..baa069aaedf 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -1,6 +1,3 @@ -scan_for_calendars: - name: Scan for calendars - description: Scan for new calendars. add_event: name: Add event description: Add a new calendar event. diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index f2cf067f7bb..cadb444c26f 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -14,11 +14,7 @@ from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) -from homeassistant.components.google import ( - DOMAIN, - SERVICE_ADD_EVENT, - SERVICE_SCAN_CALENDARS, -) +from homeassistant.components.google import DOMAIN, SERVICE_ADD_EVENT from homeassistant.components.google.const import CONF_CALENDAR_ACCESS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF @@ -140,17 +136,24 @@ async def test_invalid_calendar_yaml( component_setup: ComponentSetup, calendars_config: list[dict[str, Any]], mock_calendars_yaml: None, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, ) -> None: - """Test setup with missing entity id fields fails to setup the config entry.""" + """Test setup with missing entity id fields fails to load the platform.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 entry = entries[0] - assert entry.state is ConfigEntryState.SETUP_ERROR + assert entry.state is ConfigEntryState.LOADED assert not hass.states.get(TEST_YAML_ENTITY) + assert not hass.states.get(TEST_API_ENTITY) async def test_calendar_yaml_error( @@ -470,57 +473,6 @@ async def test_add_event_date_time( } -async def test_scan_calendars( - hass: HomeAssistant, - component_setup: ComponentSetup, - mock_calendars_list: ApiResult, - mock_events_list: ApiResult, - setup_config_entry: MockConfigEntry, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test finding a calendar from the API.""" - - mock_calendars_list({"items": []}) - assert await component_setup() - - calendar_1 = { - "id": "calendar-id-1", - "summary": "Calendar 1", - } - calendar_2 = { - "id": "calendar-id-2", - "summary": "Calendar 2", - } - - aioclient_mock.clear_requests() - mock_calendars_list({"items": [calendar_1]}) - mock_events_list({}, calendar_id="calendar-id-1") - await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) - await hass.async_block_till_done() - - state = hass.states.get("calendar.calendar_1") - assert state - assert state.name == "Calendar 1" - assert state.state == STATE_OFF - assert not hass.states.get("calendar.calendar_2") - - aioclient_mock.clear_requests() - mock_calendars_list({"items": [calendar_1, calendar_2]}) - mock_events_list({}, calendar_id="calendar-id-1") - mock_events_list({}, calendar_id="calendar-id-2") - await hass.services.async_call(DOMAIN, SERVICE_SCAN_CALENDARS, {}, blocking=True) - await hass.async_block_till_done() - - state = hass.states.get("calendar.calendar_1") - assert state - assert state.name == "Calendar 1" - assert state.state == STATE_OFF - state = hass.states.get("calendar.calendar_2") - assert state - assert state.name == "Calendar 2" - assert state.state == STATE_OFF - - @pytest.mark.parametrize( "config_entry_token_expiry", [datetime.datetime.max.timestamp() + 1] ) From 0829bec1c349f5d48e21e39736c0f12e12386df3 Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Sat, 4 Jun 2022 07:52:39 +0200 Subject: [PATCH 224/947] Bump pypck to 0.7.15 (#73009) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 412ef74e3b8..eea72a0e508 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -3,7 +3,7 @@ "name": "LCN", "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/lcn", - "requirements": ["pypck==0.7.14"], + "requirements": ["pypck==0.7.15"], "codeowners": ["@alengwenus"], "iot_class": "local_push", "loggers": ["pypck"] diff --git a/requirements_all.txt b/requirements_all.txt index bad7eac7e6c..ce727d0e31c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1735,7 +1735,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.14 +pypck==0.7.15 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54028fac054..d265ec19a3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1178,7 +1178,7 @@ pyowm==3.2.0 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.14 +pypck==0.7.15 # homeassistant.components.plaato pyplaato==0.0.18 From a1b372e4ca59b9574908f8797607725c6c9328ac Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 4 Jun 2022 12:37:39 +0200 Subject: [PATCH 225/947] Minor fixes Trafikverket Train (#72996) * Minor fixes Trafikverket Train * Remove ConfigEntryAuthFailed --- .../components/trafikverket_train/__init__.py | 30 +++++++++++-- .../components/trafikverket_train/sensor.py | 31 ++++--------- .../trafikverket_train/test_config_flow.py | 45 +++++++++++++++++++ 3 files changed, 80 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 4411ccab948..ee026371b04 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -1,15 +1,39 @@ """The trafikverket_train component.""" from __future__ import annotations -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from pytrafikverket import TrafikverketTrain -from .const import PLATFORMS +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_FROM, CONF_TO, DOMAIN, PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trafikverket Train from a config entry.""" + http_session = async_get_clientsession(hass) + train_api = TrafikverketTrain(http_session, entry.data[CONF_API_KEY]) + + try: + to_station = await train_api.async_get_train_station(entry.data[CONF_TO]) + from_station = await train_api.async_get_train_station(entry.data[CONF_FROM]) + except ValueError as error: + if "Invalid authentication" in error.args[0]: + raise ConfigEntryAuthFailed from error + raise ConfigEntryNotReady( + f"Problem when trying station {entry.data[CONF_FROM]} to {entry.data[CONF_TO]}. Error: {error} " + ) from error + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + CONF_TO: to_station, + CONF_FROM: from_station, + "train_api": train_api, + } + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 4a419ff3b33..d9674e5373a 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -10,10 +10,8 @@ from pytrafikverket.trafikverket_train import StationInfo, TrainStop from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS +from homeassistant.const import CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -42,24 +40,11 @@ async def async_setup_entry( ) -> None: """Set up the Trafikverket sensor entry.""" - httpsession = async_get_clientsession(hass) - train_api = TrafikverketTrain(httpsession, entry.data[CONF_API_KEY]) - - try: - to_station = await train_api.async_get_train_station(entry.data[CONF_TO]) - from_station = await train_api.async_get_train_station(entry.data[CONF_FROM]) - except ValueError as error: - if "Invalid authentication" in error.args[0]: - raise ConfigEntryAuthFailed from error - raise ConfigEntryNotReady( - f"Problem when trying station {entry.data[CONF_FROM]} to {entry.data[CONF_TO]}. Error: {error} " - ) from error - - train_time = ( - dt.parse_time(entry.data.get(CONF_TIME, "")) - if entry.data.get(CONF_TIME) - else None - ) + train_api = hass.data[DOMAIN][entry.entry_id]["train_api"] + to_station = hass.data[DOMAIN][entry.entry_id][CONF_TO] + from_station = hass.data[DOMAIN][entry.entry_id][CONF_FROM] + get_time: str | None = entry.data.get(CONF_TIME) + train_time = dt.parse_time(get_time) if get_time else None async_add_entities( [ @@ -157,8 +142,8 @@ class TrainSensor(SensorEntity): _state = await self._train_api.async_get_next_train_stop( self._from_station, self._to_station, when ) - except ValueError as output_error: - _LOGGER.error("Departure %s encountered a problem: %s", when, output_error) + except ValueError as error: + _LOGGER.error("Departure %s encountered a problem: %s", when, error) if not _state: self._attr_available = False diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 09539584cbc..37788fc285b 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -66,6 +66,51 @@ async def test_form(hass: HomeAssistant) -> None: ) +async def test_form_entry_already_exist(hass: HomeAssistant) -> None: + """Test flow aborts when entry already exist.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_API_KEY: "1234567890", + CONF_NAME: "Stockholm C to Uppsala C at 10:00", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + unique_id=f"stockholmc-uppsalac-10:00-{WEEKDAYS}", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", + ), patch( + "homeassistant.components.trafikverket_train.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_API_KEY: "1234567890", + CONF_FROM: "Stockholm C", + CONF_TO: "Uppsala C", + CONF_TIME: "10:00", + CONF_WEEKDAY: WEEKDAYS, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + @pytest.mark.parametrize( "error_message,base_error", [ From c7416c0bb95dd40d7f8bebc6fc5773a849e5c300 Mon Sep 17 00:00:00 2001 From: Matrix Date: Sat, 4 Jun 2022 23:54:39 +0800 Subject: [PATCH 226/947] Add yolink vibration sensor (#72926) * Add yolink vibration sensor * add battery entity * fix suggest --- .../components/yolink/binary_sensor.py | 9 +++++++ homeassistant/components/yolink/const.py | 1 + homeassistant/components/yolink/sensor.py | 25 +++++++++++++------ 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index cacba484fe9..d5c9ddedb84 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -21,6 +21,7 @@ from .const import ( ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, DOMAIN, ) from .coordinator import YoLinkCoordinator @@ -40,6 +41,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_LEAK_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, ] SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( @@ -66,6 +68,13 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( value=lambda value: value == "alert" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_LEAK_SENSOR], ), + YoLinkBinarySensorEntityDescription( + key="vibration_state", + name="Vibration", + device_class=BinarySensorDeviceClass.VIBRATION, + value=lambda value: value == "alert" if value is not None else None, + exists_fn=lambda device: device.device_type in [ATTR_DEVICE_VIBRATION_SENSOR], + ), ) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 97252c5c989..16304e0de4b 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -17,5 +17,6 @@ ATTR_DEVICE_DOOR_SENSOR = "DoorSensor" ATTR_DEVICE_TH_SENSOR = "THSensor" ATTR_DEVICE_MOTION_SENSOR = "MotionSensor" ATTR_DEVICE_LEAK_SENSOR = "LeakSensor" +ATTR_DEVICE_VIBRATION_SENSOR = "VibrationSensor" ATTR_DEVICE_OUTLET = "Outlet" ATTR_DEVICE_SIREN = "Siren" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 463d8b14da4..917a93c310d 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -23,6 +23,7 @@ from .const import ( ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, DOMAIN, ) from .coordinator import YoLinkCoordinator @@ -45,6 +46,21 @@ class YoLinkSensorEntityDescription( value: Callable = lambda state: state +SENSOR_DEVICE_TYPE = [ + ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, +] + +BATTERY_POWER_SENSOR = [ + ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_TH_SENSOR, + ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_VIBRATION_SENSOR, +] + + SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( YoLinkSensorEntityDescription( key="battery", @@ -57,8 +73,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( ) if value is not None else None, - exists_fn=lambda device: device.device_type - in [ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_MOTION_SENSOR], + exists_fn=lambda device: device.device_type in BATTERY_POWER_SENSOR, ), YoLinkSensorEntityDescription( key="humidity", @@ -78,12 +93,6 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( ), ) -SENSOR_DEVICE_TYPE = [ - ATTR_DEVICE_DOOR_SENSOR, - ATTR_DEVICE_MOTION_SENSOR, - ATTR_DEVICE_TH_SENSOR, -] - async def async_setup_entry( hass: HomeAssistant, From 0a2a166860fcc7e7fd5c693155ccc2af50ab11aa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jun 2022 09:47:30 -1000 Subject: [PATCH 227/947] Fix history stats not comparing all times in UTC (#73040) --- homeassistant/components/history_stats/data.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 5466498fc32..3b17c715c97 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -67,9 +67,10 @@ class HistoryStats: current_period_end_timestamp = floored_timestamp(current_period_end) previous_period_start_timestamp = floored_timestamp(previous_period_start) previous_period_end_timestamp = floored_timestamp(previous_period_end) - now_timestamp = floored_timestamp(datetime.datetime.now()) + utc_now = dt_util.utcnow() + now_timestamp = floored_timestamp(utc_now) - if now_timestamp < current_period_start_timestamp: + if current_period_start > utc_now: # History cannot tell the future self._history_current_period = [] self._previous_run_before_start = True From 7ac7af094f0144997aeb455190ff77998ca37694 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jun 2022 09:54:10 -1000 Subject: [PATCH 228/947] Fix missing historical context data in logbook for MySQL and PostgreSQL (#73011) --- homeassistant/components/recorder/filters.py | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 835496c2d6e..90851e9f251 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Iterable import json from typing import Any -from sqlalchemy import JSON, Column, Text, cast, not_, or_ +from sqlalchemy import Column, Text, cast, not_, or_ from sqlalchemy.sql.elements import ClauseList from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE @@ -16,6 +16,7 @@ from .models import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States DOMAIN = "history" HISTORY_FILTERS = "history_filters" +JSON_NULL = json.dumps(None) GLOB_TO_SQL_CHARS = { ord("*"): "%", @@ -196,7 +197,17 @@ class Filters: """Generate the entity filter query.""" _encoder = json.dumps return or_( - (ENTITY_ID_IN_EVENT == JSON.NULL) & (OLD_ENTITY_ID_IN_EVENT == JSON.NULL), + # sqlalchemy's SQLite json implementation always + # wraps everything with JSON_QUOTE so it resolves to 'null' + # when its empty + # + # For MySQL and PostgreSQL it will resolve to a literal + # NULL when its empty + # + ((ENTITY_ID_IN_EVENT == JSON_NULL) | ENTITY_ID_IN_EVENT.is_(None)) + & ( + (OLD_ENTITY_ID_IN_EVENT == JSON_NULL) | OLD_ENTITY_ID_IN_EVENT.is_(None) + ), self._generate_filter_for_columns( (ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT), _encoder ).self_group(), @@ -208,8 +219,11 @@ def _globs_to_like( ) -> ClauseList: """Translate glob to sql.""" matchers = [ - cast(column, Text()).like( - encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\" + ( + column.is_not(None) + & cast(column, Text()).like( + encoder(glob_str).translate(GLOB_TO_SQL_CHARS), escape="\\" + ) ) for glob_str in glob_strs for column in columns @@ -221,7 +235,10 @@ def _entity_matcher( entity_ids: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: matchers = [ - cast(column, Text()).in_([encoder(entity_id) for entity_id in entity_ids]) + ( + column.is_not(None) + & cast(column, Text()).in_([encoder(entity_id) for entity_id in entity_ids]) + ) for column in columns ] return or_(*matchers) if matchers else or_(False) @@ -231,7 +248,7 @@ def _domain_matcher( domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: matchers = [ - cast(column, Text()).like(encoder(f"{domain}.%")) + (column.is_not(None) & cast(column, Text()).like(encoder(f"{domain}.%"))) for domain in domains for column in columns ] From 13734428bbce82e0476f646df6b943e51042fb37 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 5 Jun 2022 00:26:15 +0000 Subject: [PATCH 229/947] [ci skip] Translation update --- .../components/scrape/translations/de.json | 73 +++++++++++++++++++ .../components/scrape/translations/el.json | 73 +++++++++++++++++++ .../components/scrape/translations/fr.json | 73 +++++++++++++++++++ .../components/scrape/translations/hu.json | 73 +++++++++++++++++++ .../components/scrape/translations/id.json | 22 +++++- .../components/scrape/translations/ja.json | 58 +++++++++++++++ .../scrape/translations/zh-Hant.json | 73 +++++++++++++++++++ 7 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/scrape/translations/de.json create mode 100644 homeassistant/components/scrape/translations/el.json create mode 100644 homeassistant/components/scrape/translations/fr.json create mode 100644 homeassistant/components/scrape/translations/hu.json create mode 100644 homeassistant/components/scrape/translations/ja.json create mode 100644 homeassistant/components/scrape/translations/zh-Hant.json diff --git a/homeassistant/components/scrape/translations/de.json b/homeassistant/components/scrape/translations/de.json new file mode 100644 index 00000000000..d4e2f37f88d --- /dev/null +++ b/homeassistant/components/scrape/translations/de.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert" + }, + "step": { + "user": { + "data": { + "attribute": "Attribut", + "authentication": "Authentifizierung", + "device_class": "Ger\u00e4teklasse", + "headers": "Header", + "index": "Index", + "name": "Name", + "password": "Passwort", + "resource": "Ressource", + "select": "Ausw\u00e4hlen", + "state_class": "Zustandsklasse", + "unit_of_measurement": "Ma\u00dfeinheit", + "username": "Benutzername", + "value_template": "Wertvorlage", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" + }, + "data_description": { + "attribute": "Wert eines Attributs auf dem ausgew\u00e4hlten Tag abrufen", + "authentication": "Typ der HTTP-Authentifizierung. Entweder basic oder digest", + "device_class": "Der Typ/die Klasse des Sensors, um das Symbol im Frontend festzulegen", + "headers": "F\u00fcr die Webanforderung zu verwendende Header", + "index": "Definiert, welche der vom CSS-Selektor zur\u00fcckgegebenen Elemente verwendet werden sollen", + "resource": "Die URL der Website, die den Wert enth\u00e4lt", + "select": "Legt fest, nach welchem Tag gesucht werden soll. Siehe Beautifulsoup CSS-Selektoren f\u00fcr Details", + "state_class": "Die state_class des Sensors", + "value_template": "Definiert eine Vorlage, um den Zustand des Sensors zu ermitteln", + "verify_ssl": "Aktiviert/deaktiviert die \u00dcberpr\u00fcfung des SSL/TLS-Zertifikats, z.B. wenn es selbst signiert ist" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribut", + "authentication": "Authentifizierung", + "device_class": "Ger\u00e4teklasse", + "headers": "Header", + "index": "Index", + "name": "Name", + "password": "Passwort", + "resource": "Ressource", + "select": "Ausw\u00e4hlen", + "state_class": "Zustandsklasse", + "unit_of_measurement": "Ma\u00dfeinheit", + "username": "Benutzername", + "value_template": "Wertvorlage", + "verify_ssl": "SSL-Zertifikat \u00fcberpr\u00fcfen" + }, + "data_description": { + "attribute": "Wert eines Attributs auf dem ausgew\u00e4hlten Tag abrufen", + "authentication": "Typ der HTTP-Authentifizierung. Entweder basic oder digest", + "device_class": "Der Typ/die Klasse des Sensors, um das Symbol im Frontend festzulegen", + "headers": "F\u00fcr die Webanforderung zu verwendende Header", + "index": "Definiert, welche der vom CSS-Selektor zur\u00fcckgegebenen Elemente verwendet werden sollen", + "resource": "Die URL der Website, die den Wert enth\u00e4lt", + "select": "Legt fest, nach welchem Tag gesucht werden soll. Siehe Beautifulsoup CSS-Selektoren f\u00fcr Details", + "state_class": "Die state_class des Sensors", + "value_template": "Definiert eine Vorlage, um den Zustand des Sensors zu ermitteln", + "verify_ssl": "Aktiviert/deaktiviert die \u00dcberpr\u00fcfung des SSL/TLS-Zertifikats, z.B. wenn es selbst signiert ist" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/el.json b/homeassistant/components/scrape/translations/el.json new file mode 100644 index 00000000000..8782b2b9f6d --- /dev/null +++ b/homeassistant/components/scrape/translations/el.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + }, + "step": { + "user": { + "data": { + "attribute": "\u03a7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc", + "authentication": "\u0395\u03bb\u03ad\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", + "device_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2", + "index": "\u0394\u03b5\u03af\u03ba\u03c4\u03b7\u03c2", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1", + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "resource": "\u03a0\u03cc\u03c1\u03bf\u03c2", + "select": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae", + "state_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2", + "unit_of_measurement": "\u039c\u03bf\u03bd\u03ac\u03b4\u03b1 \u03bc\u03ad\u03c4\u03c1\u03b7\u03c3\u03b7\u03c2", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7", + "value_template": "\u03a0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03c4\u03b9\u03bc\u03ae\u03c2", + "verify_ssl": "\u0395\u03c0\u03b1\u03bb\u03b7\u03b8\u03b5\u03cd\u03c3\u03c4\u03b5 \u03c4\u03bf \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03cc SSL" + }, + "data_description": { + "attribute": "\u039b\u03ae\u03c8\u03b7 \u03c4\u03b7\u03c2 \u03c4\u03b9\u03bc\u03ae\u03c2 \u03b5\u03bd\u03cc\u03c2 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03bf\u03cd \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03bc\u03ad\u03bd\u03b7 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1", + "authentication": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c4\u03bf\u03c5 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 HTTP. \u0395\u03af\u03c4\u03b5 \u03b2\u03b1\u03c3\u03b9\u03ba\u03cc\u03c2 \u03b5\u03af\u03c4\u03b5 \u03ba\u03c9\u03b4\u03b9\u03ba\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf\u03c2", + "device_class": "\u039f \u03c4\u03cd\u03c0\u03bf\u03c2/\u03ba\u03bb\u03ac\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1 \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03bf\u03c1\u03b9\u03c3\u03bc\u03cc \u03c4\u03bf\u03c5 \u03b5\u03b9\u03ba\u03bf\u03bd\u03b9\u03b4\u03af\u03bf\u03c5 \u03c3\u03c4\u03bf frontend", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2 \u03c0\u03bf\u03c5 \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03b1\u03af\u03c4\u03b7\u03c3\u03b7 \u03b9\u03c3\u03c4\u03bf\u03cd", + "index": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b1\u03c0\u03cc \u03c4\u03b1 \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03b1 \u03c0\u03bf\u03c5 \u03b5\u03c0\u03b9\u03c3\u03c4\u03c1\u03ad\u03c6\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03b1 CSS \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd", + "resource": "\u0397 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03c3\u03c4\u03bf\u03bd \u03b9\u03c3\u03c4\u03cc\u03c4\u03bf\u03c0\u03bf \u03c0\u03bf\u03c5 \u03c0\u03b5\u03c1\u03b9\u03ad\u03c7\u03b5\u03b9 \u03c4\u03b7\u03bd \u03c4\u03b9\u03bc\u03ae", + "select": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1 \u03b8\u03b1 \u03b1\u03bd\u03b1\u03b6\u03b7\u03c4\u03b7\u03b8\u03b5\u03af. \u0395\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03bf\u03c5\u03c2 \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03b5\u03af\u03c2 CSS \u03c4\u03bf\u03c5 Beautifulsoup \u03b3\u03b9\u03b1 \u03bb\u03b5\u03c0\u03c4\u03bf\u03bc\u03ad\u03c1\u03b5\u03b9\u03b5\u03c2", + "state_class": "\u0397 state_class \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1\u03c2", + "value_template": "\u039a\u03b1\u03b8\u03bf\u03c1\u03af\u03b6\u03b5\u03b9 \u03ad\u03bd\u03b1 \u03c0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03c0\u03b1\u03c1\u03b1\u03bb\u03b1\u03b2\u03ae \u03c4\u03b7\u03c2 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1", + "verify_ssl": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af/\u03b1\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03c4\u03b7\u03bd \u03b5\u03c0\u03b1\u03bb\u03ae\u03b8\u03b5\u03c5\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03bf\u03cd SSL/TLS, \u03c0.\u03c7. \u03b1\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b1\u03c5\u03c4\u03bf-\u03c5\u03c0\u03bf\u03b3\u03b5\u03b3\u03c1\u03b1\u03bc\u03bc\u03ad\u03bd\u03bf." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u03a7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc", + "authentication": "\u0395\u03bb\u03ad\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", + "device_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2", + "index": "\u0394\u03b5\u03af\u03ba\u03c4\u03b7\u03c2", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1", + "password": "\u039a\u03b5\u03bd\u03cc", + "resource": "\u03a0\u03cc\u03c1\u03bf\u03c2", + "select": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae", + "state_class": "\u039a\u03bb\u03ac\u03c3\u03b7 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2", + "unit_of_measurement": "\u039c\u03bf\u03bd\u03ac\u03b4\u03b1 \u03bc\u03ad\u03c4\u03c1\u03b7\u03c3\u03b7\u03c2", + "username": "\u039a\u03b5\u03bd\u03cc", + "value_template": "\u03a0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03c4\u03b9\u03bc\u03ae\u03c2", + "verify_ssl": "\u0395\u03c0\u03b1\u03bb\u03b7\u03b8\u03b5\u03cd\u03c3\u03c4\u03b5 \u03c4\u03bf \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03cc SSL" + }, + "data_description": { + "attribute": "\u039b\u03ae\u03c8\u03b7 \u03c4\u03b7\u03c2 \u03c4\u03b9\u03bc\u03ae\u03c2 \u03b5\u03bd\u03cc\u03c2 \u03c7\u03b1\u03c1\u03b1\u03ba\u03c4\u03b7\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03bf\u03cd \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03b5\u03b3\u03bc\u03ad\u03bd\u03b7 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1", + "authentication": "\u03a4\u03cd\u03c0\u03bf\u03c2 \u03c4\u03bf\u03c5 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 HTTP. \u0395\u03af\u03c4\u03b5 \u03b2\u03b1\u03c3\u03b9\u03ba\u03cc\u03c2 \u03b5\u03af\u03c4\u03b5 \u03ba\u03c9\u03b4\u03b9\u03ba\u03bf\u03c0\u03bf\u03b9\u03b7\u03bc\u03ad\u03bd\u03bf\u03c2", + "device_class": "\u039f \u03c4\u03cd\u03c0\u03bf\u03c2/\u03ba\u03bb\u03ac\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1 \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03bf\u03c1\u03b9\u03c3\u03bc\u03cc \u03c4\u03bf\u03c5 \u03b5\u03b9\u03ba\u03bf\u03bd\u03b9\u03b4\u03af\u03bf\u03c5 \u03c3\u03c4\u03bf frontend", + "headers": "\u039a\u03b5\u03c6\u03b1\u03bb\u03af\u03b4\u03b5\u03c2 \u03c0\u03bf\u03c5 \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03b1\u03af\u03c4\u03b7\u03c3\u03b7 \u03b9\u03c3\u03c4\u03bf\u03cd", + "index": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b1\u03c0\u03cc \u03c4\u03b1 \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03b1 \u03c0\u03bf\u03c5 \u03b5\u03c0\u03b9\u03c3\u03c4\u03c1\u03ad\u03c6\u03bf\u03bd\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03bf\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ad\u03b1 CSS \u03b8\u03b1 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b7\u03b8\u03bf\u03cd\u03bd", + "resource": "\u0397 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL \u03c3\u03c4\u03bf\u03bd \u03b9\u03c3\u03c4\u03cc\u03c4\u03bf\u03c0\u03bf \u03c0\u03bf\u03c5 \u03c0\u03b5\u03c1\u03b9\u03ad\u03c7\u03b5\u03b9 \u03c4\u03b7\u03bd \u03c4\u03b9\u03bc\u03ae", + "select": "\u039f\u03c1\u03af\u03b6\u03b5\u03b9 \u03c0\u03bf\u03b9\u03b1 \u03b5\u03c4\u03b9\u03ba\u03ad\u03c4\u03b1 \u03b8\u03b1 \u03b1\u03bd\u03b1\u03b6\u03b7\u03c4\u03b7\u03b8\u03b5\u03af. \u0395\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03bf\u03c5\u03c2 \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03b5\u03af\u03c2 CSS \u03c4\u03bf\u03c5 Beautifulsoup \u03b3\u03b9\u03b1 \u03bb\u03b5\u03c0\u03c4\u03bf\u03bc\u03ad\u03c1\u03b5\u03b9\u03b5\u03c2", + "state_class": "\u0397 state_class \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1", + "value_template": "\u039a\u03b1\u03b8\u03bf\u03c1\u03af\u03b6\u03b5\u03b9 \u03ad\u03bd\u03b1 \u03c0\u03c1\u03cc\u03c4\u03c5\u03c0\u03bf \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd \u03c0\u03b1\u03c1\u03b1\u03bb\u03b1\u03b2\u03ae \u03c4\u03b7\u03c2 \u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2 \u03c4\u03bf\u03c5 \u03b1\u03b9\u03c3\u03b8\u03b7\u03c4\u03ae\u03c1\u03b1", + "verify_ssl": "\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af/\u03b1\u03c0\u03b5\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03c4\u03b7\u03bd \u03b5\u03c0\u03b1\u03bb\u03ae\u03b8\u03b5\u03c5\u03c3\u03b7 \u03c4\u03bf\u03c5 \u03c0\u03b9\u03c3\u03c4\u03bf\u03c0\u03bf\u03b9\u03b7\u03c4\u03b9\u03ba\u03bf\u03cd SSL/TLS, \u03c0.\u03c7. \u03b1\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b1\u03c5\u03c4\u03bf-\u03c5\u03c0\u03bf\u03b3\u03b5\u03b3\u03c1\u03b1\u03bc\u03bc\u03ad\u03bd\u03bf." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/fr.json b/homeassistant/components/scrape/translations/fr.json new file mode 100644 index 00000000000..f68ce5808b7 --- /dev/null +++ b/homeassistant/components/scrape/translations/fr.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9" + }, + "step": { + "user": { + "data": { + "attribute": "Attribut", + "authentication": "Authentification", + "device_class": "Classe d'appareil", + "headers": "En-t\u00eates", + "index": "Index", + "name": "Nom", + "password": "Mot de passe", + "resource": "Ressource", + "select": "S\u00e9lectionner", + "state_class": "Classe d'\u00e9tat", + "unit_of_measurement": "Unit\u00e9 de mesure", + "username": "Nom d'utilisateur", + "value_template": "Mod\u00e8le de valeur", + "verify_ssl": "V\u00e9rifier le certificat SSL" + }, + "data_description": { + "attribute": "Obtenir la valeur d'un attribut de la balise s\u00e9lectionn\u00e9e", + "authentication": "M\u00e9thode d'authentification HTTP. \u00ab\u00a0basic\u00a0\u00bb ou \u00ab\u00a0digest\u00a0\u00bb", + "device_class": "Le type (la classe) du capteur qui d\u00e9finira l'ic\u00f4ne dans l'interface", + "headers": "Les en-t\u00eates \u00e0 utiliser pour la requ\u00eate Web", + "index": "D\u00e9finit l'\u00e9l\u00e9ment \u00e0 utiliser parmi ceux renvoy\u00e9s par le s\u00e9lecteur CSS", + "resource": "L'URL du site web qui contient la valeur", + "select": "D\u00e9finit la balise \u00e0 rechercher. Consultez les s\u00e9lecteurs CSS de Beautifulsoup pour plus de d\u00e9tails", + "state_class": "La state_class du capteur", + "value_template": "D\u00e9finit un mod\u00e8le pour obtenir l'\u00e9tat du capteur", + "verify_ssl": "Active ou d\u00e9sactive la v\u00e9rification du certificat SSL/TLS, par exemple s'il est auto-sign\u00e9" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribut", + "authentication": "Authentification", + "device_class": "Classe d'appareil", + "headers": "En-t\u00eates", + "index": "Index", + "name": "Nom", + "password": "Mot de passe", + "resource": "Ressource", + "select": "S\u00e9lectionner", + "state_class": "Classe d'\u00e9tat", + "unit_of_measurement": "Unit\u00e9 de mesure", + "username": "Nom d'utilisateur", + "value_template": "Mod\u00e8le de valeur", + "verify_ssl": "V\u00e9rifier le certificat SSL" + }, + "data_description": { + "attribute": "Obtenir la valeur d'un attribut de la balise s\u00e9lectionn\u00e9e", + "authentication": "M\u00e9thode d'authentification HTTP. \u00ab\u00a0basic\u00a0\u00bb ou \u00ab\u00a0digest\u00a0\u00bb", + "device_class": "Le type (la classe) du capteur qui d\u00e9finira l'ic\u00f4ne dans l'interface", + "headers": "Les en-t\u00eates \u00e0 utiliser pour la requ\u00eate Web", + "index": "D\u00e9finit l'\u00e9l\u00e9ment \u00e0 utiliser parmi ceux renvoy\u00e9s par le s\u00e9lecteur CSS", + "resource": "L'URL du site web qui contient la valeur", + "select": "D\u00e9finit la balise \u00e0 rechercher. Consultez les s\u00e9lecteurs CSS de Beautifulsoup pour plus de d\u00e9tails", + "state_class": "La state_class du capteur", + "value_template": "D\u00e9finit un mod\u00e8le pour obtenir l'\u00e9tat du capteur", + "verify_ssl": "Active ou d\u00e9sactive la v\u00e9rification du certificat SSL/TLS, par exemple s'il est auto-sign\u00e9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/hu.json b/homeassistant/components/scrape/translations/hu.json new file mode 100644 index 00000000000..7af59751b98 --- /dev/null +++ b/homeassistant/components/scrape/translations/hu.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van" + }, + "step": { + "user": { + "data": { + "attribute": "Attrib\u00fatum", + "authentication": "Hiteles\u00edt\u00e9s", + "device_class": "Eszk\u00f6zoszt\u00e1ly", + "headers": "Fejl\u00e9cek", + "index": "Index", + "name": "Elnevez\u00e9s", + "password": "Jelsz\u00f3", + "resource": "Er\u0151forr\u00e1s", + "select": "Kiv\u00e1laszt\u00e1s", + "state_class": "\u00c1llapotoszt\u00e1ly", + "unit_of_measurement": "M\u00e9rt\u00e9kegys\u00e9g", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "value_template": "\u00c9rt\u00e9ksablon", + "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" + }, + "data_description": { + "attribute": "Egy attrib\u00fatum \u00e9rt\u00e9k\u00e9nek lek\u00e9r\u00e9se a kiv\u00e1lasztott c\u00edmk\u00e9n", + "authentication": "A HTTP-hiteles\u00edt\u00e9s t\u00edpusa. Basic vagy digest", + "device_class": "Az \u00e9rz\u00e9kel\u0151 t\u00edpusa/oszt\u00e1lya az ikonnak a kezl\u0151fel\u00fcleten val\u00f3 be\u00e1ll\u00edt\u00e1s\u00e1hoz", + "headers": "A webes k\u00e9r\u00e9shez haszn\u00e1land\u00f3 fejl\u00e9cek", + "index": "Meghat\u00e1rozza, hogy a CSS-v\u00e1laszt\u00f3 \u00e1ltal visszaadott elemek k\u00f6z\u00fcl melyiket haszn\u00e1lja.", + "resource": "Az \u00e9rt\u00e9ket tartalmaz\u00f3 weboldal URL c\u00edme", + "select": "Meghat\u00e1rozza, hogy milyen c\u00edmk\u00e9t keressen. N\u00e9zze meg a Beautifulsoup CSS szelektorokat a r\u00e9szletek\u00e9rt", + "state_class": "Az \u00e9rz\u00e9kel\u0151 \u00e1llapot oszt\u00e1lya", + "value_template": "Meghat\u00e1roz egy sablont az \u00e9rz\u00e9kel\u0151 \u00e1llapot\u00e1nak lek\u00e9rdez\u00e9s\u00e9re.", + "verify_ssl": "Az SSL/TLS tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9s\u00e9nek enged\u00e9lyez\u00e9se/letilt\u00e1sa, p\u00e9ld\u00e1ul ha saj\u00e1t al\u00e1\u00edr\u00e1s\u00fa." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attrib\u00fatum", + "authentication": "Hiteles\u00edt\u00e9s", + "device_class": "Eszk\u00f6zoszt\u00e1ly", + "headers": "Fejl\u00e9cek", + "index": "Index", + "name": "Elnevez\u00e9s", + "password": "Jelsz\u00f3", + "resource": "Er\u0151forr\u00e1s", + "select": "Kiv\u00e1laszt\u00e1s", + "state_class": "\u00c1llapotoszt\u00e1ly", + "unit_of_measurement": "M\u00e9rt\u00e9kegys\u00e9g", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v", + "value_template": "\u00c9rt\u00e9ksablon", + "verify_ssl": "SSL-tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9se" + }, + "data_description": { + "attribute": "Egy attrib\u00fatum \u00e9rt\u00e9k\u00e9nek lek\u00e9r\u00e9se a kiv\u00e1lasztott c\u00edmk\u00e9n", + "authentication": "A HTTP-hiteles\u00edt\u00e9s t\u00edpusa. Basic vagy digest", + "device_class": "Az \u00e9rz\u00e9kel\u0151 t\u00edpusa/oszt\u00e1lya az ikonnak a kezl\u0151fel\u00fcleten val\u00f3 be\u00e1ll\u00edt\u00e1s\u00e1hoz", + "headers": "A webes k\u00e9r\u00e9shez haszn\u00e1land\u00f3 fejl\u00e9cek", + "index": "Meghat\u00e1rozza, hogy a CSS-v\u00e1laszt\u00f3 \u00e1ltal visszaadott elemek k\u00f6z\u00fcl melyiket haszn\u00e1lja.", + "resource": "Az \u00e9rt\u00e9ket tartalmaz\u00f3 weboldal URL c\u00edme", + "select": "Meghat\u00e1rozza, hogy milyen c\u00edmk\u00e9t keressen. N\u00e9zze meg a Beautifulsoup CSS szelektorokat a r\u00e9szletek\u00e9rt", + "state_class": "Az \u00e9rz\u00e9kel\u0151 \u00e1llapot oszt\u00e1lya", + "value_template": "Meghat\u00e1roz egy sablont az \u00e9rz\u00e9kel\u0151 \u00e1llapot\u00e1nak lek\u00e9rdez\u00e9s\u00e9re.", + "verify_ssl": "Az SSL/TLS tan\u00fas\u00edtv\u00e1ny ellen\u0151rz\u00e9s\u00e9nek enged\u00e9lyez\u00e9se/letilt\u00e1sa, p\u00e9ld\u00e1ul ha saj\u00e1t al\u00e1\u00edr\u00e1s\u00fa." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/id.json b/homeassistant/components/scrape/translations/id.json index d83b07ac5c3..b6cecd6007c 100644 --- a/homeassistant/components/scrape/translations/id.json +++ b/homeassistant/components/scrape/translations/id.json @@ -6,7 +6,21 @@ "step": { "user": { "data": { - "resource": "Sumber daya" + "attribute": "Atribut", + "authentication": "Autentikasi", + "device_class": "Kelas Perangkat", + "headers": "Header", + "password": "Kata Sandi", + "resource": "Sumber daya", + "select": "Pilihan", + "unit_of_measurement": "Satuan Pengukuran", + "value_template": "T" + }, + "data_description": { + "attribute": "Dapatkan nilai atribut pada tag yang dipilih", + "authentication": "Jenis autentikasi HTTP. Salah satu dari basic atau digest", + "device_class": "Jenis/kelas sensor untuk mengatur ikon di antarmuka", + "verify_ssl": "Mengaktifkan/menonaktifkan verifikasi sertifikat SSL/TLS, misalnya jika sertifikat ditandatangani sendiri" } } } @@ -15,7 +29,11 @@ "step": { "init": { "data": { - "resource": "Sumber daya" + "resource": "Sumber daya", + "unit_of_measurement": "Satuan Pengukuran" + }, + "data_description": { + "attribute": "Dapatkan nilai atribut pada tag yang dipilih" } } } diff --git a/homeassistant/components/scrape/translations/ja.json b/homeassistant/components/scrape/translations/ja.json new file mode 100644 index 00000000000..3159fa65739 --- /dev/null +++ b/homeassistant/components/scrape/translations/ja.json @@ -0,0 +1,58 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "step": { + "user": { + "data": { + "attribute": "\u5c5e\u6027", + "authentication": "\u8a8d\u8a3c", + "device_class": "\u30c7\u30d0\u30a4\u30b9\u30af\u30e9\u30b9", + "headers": "\u30d8\u30c3\u30c0\u30fc", + "index": "\u30a4\u30f3\u30c7\u30c3\u30af\u30b9", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "resource": "\u30ea\u30bd\u30fc\u30b9", + "select": "\u9078\u629e", + "state_class": "\u72b6\u614b\u30af\u30e9\u30b9(State Class)", + "unit_of_measurement": "\u6e2c\u5b9a\u306e\u5358\u4f4d", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "value_template": "\u5024\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "data_description": { + "headers": "Web\u30ea\u30af\u30a8\u30b9\u30c8\u306b\u4f7f\u7528\u3059\u308b\u30d8\u30c3\u30c0\u30fc", + "select": "\u691c\u7d22\u3059\u308b\u30bf\u30b0\u3092\u5b9a\u7fa9\u3057\u307e\u3059\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001Beautifulsoup CSS selectors\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u5c5e\u6027", + "authentication": "\u8a8d\u8a3c", + "device_class": "\u30c7\u30d0\u30a4\u30b9\u30af\u30e9\u30b9", + "headers": "\u30d8\u30c3\u30c0\u30fc", + "index": "\u30a4\u30f3\u30c7\u30c3\u30af\u30b9", + "name": "\u540d\u524d", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "resource": "\u30ea\u30bd\u30fc\u30b9", + "select": "\u9078\u629e", + "state_class": "\u72b6\u614b\u30af\u30e9\u30b9(State Class)", + "unit_of_measurement": "\u6e2c\u5b9a\u306e\u5358\u4f4d", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d", + "value_template": "\u5024\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8", + "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" + }, + "data_description": { + "headers": "Web\u30ea\u30af\u30a8\u30b9\u30c8\u306b\u4f7f\u7528\u3059\u308b\u30d8\u30c3\u30c0\u30fc", + "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/zh-Hant.json b/homeassistant/components/scrape/translations/zh-Hant.json new file mode 100644 index 00000000000..499ca44d334 --- /dev/null +++ b/homeassistant/components/scrape/translations/zh-Hant.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "attribute": "\u5c6c\u6027", + "authentication": "\u9a57\u8b49", + "device_class": "\u88dd\u7f6e\u985e\u5225", + "headers": "Headers", + "index": "\u6307\u6578", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "resource": "\u4f86\u6e90", + "select": "\u9078\u64c7", + "state_class": "\u72c0\u614b\u985e\u5225", + "unit_of_measurement": "\u6e2c\u91cf\u55ae\u4f4d", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "value_template": "\u6578\u503c\u6a21\u677f", + "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" + }, + "data_description": { + "attribute": "\u7372\u53d6\u6240\u9078\u6a19\u7c64\u5c6c\u6027\u6578\u503c", + "authentication": "HTTP \u9a57\u8b49\u985e\u578b\u3002\u57fa\u672c\u6216\u6458\u8981", + "device_class": "\u65bc Frontend \u4e2d\u8a2d\u5b9a\u4e4b\u50b3\u611f\u5668\u985e\u578b/\u985e\u5225\u5716\u793a", + "headers": "\u7528\u65bc Web \u8acb\u6c42\u4e4b Headers", + "index": "\u5b9a\u7fa9\u4f7f\u7528 CSS selector \u56de\u8986\u5143\u7d20", + "resource": "\u5305\u542b\u6578\u503c\u7684\u7db2\u7ad9 URL", + "select": "\u5b9a\u7fa9\u8981\u7d22\u7684\u6a19\u7c64\u3002\u53c3\u95b1 Beautifulsoup CSS selector \u4ee5\u7372\u5f97\u8a73\u7d30\u8cc7\u8a0a", + "state_class": "\u611f\u6e2c\u5668 state_class", + "value_template": "\u5b9a\u7fa9\u6a21\u677f\u4ee5\u53d6\u5f97\u611f\u6e2c\u5668\u72c0\u614b", + "verify_ssl": "\u958b\u555f/\u95dc\u9589 SSL/TLS \u9a57\u8b49\u8a8d\u8b49\uff0c\u4f8b\u5982\u81ea\u7c3d\u7ae0\u6191\u8b49" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u5c6c\u6027", + "authentication": "\u9a57\u8b49", + "device_class": "\u88dd\u7f6e\u985e\u5225", + "headers": "Headers", + "index": "\u6307\u6578", + "name": "\u540d\u7a31", + "password": "\u5bc6\u78bc", + "resource": "\u4f86\u6e90", + "select": "\u9078\u64c7", + "state_class": "\u72c0\u614b\u985e\u5225", + "unit_of_measurement": "\u6e2c\u91cf\u55ae\u4f4d", + "username": "\u4f7f\u7528\u8005\u540d\u7a31", + "value_template": "\u6578\u503c\u6a21\u677f", + "verify_ssl": "\u78ba\u8a8d SSL \u8a8d\u8b49" + }, + "data_description": { + "attribute": "\u7372\u53d6\u6240\u9078\u6a19\u7c64\u5c6c\u6027\u6578\u503c", + "authentication": "HTTP \u9a57\u8b49\u985e\u578b\u3002\u57fa\u672c\u6216\u6458\u8981", + "device_class": "\u65bc Frontend \u4e2d\u8a2d\u5b9a\u4e4b\u50b3\u611f\u5668\u985e\u578b/\u985e\u5225\u5716\u793a", + "headers": "\u7528\u65bc Web \u8acb\u6c42\u4e4b Headers", + "index": "\u5b9a\u7fa9\u4f7f\u7528 CSS selector \u56de\u8986\u5143\u7d20", + "resource": "\u5305\u542b\u6578\u503c\u7684\u7db2\u7ad9 URL", + "select": "\u5b9a\u7fa9\u8981\u7d22\u7684\u6a19\u7c64\u3002\u53c3\u95b1 Beautifulsoup CSS selector \u4ee5\u7372\u5f97\u8a73\u7d30\u8cc7\u8a0a", + "state_class": "\u611f\u6e2c\u5668 state_class", + "value_template": "\u5b9a\u7fa9\u6a21\u677f\u4ee5\u53d6\u5f97\u611f\u6e2c\u5668\u72c0\u614b", + "verify_ssl": "\u958b\u555f/\u95dc\u9589 SSL/TLS \u9a57\u8b49\u8a8d\u8b49\uff0c\u4f8b\u5982\u81ea\u7c3d\u7ae0\u6191\u8b49" + } + } + } + } +} \ No newline at end of file From bc22e79c7b034df3d1779038f67042ba1a053b1f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jun 2022 15:43:23 -1000 Subject: [PATCH 230/947] Add a test for a complex entity filter (#73005) --- tests/helpers/test_entityfilter.py | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 9576c7d95b6..043fb44a95a 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -303,3 +303,66 @@ def test_exlictly_included(): assert not filt.explicitly_excluded("switch.other") assert filt.explicitly_excluded("sensor.weather_5") assert filt.explicitly_excluded("light.kitchen") + + +def test_complex_include_exclude_filter(): + """Test a complex include exclude filter.""" + conf = { + "include": { + "domains": ["switch", "person"], + "entities": ["group.family"], + "entity_globs": [ + "sensor.*_sensor_temperature", + "sensor.*_actueel", + "sensor.*_totaal", + "sensor.calculated*", + "sensor.solaredge_*", + "sensor.speedtest*", + "sensor.teller*", + "sensor.zp*", + "binary_sensor.*_sensor_motion", + "binary_sensor.*_door", + "sensor.water_*ly", + "sensor.gas_*ly", + ], + }, + "exclude": { + "domains": [ + "alarm_control_panel", + "alert", + "automation", + "button", + "camera", + "climate", + "counter", + "cover", + "geo_location", + "group", + "input_boolean", + "input_datetime", + "input_number", + "input_select", + "input_text", + "light", + "media_player", + "number", + "proximity", + "remote", + "scene", + "script", + "sun", + "timer", + "updater", + "variable", + "weather", + "zone", + ], + "entities": [ + "sensor.solaredge_last_updatetime", + "sensor.solaredge_last_changed", + ], + "entity_globs": ["switch.*_light_level", "switch.sonos_*"], + }, + } + filt: EntityFilter = INCLUDE_EXCLUDE_FILTER_SCHEMA(conf) + assert filt("switch.espresso_keuken") is True From e98a6413768ad45bec59be633d8d4b734d8f77f8 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 4 Jun 2022 21:50:38 -0400 Subject: [PATCH 231/947] Refactor goalzero (#72398) --- homeassistant/components/goalzero/__init__.py | 85 ++----------------- .../components/goalzero/binary_sensor.py | 35 ++------ homeassistant/components/goalzero/const.py | 10 +-- .../components/goalzero/coordinator.py | 34 ++++++++ homeassistant/components/goalzero/entity.py | 48 +++++++++++ homeassistant/components/goalzero/sensor.py | 40 ++------- homeassistant/components/goalzero/switch.py | 39 ++------- 7 files changed, 116 insertions(+), 175 deletions(-) create mode 100644 homeassistant/components/goalzero/coordinator.py create mode 100644 homeassistant/components/goalzero/entity.py diff --git a/homeassistant/components/goalzero/__init__.py b/homeassistant/components/goalzero/__init__.py index e014c4780ad..ea292a651c2 100644 --- a/homeassistant/components/goalzero/__init__.py +++ b/homeassistant/components/goalzero/__init__.py @@ -1,70 +1,31 @@ """The Goal Zero Yeti integration.""" from __future__ import annotations -import logging - from goalzero import Yeti, exceptions from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODEL, CONF_HOST, CONF_NAME, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) - -from .const import ( - ATTRIBUTION, - DATA_KEY_API, - DATA_KEY_COORDINATOR, - DOMAIN, - MANUFACTURER, - MIN_TIME_BETWEEN_UPDATES, -) - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN +from .coordinator import GoalZeroDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Goal Zero Yeti from a config entry.""" - name = entry.data[CONF_NAME] - host = entry.data[CONF_HOST] - - api = Yeti(host, async_get_clientsession(hass)) + api = Yeti(entry.data[CONF_HOST], async_get_clientsession(hass)) try: await api.init_connect() except exceptions.ConnectError as ex: raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex - async def async_update_data() -> None: - """Fetch data from API endpoint.""" - try: - await api.get_state() - except exceptions.ConnectError as err: - raise UpdateFailed("Failed to communicate with device") from err - - coordinator = DataUpdateCoordinator( - hass, - _LOGGER, - name=name, - update_method=async_update_data, - update_interval=MIN_TIME_BETWEEN_UPDATES, - ) + coordinator = GoalZeroDataUpdateCoordinator(hass, api) await coordinator.async_config_entry_first_refresh() - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - DATA_KEY_API: api, - DATA_KEY_COORDINATOR: coordinator, - } - + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -72,38 +33,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class YetiEntity(CoordinatorEntity): - """Representation of a Goal Zero Yeti entity.""" - - _attr_attribution = ATTRIBUTION - - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti entity.""" - super().__init__(coordinator) - self.api = api - self._name = name - self._server_unique_id = server_unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information of the entity.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.api.sysdata["macAddress"])}, - identifiers={(DOMAIN, self._server_unique_id)}, - manufacturer=MANUFACTURER, - model=self.api.sysdata[ATTR_MODEL], - name=self._name, - sw_version=self.api.data["firmwareVersion"], - ) diff --git a/homeassistant/components/goalzero/binary_sensor.py b/homeassistant/components/goalzero/binary_sensor.py index 56d812c5923..c4219b51e6c 100644 --- a/homeassistant/components/goalzero/binary_sensor.py +++ b/homeassistant/components/goalzero/binary_sensor.py @@ -3,22 +3,18 @@ from __future__ import annotations from typing import cast -from goalzero import Yeti - from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from .const import DOMAIN +from .entity import GoalZeroEntity PARALLEL_UPDATES = 0 @@ -51,38 +47,19 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Goal Zero Yeti sensor.""" - name = entry.data[CONF_NAME] - goalzero_data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - YetiBinarySensor( - goalzero_data[DATA_KEY_API], - goalzero_data[DATA_KEY_COORDINATOR], - name, + GoalZeroBinarySensor( + hass.data[DOMAIN][entry.entry_id], description, - entry.entry_id, ) for description in BINARY_SENSOR_TYPES ) -class YetiBinarySensor(YetiEntity, BinarySensorEntity): +class GoalZeroBinarySensor(GoalZeroEntity, BinarySensorEntity): """Representation of a Goal Zero Yeti sensor.""" - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - description: BinarySensorEntityDescription, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti sensor.""" - super().__init__(api, coordinator, name, server_unique_id) - self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{server_unique_id}/{description.key}" - @property def is_on(self) -> bool: """Return True if the service is on.""" - return cast(bool, self.api.data[self.entity_description.key] == 1) + return cast(bool, self._api.data[self.entity_description.key] == 1) diff --git a/homeassistant/components/goalzero/const.py b/homeassistant/components/goalzero/const.py index fef1636005d..280a70abbf1 100644 --- a/homeassistant/components/goalzero/const.py +++ b/homeassistant/components/goalzero/const.py @@ -1,12 +1,12 @@ """Constants for the Goal Zero Yeti integration.""" -from datetime import timedelta +import logging +from typing import Final ATTRIBUTION = "Data provided by Goal Zero" ATTR_DEFAULT_ENABLED = "default_enabled" -DATA_KEY_COORDINATOR = "coordinator" -DOMAIN = "goalzero" +DOMAIN: Final = "goalzero" DEFAULT_NAME = "Yeti" -DATA_KEY_API = "api" MANUFACTURER = "Goal Zero" -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + +LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/goalzero/coordinator.py b/homeassistant/components/goalzero/coordinator.py new file mode 100644 index 00000000000..416b420f29d --- /dev/null +++ b/homeassistant/components/goalzero/coordinator.py @@ -0,0 +1,34 @@ +"""Data update coordinator for the Goal zero integration.""" + +from datetime import timedelta + +from goalzero import Yeti, exceptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class GoalZeroDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Goal zero integration.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, api: Yeti) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=30), + ) + self.api = api + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + try: + await self.api.get_state() + except exceptions.ConnectError as err: + raise UpdateFailed("Failed to communicate with device") from err diff --git a/homeassistant/components/goalzero/entity.py b/homeassistant/components/goalzero/entity.py new file mode 100644 index 00000000000..fa0d55b0d5f --- /dev/null +++ b/homeassistant/components/goalzero/entity.py @@ -0,0 +1,48 @@ +"""Entity representing a Goal Zero Yeti device.""" + +from goalzero import Yeti + +from homeassistant.const import ATTR_MODEL, CONF_NAME +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER +from .coordinator import GoalZeroDataUpdateCoordinator + + +class GoalZeroEntity(CoordinatorEntity[GoalZeroDataUpdateCoordinator]): + """Representation of a Goal Zero Yeti entity.""" + + _attr_attribution = ATTRIBUTION + + def __init__( + self, + coordinator: GoalZeroDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize a Goal Zero Yeti entity.""" + super().__init__(coordinator) + self.coordinator = coordinator + self.entity_description = description + self._attr_name = ( + f"{coordinator.config_entry.data[CONF_NAME]} {description.name}" + ) + self._attr_unique_id = f"{coordinator.config_entry.entry_id}/{description.key}" + + @property + def device_info(self) -> DeviceInfo: + """Return the device information of the entity.""" + return DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._api.sysdata["macAddress"])}, + identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + manufacturer=MANUFACTURER, + model=self._api.sysdata[ATTR_MODEL], + name=self.coordinator.config_entry.data[CONF_NAME], + sw_version=self._api.data["firmwareVersion"], + ) + + @property + def _api(self) -> Yeti: + """Return api from coordinator.""" + return self.coordinator.api diff --git a/homeassistant/components/goalzero/sensor.py b/homeassistant/components/goalzero/sensor.py index 464cd7e5f31..ef95578820d 100644 --- a/homeassistant/components/goalzero/sensor.py +++ b/homeassistant/components/goalzero/sensor.py @@ -3,8 +3,6 @@ from __future__ import annotations from typing import cast -from goalzero import Yeti - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -13,7 +11,6 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - CONF_NAME, ELECTRIC_CURRENT_AMPERE, ELECTRIC_POTENTIAL_VOLT, ENERGY_WATT_HOUR, @@ -28,10 +25,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from .const import DOMAIN +from .entity import GoalZeroEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -139,39 +135,19 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Goal Zero Yeti sensor.""" - name = entry.data[CONF_NAME] - goalzero_data = hass.data[DOMAIN][entry.entry_id] - sensors = [ - YetiSensor( - goalzero_data[DATA_KEY_API], - goalzero_data[DATA_KEY_COORDINATOR], - name, + async_add_entities( + GoalZeroSensor( + hass.data[DOMAIN][entry.entry_id], description, - entry.entry_id, ) for description in SENSOR_TYPES - ] - async_add_entities(sensors, True) + ) -class YetiSensor(YetiEntity, SensorEntity): +class GoalZeroSensor(GoalZeroEntity, SensorEntity): """Representation of a Goal Zero Yeti sensor.""" - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - description: SensorEntityDescription, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti sensor.""" - super().__init__(api, coordinator, name, server_unique_id) - self._attr_name = f"{name} {description.name}" - self.entity_description = description - self._attr_unique_id = f"{server_unique_id}/{description.key}" - @property def native_value(self) -> StateType: """Return the state.""" - return cast(StateType, self.api.data[self.entity_description.key]) + return cast(StateType, self._api.data[self.entity_description.key]) diff --git a/homeassistant/components/goalzero/switch.py b/homeassistant/components/goalzero/switch.py index b45e3b0f89a..9a58cb385b6 100644 --- a/homeassistant/components/goalzero/switch.py +++ b/homeassistant/components/goalzero/switch.py @@ -3,17 +3,13 @@ from __future__ import annotations from typing import Any, cast -from goalzero import Yeti - from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import YetiEntity -from .const import DATA_KEY_API, DATA_KEY_COORDINATOR, DOMAIN +from .const import DOMAIN +from .entity import GoalZeroEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -35,50 +31,31 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Goal Zero Yeti switch.""" - name = entry.data[CONF_NAME] - goalzero_data = hass.data[DOMAIN][entry.entry_id] async_add_entities( - YetiSwitch( - goalzero_data[DATA_KEY_API], - goalzero_data[DATA_KEY_COORDINATOR], - name, + GoalZeroSwitch( + hass.data[DOMAIN][entry.entry_id], description, - entry.entry_id, ) for description in SWITCH_TYPES ) -class YetiSwitch(YetiEntity, SwitchEntity): +class GoalZeroSwitch(GoalZeroEntity, SwitchEntity): """Representation of a Goal Zero Yeti switch.""" - def __init__( - self, - api: Yeti, - coordinator: DataUpdateCoordinator, - name: str, - description: SwitchEntityDescription, - server_unique_id: str, - ) -> None: - """Initialize a Goal Zero Yeti switch.""" - super().__init__(api, coordinator, name, server_unique_id) - self.entity_description = description - self._attr_name = f"{name} {description.name}" - self._attr_unique_id = f"{server_unique_id}/{description.key}" - @property def is_on(self) -> bool: """Return state of the switch.""" - return cast(bool, self.api.data[self.entity_description.key] == 1) + return cast(bool, self._api.data[self.entity_description.key] == 1) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" payload = {self.entity_description.key: 0} - await self.api.post_state(payload=payload) + await self._api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" payload = {self.entity_description.key: 1} - await self.api.post_state(payload=payload) + await self._api.post_state(payload=payload) self.coordinator.async_set_updated_data(data=payload) From cbea919c3ddd3fd3ad27fc10b5b42ab2e33d0f31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jun 2022 16:34:04 -1000 Subject: [PATCH 232/947] Bump aiolookup to 0.1.1 (#73048) --- homeassistant/components/lookin/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lookin/manifest.json b/homeassistant/components/lookin/manifest.json index 7cf70540372..b58eb254f8f 100644 --- a/homeassistant/components/lookin/manifest.json +++ b/homeassistant/components/lookin/manifest.json @@ -3,7 +3,7 @@ "name": "LOOKin", "documentation": "https://www.home-assistant.io/integrations/lookin/", "codeowners": ["@ANMalko", "@bdraco"], - "requirements": ["aiolookin==0.1.0"], + "requirements": ["aiolookin==0.1.1"], "zeroconf": ["_lookin._tcp.local."], "config_flow": true, "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index ce727d0e31c..6b8563f0510 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -187,7 +187,7 @@ aiolifx==0.8.1 aiolifx_effects==0.2.2 # homeassistant.components.lookin -aiolookin==0.1.0 +aiolookin==0.1.1 # homeassistant.components.lyric aiolyric==1.0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d265ec19a3e..6cfd2352b8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -159,7 +159,7 @@ aiohue==4.4.1 aiokafka==0.6.0 # homeassistant.components.lookin -aiolookin==0.1.0 +aiolookin==0.1.1 # homeassistant.components.lyric aiolyric==1.0.8 From a502a8798ff74eb6185473df7f69553fc4663634 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 4 Jun 2022 22:37:08 -0400 Subject: [PATCH 233/947] Add config flow to skybell (#70887) --- .coveragerc | 9 +- CODEOWNERS | 2 + homeassistant/components/skybell/__init__.py | 174 ++++++++++-------- .../components/skybell/binary_sensor.py | 89 ++++----- homeassistant/components/skybell/camera.py | 112 ++++------- .../components/skybell/config_flow.py | 76 ++++++++ homeassistant/components/skybell/const.py | 14 ++ .../components/skybell/coordinator.py | 34 ++++ homeassistant/components/skybell/entity.py | 65 +++++++ homeassistant/components/skybell/light.py | 89 ++++----- .../components/skybell/manifest.json | 7 +- homeassistant/components/skybell/sensor.py | 59 ++---- homeassistant/components/skybell/strings.json | 21 +++ homeassistant/components/skybell/switch.py | 64 +++---- .../components/skybell/translations/en.json | 21 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 6 +- requirements_test_all.txt | 3 + tests/components/skybell/__init__.py | 30 +++ tests/components/skybell/test_config_flow.py | 137 ++++++++++++++ 20 files changed, 664 insertions(+), 349 deletions(-) create mode 100644 homeassistant/components/skybell/config_flow.py create mode 100644 homeassistant/components/skybell/const.py create mode 100644 homeassistant/components/skybell/coordinator.py create mode 100644 homeassistant/components/skybell/entity.py create mode 100644 homeassistant/components/skybell/strings.json create mode 100644 homeassistant/components/skybell/translations/en.json create mode 100644 tests/components/skybell/__init__.py create mode 100644 tests/components/skybell/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 204353ffe87..4cbddd14601 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1064,7 +1064,14 @@ omit = homeassistant/components/sisyphus/* homeassistant/components/sky_hub/* homeassistant/components/skybeacon/sensor.py - homeassistant/components/skybell/* + homeassistant/components/skybell/__init__.py + homeassistant/components/skybell/binary_sensor.py + homeassistant/components/skybell/camera.py + homeassistant/components/skybell/coordinator.py + homeassistant/components/skybell/entity.py + homeassistant/components/skybell/light.py + homeassistant/components/skybell/sensor.py + homeassistant/components/skybell/switch.py homeassistant/components/slack/__init__.py homeassistant/components/slack/notify.py homeassistant/components/sia/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index e2e9fc27b5c..59f3671c475 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -933,6 +933,8 @@ build.json @home-assistant/supervisor /tests/components/siren/ @home-assistant/core @raman325 /homeassistant/components/sisyphus/ @jkeljo /homeassistant/components/sky_hub/ @rogerselwyn +/homeassistant/components/skybell/ @tkdrob +/tests/components/skybell/ @tkdrob /homeassistant/components/slack/ @bachya @tkdrob /tests/components/slack/ @bachya @tkdrob /homeassistant/components/sleepiq/ @mfugate1 @kbickar diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 47e22f5b619..00c7a533590 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -1,101 +1,117 @@ """Support for the Skybell HD Doorbell.""" -import logging +from __future__ import annotations -from requests.exceptions import ConnectTimeout, HTTPError -from skybellpy import Skybell +import asyncio +import os + +from aioskybell import Skybell +from aioskybell.exceptions import SkybellAuthenticationException, SkybellException import voluptuous as vol -from homeassistant.components import persistent_notification -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_PASSWORD, - CONF_USERNAME, - __version__, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by Skybell.com" - -NOTIFICATION_ID = "skybell_notification" -NOTIFICATION_TITLE = "Skybell Sensor Setup" - -DOMAIN = "skybell" -DEFAULT_CACHEDB = "./skybell_cache.pickle" -DEFAULT_ENTITY_NAMESPACE = "skybell" - -AGENT_IDENTIFIER = f"HomeAssistant/{__version__}" +from .const import DEFAULT_CACHEDB, DOMAIN +from .coordinator import SkybellDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, + vol.All( + # Deprecated in Home Assistant 2022.6 + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + ), extra=vol.ALLOW_EXTRA, ) +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.CAMERA, + Platform.LIGHT, + Platform.SENSOR, + Platform.SWITCH, +] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Skybell component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - try: - cache = hass.config.path(DEFAULT_CACHEDB) - skybell = Skybell( - username=username, - password=password, - get_devices=True, - cache_path=cache, - agent_identifier=AGENT_IDENTIFIER, +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the SkyBell component.""" + hass.data.setdefault(DOMAIN, {}) + + entry_config = {} + if DOMAIN not in config: + return True + for parameter, value in config[DOMAIN].items(): + if parameter == CONF_USERNAME: + entry_config[CONF_EMAIL] = value + else: + entry_config[parameter] = value + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=entry_config, + ) ) - hass.data[DOMAIN] = skybell - except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Skybell service: %s", str(ex)) - persistent_notification.create( - hass, - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - return False + # Clean up unused cache file since we are using an account specific name + # Remove with import + def clean_cache(): + """Clean old cache filename.""" + if os.path.exists(hass.config.path(DEFAULT_CACHEDB)): + os.remove(hass.config.path(DEFAULT_CACHEDB)) + + await hass.async_add_executor_job(clean_cache) + return True -class SkybellDevice(Entity): - """A HA implementation for Skybell devices.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Skybell from a config entry.""" + email = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] - def __init__(self, device): - """Initialize a sensor for Skybell device.""" - self._device = device + api = Skybell( + username=email, + password=password, + get_devices=True, + cache_path=hass.config.path(f"./skybell_{entry.unique_id}.pickle"), + session=async_get_clientsession(hass), + ) + try: + devices = await api.async_initialize() + except SkybellAuthenticationException: + return False + except SkybellException as ex: + raise ConfigEntryNotReady(f"Unable to connect to Skybell service: {ex}") from ex - def update(self): - """Update automation state.""" - self._device.refresh() + device_coordinators: list[SkybellDataUpdateCoordinator] = [ + SkybellDataUpdateCoordinator(hass, device) for device in devices + ] + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in device_coordinators + ] + ) + hass.data[DOMAIN][entry.entry_id] = device_coordinators + hass.config_entries.async_setup_platforms(entry, PLATFORMS) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return { - ATTR_ATTRIBUTION: ATTRIBUTION, - "device_id": self._device.device_id, - "status": self._device.status, - "location": self._device.location, - "wifi_ssid": self._device.wifi_ssid, - "wifi_status": self._device.wifi_status, - "last_check_in": self._device.last_check_in, - "motion_threshold": self._device.motion_threshold, - "video_profile": self._device.video_profile, - } + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index bf8ffcfce9d..dcb5466e479 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,9 +1,7 @@ """Binary sensor support for the Skybell HD Doorbell.""" from __future__ import annotations -from datetime import timedelta -from typing import Any - +from aioskybell.helpers import const as CONST import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -12,36 +10,33 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from . import DOMAIN +from .coordinator import SkybellDataUpdateCoordinator +from .entity import SkybellEntity -SCAN_INTERVAL = timedelta(seconds=10) - - -BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { - "button": BinarySensorEntityDescription( - key="device:sensor:button", +BINARY_SENSOR_TYPES: tuple[BinarySensorEntityDescription, ...] = ( + BinarySensorEntityDescription( + key="button", name="Button", device_class=BinarySensorDeviceClass.OCCUPANCY, ), - "motion": BinarySensorEntityDescription( - key="device:sensor:motion", + BinarySensorEntityDescription( + key="motion", name="Motion", device_class=BinarySensorDeviceClass.MOTION, ), -} - +) +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( cv.ensure_list, [vol.In(BINARY_SENSOR_TYPES)] ), @@ -49,53 +44,41 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - binary_sensors = [ - SkybellBinarySensor(device, BINARY_SENSOR_TYPES[sensor_type]) - for device in skybell.get_devices() - for sensor_type in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(binary_sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellBinarySensor(coordinator, sensor) + for sensor in BINARY_SENSOR_TYPES + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -class SkybellBinarySensor(SkybellDevice, BinarySensorEntity): +class SkybellBinarySensor(SkybellEntity, BinarySensorEntity): """A binary sensor implementation for Skybell devices.""" def __init__( self, - device, + coordinator: SkybellDataUpdateCoordinator, description: BinarySensorEntityDescription, - ): + ) -> None: """Initialize a binary sensor for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - self._event: dict[Any, Any] = {} + super().__init__(coordinator, description) + self._event: dict[str, str] = {} @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str | int | tuple[str, str]]: """Return the state attributes.""" attrs = super().extra_state_attributes - - attrs["event_date"] = self._event.get("createdAt") - + if event := self._event.get(CONST.CREATED_AT): + attrs["event_date"] = event return attrs - def update(self): - """Get the latest data and updates the state.""" - super().update() - + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" event = self._device.latest(self.entity_description.key) - - self._attr_is_on = bool(event and event.get("id") != self._event.get("id")) - - self._event = event or {} + self._attr_is_on = bool(event.get(CONST.ID) != self._event.get(CONST.ID)) + self._event = event + super()._handle_coordinator_update() diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index 96989fad747..f531e67f2d0 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,31 +1,31 @@ """Camera support for the Skybell HD Doorbell.""" from __future__ import annotations -from datetime import timedelta -import logging - -import requests import voluptuous as vol -from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.components.camera import ( + PLATFORM_SCHEMA, + Camera, + CameraEntityDescription, +) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN as SKYBELL_DOMAIN, SkybellDevice - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=90) - -IMAGE_AVATAR = "avatar" -IMAGE_ACTIVITY = "activity" - -CONF_ACTIVITY_NAME = "activity_name" -CONF_AVATAR_NAME = "avatar_name" +from .const import ( + CONF_ACTIVITY_NAME, + CONF_AVATAR_NAME, + DOMAIN, + IMAGE_ACTIVITY, + IMAGE_AVATAR, +) +from .coordinator import SkybellDataUpdateCoordinator +from .entity import SkybellEntity +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_MONITORED_CONDITIONS, default=[IMAGE_AVATAR]): vol.All( @@ -36,71 +36,37 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +CAMERA_TYPES: tuple[CameraEntityDescription, ...] = ( + CameraEntityDescription(key="activity", name="Last Activity"), + CameraEntityDescription(key="avatar", name="Camera"), +) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - cond = config[CONF_MONITORED_CONDITIONS] - names = {} - names[IMAGE_ACTIVITY] = config.get(CONF_ACTIVITY_NAME) - names[IMAGE_AVATAR] = config.get(CONF_AVATAR_NAME) - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [] - for device in skybell.get_devices(): - for camera_type in cond: - sensors.append(SkybellCamera(device, camera_type, names.get(camera_type))) - - add_entities(sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellCamera(coordinator, description) + for description in CAMERA_TYPES + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -class SkybellCamera(SkybellDevice, Camera): +class SkybellCamera(SkybellEntity, Camera): """A camera implementation for Skybell devices.""" - def __init__(self, device, camera_type, name=None): + def __init__( + self, + coordinator: SkybellDataUpdateCoordinator, + description: EntityDescription, + ) -> None: """Initialize a camera for a Skybell device.""" - self._type = camera_type - SkybellDevice.__init__(self, device) + super().__init__(coordinator, description) Camera.__init__(self) - if name is not None: - self._name = f"{self._device.name} {name}" - else: - self._name = self._device.name - self._url = None - self._response = None - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def image_url(self): - """Get the camera image url based on type.""" - if self._type == IMAGE_ACTIVITY: - return self._device.activity_image - return self._device.image - - def camera_image( + async def async_camera_image( self, width: int | None = None, height: int | None = None ) -> bytes | None: """Get the latest camera image.""" - super().update() - - if self._url != self.image_url: - self._url = self.image_url - - try: - self._response = requests.get(self._url, stream=True, timeout=10) - except requests.HTTPError as err: - _LOGGER.warning("Failed to get camera image: %s", err) - self._response = None - - if not self._response: - return None - - return self._response.content + return self._device.images[self.entity_description.key] diff --git a/homeassistant/components/skybell/config_flow.py b/homeassistant/components/skybell/config_flow.py new file mode 100644 index 00000000000..7b7b43788b3 --- /dev/null +++ b/homeassistant/components/skybell/config_flow.py @@ -0,0 +1,76 @@ +"""Config flow for Skybell integration.""" +from __future__ import annotations + +from typing import Any + +from aioskybell import Skybell, exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import DOMAIN + + +class SkybellFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Skybell.""" + + async def async_step_import(self, user_input: ConfigType) -> FlowResult: + """Import a config entry from configuration.yaml.""" + if self._async_current_entries(): + return self.async_abort(reason="already_configured") + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + + if user_input is not None: + email = user_input[CONF_EMAIL].lower() + password = user_input[CONF_PASSWORD] + + self._async_abort_entries_match({CONF_EMAIL: email}) + user_id, error = await self._async_validate_input(email, password) + if error is None: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=email, + data={CONF_EMAIL: email, CONF_PASSWORD: password}, + ) + errors["base"] = error + + user_input = user_input or {} + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_EMAIL, default=user_input.get(CONF_EMAIL)): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def _async_validate_input(self, email: str, password: str) -> tuple: + """Validate login credentials.""" + skybell = Skybell( + username=email, + password=password, + disable_cache=True, + session=async_get_clientsession(self.hass), + ) + try: + await skybell.async_initialize() + except exceptions.SkybellAuthenticationException: + return None, "invalid_auth" + except exceptions.SkybellException: + return None, "cannot_connect" + except Exception: # pylint: disable=broad-except + return None, "unknown" + return skybell.user_id, None diff --git a/homeassistant/components/skybell/const.py b/homeassistant/components/skybell/const.py new file mode 100644 index 00000000000..d8f7e4992d5 --- /dev/null +++ b/homeassistant/components/skybell/const.py @@ -0,0 +1,14 @@ +"""Constants for the Skybell HD Doorbell.""" +import logging +from typing import Final + +CONF_ACTIVITY_NAME = "activity_name" +CONF_AVATAR_NAME = "avatar_name" +DEFAULT_CACHEDB = "./skybell_cache.pickle" +DEFAULT_NAME = "SkyBell" +DOMAIN: Final = "skybell" + +IMAGE_AVATAR = "avatar" +IMAGE_ACTIVITY = "activity" + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/skybell/coordinator.py b/homeassistant/components/skybell/coordinator.py new file mode 100644 index 00000000000..26545609bd5 --- /dev/null +++ b/homeassistant/components/skybell/coordinator.py @@ -0,0 +1,34 @@ +"""Data update coordinator for the Skybell integration.""" + +from datetime import timedelta + +from aioskybell import SkybellDevice, SkybellException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import LOGGER + + +class SkybellDataUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for the Skybell integration.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, device: SkybellDevice) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=LOGGER, + name=device.name, + update_interval=timedelta(seconds=30), + ) + self.device = device + + async def _async_update_data(self) -> None: + """Fetch data from API endpoint.""" + try: + await self.device.async_update() + except SkybellException as err: + raise UpdateFailed(f"Failed to communicate with device: {err}") from err diff --git a/homeassistant/components/skybell/entity.py b/homeassistant/components/skybell/entity.py new file mode 100644 index 00000000000..cf728cde069 --- /dev/null +++ b/homeassistant/components/skybell/entity.py @@ -0,0 +1,65 @@ +"""Entity representing a Skybell HD Doorbell.""" +from __future__ import annotations + +from aioskybell import SkybellDevice + +from homeassistant.const import ATTR_CONNECTIONS +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo, EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import SkybellDataUpdateCoordinator + + +class SkybellEntity(CoordinatorEntity[SkybellDataUpdateCoordinator]): + """An HA implementation for Skybell entity.""" + + _attr_attribution = "Data provided by Skybell.com" + + def __init__( + self, coordinator: SkybellDataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize a SkyBell entity.""" + super().__init__(coordinator) + self.entity_description = description + if description.name != coordinator.device.name: + self._attr_name = f"{self._device.name} {description.name}" + self._attr_unique_id = f"{self._device.device_id}_{description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device.device_id)}, + manufacturer=DEFAULT_NAME, + model=self._device.type, + name=self._device.name, + sw_version=self._device.firmware_ver, + ) + if self._device.mac: + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, self._device.mac) + } + + @property + def _device(self) -> SkybellDevice: + """Return the device.""" + return self.coordinator.device + + @property + def extra_state_attributes(self) -> dict[str, str | int | tuple[str, str]]: + """Return the state attributes.""" + attr: dict[str, str | int | tuple[str, str]] = { + "device_id": self._device.device_id, + "status": self._device.status, + "location": self._device.location, + "motion_threshold": self._device.motion_threshold, + "video_profile": self._device.video_profile, + } + if self._device.owner: + attr["wifi_ssid"] = self._device.wifi_ssid + attr["wifi_status"] = self._device.wifi_status + attr["last_check_in"] = self._device.last_check_in + return attr + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/skybell/light.py b/homeassistant/components/skybell/light.py index 7fbd1519e26..845be44a34b 100644 --- a/homeassistant/components/skybell/light.py +++ b/homeassistant/components/skybell/light.py @@ -1,82 +1,63 @@ """Light/LED support for the Skybell HD Doorbell.""" from __future__ import annotations +from typing import Any + from homeassistant.components.light import ( ATTR_BRIGHTNESS, - ATTR_HS_COLOR, + ATTR_RGB_COLOR, ColorMode, LightEntity, + LightEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -import homeassistant.util.color as color_util -from . import DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from .const import DOMAIN +from .entity import SkybellEntity -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [] - for device in skybell.get_devices(): - sensors.append(SkybellLight(device)) - - add_entities(sensors, True) + """Set up Skybell switch.""" + async_add_entities( + SkybellLight( + coordinator, + LightEntityDescription( + key=coordinator.device.name, + name=coordinator.device.name, + ), + ) + for coordinator in hass.data[DOMAIN][entry.entry_id] + ) -def _to_skybell_level(level): - """Convert the given Home Assistant light level (0-255) to Skybell (0-100).""" - return int((level * 100) / 255) +class SkybellLight(SkybellEntity, LightEntity): + """A light implementation for Skybell devices.""" + _attr_supported_color_modes = {ColorMode.BRIGHTNESS, ColorMode.RGB} -def _to_hass_level(level): - """Convert the given Skybell (0-100) light level to Home Assistant (0-255).""" - return int((level * 255) / 100) - - -class SkybellLight(SkybellDevice, LightEntity): - """A binary sensor implementation for Skybell devices.""" - - _attr_color_mode = ColorMode.HS - _attr_supported_color_modes = {ColorMode.HS} - - def __init__(self, device): - """Initialize a light for a Skybell device.""" - super().__init__(device) - self._attr_name = device.name - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" - if ATTR_HS_COLOR in kwargs: - rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) - self._device.led_rgb = rgb - elif ATTR_BRIGHTNESS in kwargs: - self._device.led_intensity = _to_skybell_level(kwargs[ATTR_BRIGHTNESS]) - else: - self._device.led_intensity = _to_skybell_level(255) + if ATTR_RGB_COLOR in kwargs: + rgb = kwargs[ATTR_RGB_COLOR] + await self._device.async_set_setting(ATTR_RGB_COLOR, rgb) + if ATTR_BRIGHTNESS in kwargs: + level = int((kwargs.get(ATTR_BRIGHTNESS, 0) * 100) / 255) + await self._device.async_set_setting(ATTR_BRIGHTNESS, level) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the light.""" - self._device.led_intensity = 0 + await self._device.async_set_setting(ATTR_BRIGHTNESS, 0) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._device.led_intensity > 0 @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of the light.""" - return _to_hass_level(self._device.led_intensity) - - @property - def hs_color(self): - """Return the color of the light.""" - return color_util.color_RGB_to_hs(*self._device.led_rgb) + return int((self._device.led_intensity * 255) / 100) diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index ce166179969..335ff2615f8 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -1,9 +1,10 @@ { "domain": "skybell", "name": "SkyBell", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/skybell", - "requirements": ["skybellpy==0.6.3"], - "codeowners": [], + "requirements": ["aioskybell==22.3.0"], + "codeowners": ["@tkdrob"], "iot_class": "cloud_polling", - "loggers": ["skybellpy"] + "loggers": ["aioskybell"] } diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index 5922bb05382..c769570b10c 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -1,8 +1,6 @@ """Sensor support for Skybell Doorbells.""" from __future__ import annotations -from datetime import timedelta - import voluptuous as vol from homeassistant.components.sensor import ( @@ -10,15 +8,13 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice - -SCAN_INTERVAL = timedelta(seconds=30) +from .entity import DOMAIN, SkybellEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -27,14 +23,13 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:bell-ring", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] +MONITORED_CONDITIONS = SENSOR_TYPES +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] ), @@ -42,41 +37,21 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - sensors = [ - SkybellSensor(device, description) - for device in skybell.get_devices() + """Set up Skybell sensor.""" + async_add_entities( + SkybellSensor(coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id] for description in SENSOR_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(sensors, True) + ) -class SkybellSensor(SkybellDevice, SensorEntity): +class SkybellSensor(SkybellEntity, SensorEntity): """A sensor implementation for Skybell devices.""" - def __init__( - self, - device, - description: SensorEntityDescription, - ): - """Initialize a sensor for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - - def update(self): - """Get the latest data and updates the state.""" - super().update() - - if self.entity_description.key == "chime_level": - self._attr_native_value = self._device.outdoor_chime_level + @property + def native_value(self) -> int: + """Return the state of the sensor.""" + return self._device.outdoor_chime_level diff --git a/homeassistant/components/skybell/strings.json b/homeassistant/components/skybell/strings.json new file mode 100644 index 00000000000..e48a75c12bd --- /dev/null +++ b/homeassistant/components/skybell/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index 2873ad2c081..d28369e40b0 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -1,6 +1,8 @@ """Switch support for the Skybell HD Doorbell.""" from __future__ import annotations +from typing import Any, cast + import voluptuous as vol from homeassistant.components.switch import ( @@ -8,13 +10,14 @@ from homeassistant.components.switch import ( SwitchEntity, SwitchEntityDescription, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DEFAULT_ENTITY_NAMESPACE, DOMAIN as SKYBELL_DOMAIN, SkybellDevice +from .const import DOMAIN +from .entity import SkybellEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -26,62 +29,41 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( name="Motion Sensor", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SWITCH_TYPES] - +# Deprecated in Home Assistant 2022.6 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { - vol.Optional( - CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE - ): cv.string, + vol.Optional(CONF_ENTITY_NAMESPACE, default=DOMAIN): cv.string, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All( - cv.ensure_list, [vol.In(MONITORED_CONDITIONS)] + cv.ensure_list, [vol.In(SWITCH_TYPES)] ), } ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up the platform for a Skybell device.""" - skybell = hass.data[SKYBELL_DOMAIN] - - switches = [ - SkybellSwitch(device, description) - for device in skybell.get_devices() + """Set up the SkyBell switch.""" + async_add_entities( + SkybellSwitch(coordinator, description) + for coordinator in hass.data[DOMAIN][entry.entry_id] for description in SWITCH_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(switches, True) + ) -class SkybellSwitch(SkybellDevice, SwitchEntity): +class SkybellSwitch(SkybellEntity, SwitchEntity): """A switch implementation for Skybell devices.""" - def __init__( - self, - device, - description: SwitchEntityDescription, - ): - """Initialize a light for a Skybell device.""" - super().__init__(device) - self.entity_description = description - self._attr_name = f"{self._device.name} {description.name}" - - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" - setattr(self._device, self.entity_description.key, True) + await self._device.async_set_setting(self.entity_description.key, True) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" - setattr(self._device, self.entity_description.key, False) + await self._device.async_set_setting(self.entity_description.key, False) @property - def is_on(self): - """Return true if device is on.""" - return getattr(self._device, self.entity_description.key) + def is_on(self) -> bool: + """Return true if entity is on.""" + return cast(bool, getattr(self._device, self.entity_description.key)) diff --git a/homeassistant/components/skybell/translations/en.json b/homeassistant/components/skybell/translations/en.json new file mode 100644 index 00000000000..b84c4ebc999 --- /dev/null +++ b/homeassistant/components/skybell/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid authentication", + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e1e2938c9ff..06da1eae90c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -311,6 +311,7 @@ FLOWS = { "shopping_list", "sia", "simplisafe", + "skybell", "slack", "sleepiq", "slimproto", diff --git a/requirements_all.txt b/requirements_all.txt index 6b8563f0510..e74f72d75dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -240,6 +240,9 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.skybell +aioskybell==22.3.0 + # homeassistant.components.slimproto aioslimproto==2.0.1 @@ -2173,9 +2176,6 @@ simplisafe-python==2022.05.2 # homeassistant.components.sisyphus sisyphus-control==3.1.2 -# homeassistant.components.skybell -skybellpy==0.6.3 - # homeassistant.components.slack slackclient==2.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6cfd2352b8b..f68ea51ad97 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -209,6 +209,9 @@ aiosenz==1.0.0 # homeassistant.components.shelly aioshelly==2.0.0 +# homeassistant.components.skybell +aioskybell==22.3.0 + # homeassistant.components.slimproto aioslimproto==2.0.1 diff --git a/tests/components/skybell/__init__.py b/tests/components/skybell/__init__.py new file mode 100644 index 00000000000..dd162ed5d80 --- /dev/null +++ b/tests/components/skybell/__init__.py @@ -0,0 +1,30 @@ +"""Tests for the SkyBell integration.""" + +from unittest.mock import AsyncMock, patch + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +USERNAME = "user" +PASSWORD = "password" +USER_ID = "123456789012345678901234" + +CONF_CONFIG_FLOW = { + CONF_EMAIL: USERNAME, + CONF_PASSWORD: PASSWORD, +} + + +def _patch_skybell_devices() -> None: + mocked_skybell = AsyncMock() + mocked_skybell.user_id = USER_ID + return patch( + "homeassistant.components.skybell.config_flow.Skybell.async_get_devices", + return_value=[mocked_skybell], + ) + + +def _patch_skybell() -> None: + return patch( + "homeassistant.components.skybell.config_flow.Skybell.async_send_request", + return_value={"id": USER_ID}, + ) diff --git a/tests/components/skybell/test_config_flow.py b/tests/components/skybell/test_config_flow.py new file mode 100644 index 00000000000..0171a522e50 --- /dev/null +++ b/tests/components/skybell/test_config_flow.py @@ -0,0 +1,137 @@ +"""Test SkyBell config flow.""" +from unittest.mock import patch + +from aioskybell import exceptions + +from homeassistant.components.skybell.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import CONF_CONFIG_FLOW, _patch_skybell, _patch_skybell_devices + +from tests.common import MockConfigEntry + + +def _patch_setup_entry() -> None: + return patch( + "homeassistant.components.skybell.async_setup_entry", + return_value=True, + ) + + +def _patch_setup() -> None: + return patch( + "homeassistant.components.skybell.async_setup", + return_value=True, + ) + + +async def test_flow_user(hass: HomeAssistant) -> None: + """Test that the user step works.""" + with _patch_skybell(), _patch_skybell_devices(), _patch_setup_entry(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == CONF_CONFIG_FLOW + + +async def test_flow_user_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate server.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF_CONFIG_FLOW, + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_cannot_connect(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with _patch_skybell() as skybell_mock: + skybell_mock.side_effect = exceptions.SkybellException(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_credentials(hass: HomeAssistant) -> None: + """Test that invalid credentials throws an error.""" + with patch("homeassistant.components.skybell.Skybell.async_login") as skybell_mock: + skybell_mock.side_effect = exceptions.SkybellAuthenticationException(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: + """Test user initialized flow with unreachable server.""" + with _patch_skybell_devices() as skybell_mock: + skybell_mock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "unknown"} + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test import step.""" + with _patch_skybell(), _patch_skybell_devices(), _patch_setup_entry(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_CONFIG_FLOW, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "user" + assert result["data"] == CONF_CONFIG_FLOW + + +async def test_flow_import_already_configured(hass: HomeAssistant) -> None: + """Test import step already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id="123456789012345678901234", data=CONF_CONFIG_FLOW + ) + + entry.add_to_hass(hass) + + with _patch_skybell(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" From 395d58840ca3ceb057a73cd7ed8220d3710b7386 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Sun, 5 Jun 2022 13:42:21 +1000 Subject: [PATCH 234/947] Add Hunter Douglas Powerview Diagnostics (#72918) Co-authored-by: J. Nick Koston --- .coveragerc | 1 + .../hunterdouglas_powerview/__init__.py | 8 +- .../hunterdouglas_powerview/diagnostics.py | 108 ++++++++++++++++++ .../hunterdouglas_powerview/shade_data.py | 4 + 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/hunterdouglas_powerview/diagnostics.py diff --git a/.coveragerc b/.coveragerc index 4cbddd14601..f4cdfa1c460 100644 --- a/.coveragerc +++ b/.coveragerc @@ -506,6 +506,7 @@ omit = homeassistant/components/hue/light.py homeassistant/components/hunterdouglas_powerview/__init__.py homeassistant/components/hunterdouglas_powerview/coordinator.py + homeassistant/components/hunterdouglas_powerview/diagnostics.py homeassistant/components/hunterdouglas_powerview/cover.py homeassistant/components/hunterdouglas_powerview/entity.py homeassistant/components/hunterdouglas_powerview/scene.py diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 587bf8aef12..88aa5214c9b 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -87,9 +87,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async with async_timeout.timeout(10): shades = Shades(pv_request) - shade_data = async_map_data_by_id( - (await shades.get_resources())[SHADE_DATA] - ) + shade_entries = await shades.get_resources() + shade_data = async_map_data_by_id(shade_entries[SHADE_DATA]) + except HUB_EXCEPTIONS as err: raise ConfigEntryNotReady( f"Connection error to PowerView hub: {hub_address}: {err}" @@ -99,6 +99,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = PowerviewShadeUpdateCoordinator(hass, shades, hub_address) coordinator.async_set_updated_data(PowerviewShadeData()) + # populate raw shade data into the coordinator for diagnostics + coordinator.data.store_group_data(shade_entries[SHADE_DATA]) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { PV_API: pv_request, diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py new file mode 100644 index 00000000000..1b2887c3af2 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -0,0 +1,108 @@ +"""Diagnostics support for Powerview Hunter Douglas.""" +from __future__ import annotations + +from typing import Any + +import attr + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntry + +from .const import ( + COORDINATOR, + DEVICE_INFO, + DEVICE_MAC_ADDRESS, + DEVICE_SERIAL_NUMBER, + DOMAIN, + PV_HUB_ADDRESS, +) +from .coordinator import PowerviewShadeUpdateCoordinator + +REDACT_CONFIG = { + CONF_HOST, + DEVICE_MAC_ADDRESS, + DEVICE_SERIAL_NUMBER, + PV_HUB_ADDRESS, + "configuration_url", +} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + data = _async_get_diagnostics(hass, entry) + device_registry = dr.async_get(hass) + data.update( + device_info=[ + _async_device_as_dict(hass, device) + for device in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + ], + ) + return data + + +async def async_get_device_diagnostics( + hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry +) -> dict[str, Any]: + """Return diagnostics for a device entry.""" + data = _async_get_diagnostics(hass, entry) + data["device_info"] = _async_device_as_dict(hass, device) + # try to match on name to restrict to shade if we can + # otherwise just return all shade data + # shade name is unique in powerview + shade_data = data["shade_data"] + for shade in shade_data: + if shade_data[shade]["name_unicode"] == device.name: + data["shade_data"] = shade_data[shade] + return data + + +@callback +def _async_get_diagnostics( + hass: HomeAssistant, + entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + pv_data = hass.data[DOMAIN][entry.entry_id] + coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR] + shade_data = coordinator.data.get_all_raw_data() + hub_info = async_redact_data(pv_data[DEVICE_INFO], REDACT_CONFIG) + return {"hub_info": hub_info, "shade_data": shade_data} + + +@callback +def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str, Any]: + """Represent a Powerview device as a dictionary.""" + + # Gather information how this device is represented in Home Assistant + entity_registry = er.async_get(hass) + + data = async_redact_data(attr.asdict(device), REDACT_CONFIG) + data["entities"] = [] + entities: list[dict[str, Any]] = data["entities"] + + entries = er.async_entries_for_device( + entity_registry, + device_id=device.id, + include_disabled_entities=True, + ) + + for entity_entry in entries: + state = hass.states.get(entity_entry.entity_id) + state_dict = None + if state: + state_dict = dict(state.as_dict()) + state_dict.pop("context", None) + + entity = attr.asdict(entity_entry) + entity["state"] = state_dict + entities.append(entity) + + return data diff --git a/homeassistant/components/hunterdouglas_powerview/shade_data.py b/homeassistant/components/hunterdouglas_powerview/shade_data.py index 4a7b7be0945..b66024aec7f 100644 --- a/homeassistant/components/hunterdouglas_powerview/shade_data.py +++ b/homeassistant/components/hunterdouglas_powerview/shade_data.py @@ -59,6 +59,10 @@ class PowerviewShadeData: """Get data for the shade.""" return self._group_data_by_id[shade_id] + def get_all_raw_data(self) -> dict[int, dict[str | int, Any]]: + """Get data for all shades.""" + return self._group_data_by_id + def get_shade_positions(self, shade_id: int) -> PowerviewShadePositions: """Get positions for a shade.""" if shade_id not in self.positions: From 41f38f10991dd50dd82aa74f29214f0da9047739 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Sun, 5 Jun 2022 14:14:04 +1000 Subject: [PATCH 235/947] Use constant in powerview diagnostics (#73059) --- .../components/hunterdouglas_powerview/diagnostics.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index 1b2887c3af2..ca6131b2761 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -7,7 +7,7 @@ import attr from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST +from homeassistant.const import ATTR_CONFIGURATION_URL, CONF_HOST from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry @@ -27,7 +27,7 @@ REDACT_CONFIG = { DEVICE_MAC_ADDRESS, DEVICE_SERIAL_NUMBER, PV_HUB_ADDRESS, - "configuration_url", + ATTR_CONFIGURATION_URL, } From aad3253ed18987c0aebe5a5f12b484a1d6a53895 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 5 Jun 2022 06:47:08 +0200 Subject: [PATCH 236/947] Bump pysensibo to 1.0.16 (#73029) --- homeassistant/components/sensibo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index c289322d584..203eb751dcf 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -2,7 +2,7 @@ "domain": "sensibo", "name": "Sensibo", "documentation": "https://www.home-assistant.io/integrations/sensibo", - "requirements": ["pysensibo==1.0.15"], + "requirements": ["pysensibo==1.0.16"], "config_flow": true, "codeowners": ["@andrey-git", "@gjohansson-ST"], "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index e74f72d75dc..9ebbd7ef32b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1795,7 +1795,7 @@ pysaj==0.0.16 pysdcp==1 # homeassistant.components.sensibo -pysensibo==1.0.15 +pysensibo==1.0.16 # homeassistant.components.serial # homeassistant.components.zha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f68ea51ad97..f1e8ecd9621 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1214,7 +1214,7 @@ pyruckus==0.12 pysabnzbd==1.1.1 # homeassistant.components.sensibo -pysensibo==1.0.15 +pysensibo==1.0.16 # homeassistant.components.serial # homeassistant.components.zha From 4c11cc3dbbcae294d3df8acf3c72193fd5ff9eca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 4 Jun 2022 21:32:59 -1000 Subject: [PATCH 237/947] Additional cleanups for emulated_hue (#73004) * Additional cleanups for emulated_hue Followup to https://github.com/home-assistant/core/pull/72663#discussion_r884268731 * split long lines --- .../components/emulated_hue/config.py | 34 ++++---- .../components/emulated_hue/hue_api.py | 87 +++++++++++-------- 2 files changed, 66 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index fce521eee55..c2cf67b43f4 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -156,49 +156,49 @@ class Config: # Google Home return self.numbers.get(number) - def get_entity_name(self, entity: State) -> str: + def get_entity_name(self, state: State) -> str: """Get the name of an entity.""" if ( - entity.entity_id in self.entities - and CONF_ENTITY_NAME in self.entities[entity.entity_id] + state.entity_id in self.entities + and CONF_ENTITY_NAME in self.entities[state.entity_id] ): - return self.entities[entity.entity_id][CONF_ENTITY_NAME] + return self.entities[state.entity_id][CONF_ENTITY_NAME] - return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name) + return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) - def is_entity_exposed(self, entity: State) -> bool: + def is_state_exposed(self, state: State) -> bool: """Cache determine if an entity should be exposed on the emulated bridge.""" - if (exposed := self._exposed_cache.get(entity.entity_id)) is not None: + if (exposed := self._exposed_cache.get(state.entity_id)) is not None: return exposed - exposed = self._is_entity_exposed(entity) - self._exposed_cache[entity.entity_id] = exposed + exposed = self._is_state_exposed(state) + self._exposed_cache[state.entity_id] = exposed return exposed - def filter_exposed_entities(self, states: Iterable[State]) -> list[State]: + def filter_exposed_states(self, states: Iterable[State]) -> list[State]: """Filter a list of all states down to exposed entities.""" exposed: list[State] = [ - state for state in states if self.is_entity_exposed(state) + state for state in states if self.is_state_exposed(state) ] return exposed - def _is_entity_exposed(self, entity: State) -> bool: - """Determine if an entity should be exposed on the emulated bridge. + def _is_state_exposed(self, state: State) -> bool: + """Determine if an entity state should be exposed on the emulated bridge. Async friendly. """ - if entity.attributes.get("view") is not None: + if state.attributes.get("view") is not None: # Ignore entities that are views return False - if entity.entity_id in self._entities_with_hidden_attr_in_config: - return not self._entities_with_hidden_attr_in_config[entity.entity_id] + if state.entity_id in self._entities_with_hidden_attr_in_config: + return not self._entities_with_hidden_attr_in_config[state.entity_id] if not self.expose_by_default: return False # Expose an entity if the entity's domain is exposed by default and # the configuration doesn't explicitly exclude it from being # exposed, or if the entity is explicitly exposed - if entity.domain in self.exposed_domains: + if state.domain in self.exposed_domains: return True return False diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index d6ac67b6984..2a9022f909d 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from functools import lru_cache import hashlib from http import HTTPStatus from ipaddress import ip_address @@ -302,15 +303,15 @@ class HueOneLightStateView(HomeAssistantView): ) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - if (entity := hass.states.get(hass_entity_id)) is None: + if (state := hass.states.get(hass_entity_id)) is None: _LOGGER.error("Entity not found: %s", hass_entity_id) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - if not self.config.is_entity_exposed(entity): + if not self.config.is_state_exposed(state): _LOGGER.error("Entity not exposed: %s", entity_id) return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) - json_response = entity_to_json(self.config, entity) + json_response = state_to_json(self.config, state) return self.json(json_response) @@ -346,7 +347,7 @@ class HueOneLightChangeView(HomeAssistantView): _LOGGER.error("Entity not found: %s", entity_id) return self.json_message("Entity not found", HTTPStatus.NOT_FOUND) - if not config.is_entity_exposed(entity): + if not config.is_state_exposed(entity): _LOGGER.error("Entity not exposed: %s", entity_id) return self.json_message("Entity not exposed", HTTPStatus.UNAUTHORIZED) @@ -614,7 +615,7 @@ class HueOneLightChangeView(HomeAssistantView): return self.json(json_response) -def get_entity_state(config: Config, entity: State) -> dict[str, Any]: +def get_entity_state_dict(config: Config, entity: State) -> dict[str, Any]: """Retrieve and convert state and brightness values for an entity.""" cached_state_entry = config.cached_states.get(entity.entity_id, None) cached_state = None @@ -718,22 +719,32 @@ def get_entity_state(config: Config, entity: State) -> dict[str, Any]: return data -def entity_to_json(config: Config, entity: State) -> dict[str, Any]: +@lru_cache(maxsize=1024) +def _entity_unique_id(entity_id: str) -> str: + """Return the emulated_hue unique id for the entity_id.""" + unique_id = hashlib.md5(entity_id.encode()).hexdigest() + return ( + f"00:{unique_id[0:2]}:{unique_id[2:4]}:" + f"{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:" + f"{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}" + ) + + +def state_to_json(config: Config, state: State) -> dict[str, Any]: """Convert an entity to its Hue bridge JSON representation.""" - entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - color_modes = entity.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) - unique_id = hashlib.md5(entity.entity_id.encode()).hexdigest() - unique_id = f"00:{unique_id[0:2]}:{unique_id[2:4]}:{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}" + entity_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + color_modes = state.attributes.get(light.ATTR_SUPPORTED_COLOR_MODES, []) + unique_id = _entity_unique_id(state.entity_id) + state_dict = get_entity_state_dict(config, state) - state = get_entity_state(config, entity) - - retval: dict[str, Any] = { - "state": { - HUE_API_STATE_ON: state[STATE_ON], - "reachable": entity.state != STATE_UNAVAILABLE, - "mode": "homeautomation", - }, - "name": config.get_entity_name(entity), + json_state: dict[str, str | bool | int] = { + HUE_API_STATE_ON: state_dict[STATE_ON], + "reachable": state.state != STATE_UNAVAILABLE, + "mode": "homeautomation", + } + retval: dict[str, str | dict[str, str | bool | int]] = { + "state": json_state, + "name": config.get_entity_name(state), "uniqueid": unique_id, "manufacturername": "Home Assistant", "swversion": "123", @@ -744,30 +755,30 @@ def entity_to_json(config: Config, entity: State) -> dict[str, Any]: # Same as Color light, but which supports additional setting of color temperature retval["type"] = "Extended color light" retval["modelid"] = "HASS231" - retval["state"].update( + json_state.update( { - HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], - HUE_API_STATE_HUE: state[STATE_HUE], - HUE_API_STATE_SAT: state[STATE_SATURATION], - HUE_API_STATE_CT: state[STATE_COLOR_TEMP], + HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], + HUE_API_STATE_HUE: state_dict[STATE_HUE], + HUE_API_STATE_SAT: state_dict[STATE_SATURATION], + HUE_API_STATE_CT: state_dict[STATE_COLOR_TEMP], HUE_API_STATE_EFFECT: "none", } ) - if state[STATE_HUE] > 0 or state[STATE_SATURATION] > 0: - retval["state"][HUE_API_STATE_COLORMODE] = "hs" + if state_dict[STATE_HUE] > 0 or state_dict[STATE_SATURATION] > 0: + json_state[HUE_API_STATE_COLORMODE] = "hs" else: - retval["state"][HUE_API_STATE_COLORMODE] = "ct" + json_state[HUE_API_STATE_COLORMODE] = "ct" elif light.color_supported(color_modes): # Color light (Zigbee Device ID: 0x0200) # Supports on/off, dimming and color control (hue/saturation, enhanced hue, color loop and XY) retval["type"] = "Color light" retval["modelid"] = "HASS213" - retval["state"].update( + json_state.update( { - HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], HUE_API_STATE_COLORMODE: "hs", - HUE_API_STATE_HUE: state[STATE_HUE], - HUE_API_STATE_SAT: state[STATE_SATURATION], + HUE_API_STATE_HUE: state_dict[STATE_HUE], + HUE_API_STATE_SAT: state_dict[STATE_SATURATION], HUE_API_STATE_EFFECT: "none", } ) @@ -776,11 +787,11 @@ def entity_to_json(config: Config, entity: State) -> dict[str, Any]: # Supports groups, scenes, on/off, dimming, and setting of a color temperature retval["type"] = "Color temperature light" retval["modelid"] = "HASS312" - retval["state"].update( + json_state.update( { HUE_API_STATE_COLORMODE: "ct", - HUE_API_STATE_CT: state[STATE_COLOR_TEMP], - HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + HUE_API_STATE_CT: state_dict[STATE_COLOR_TEMP], + HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS], } ) elif entity_features & ( @@ -793,7 +804,7 @@ def entity_to_json(config: Config, entity: State) -> dict[str, Any]: # Supports groups, scenes, on/off and dimming retval["type"] = "Dimmable light" retval["modelid"] = "HASS123" - retval["state"].update({HUE_API_STATE_BRI: state[STATE_BRIGHTNESS]}) + json_state.update({HUE_API_STATE_BRI: state_dict[STATE_BRIGHTNESS]}) elif not config.lights_all_dimmable: # On/Off light (ZigBee Device ID: 0x0000) # Supports groups, scenes and on/off control @@ -806,7 +817,7 @@ def entity_to_json(config: Config, entity: State) -> dict[str, Any]: # Reports fixed brightness for compatibility with Alexa. retval["type"] = "Dimmable light" retval["modelid"] = "HASS123" - retval["state"].update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) + json_state.update({HUE_API_STATE_BRI: HUE_API_STATE_BRI_MAX}) return retval @@ -835,8 +846,8 @@ def create_list_of_entities(config: Config, request: web.Request) -> dict[str, A """Create a list of all entities.""" hass: core.HomeAssistant = request.app["hass"] json_response: dict[str, Any] = { - config.entity_id_to_number(entity.entity_id): entity_to_json(config, entity) - for entity in config.filter_exposed_entities(hass.states.async_all()) + config.entity_id_to_number(entity.entity_id): state_to_json(config, entity) + for entity in config.filter_exposed_states(hass.states.async_all()) } return json_response From 228fc02abb41f2032d7eb10eafaebfdde4f6035d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 5 Jun 2022 09:13:43 -0600 Subject: [PATCH 238/947] Bump regenmaschine to 2022.06.0 (#73056) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index 98dc9a6c877..a61283ea298 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==2022.05.1"], + "requirements": ["regenmaschine==2022.06.0"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 9ebbd7ef32b..e79f2c06b23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2068,7 +2068,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.05.1 +regenmaschine==2022.06.0 # homeassistant.components.renault renault-api==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f1e8ecd9621..023521fc4be 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ rachiopy==1.0.3 radios==0.1.1 # homeassistant.components.rainmachine -regenmaschine==2022.05.1 +regenmaschine==2022.06.0 # homeassistant.components.renault renault-api==0.1.11 From e8cfc747f9f3553a534f12d154407391b374c28c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 5 Jun 2022 09:13:54 -0600 Subject: [PATCH 239/947] Bump simplisafe-python to 2022.06.0 (#73054) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index f62da735f92..4da8f09eff8 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==2022.05.2"], + "requirements": ["simplisafe-python==2022.06.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index e79f2c06b23..56411aafceb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2171,7 +2171,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.05.2 +simplisafe-python==2022.06.0 # homeassistant.components.sisyphus sisyphus-control==3.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 023521fc4be..c96a7e1e428 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1428,7 +1428,7 @@ sharkiq==0.0.1 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==2022.05.2 +simplisafe-python==2022.06.0 # homeassistant.components.slack slackclient==2.5.0 From b1073fb362092c760f7115afe9097b4f7c62f38d Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Sun, 5 Jun 2022 18:45:21 +0200 Subject: [PATCH 240/947] Remove myself from fixer codeowners (#73070) --- CODEOWNERS | 1 - homeassistant/components/fixer/manifest.json | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 59f3671c475..077f4ca9063 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -328,7 +328,6 @@ build.json @home-assistant/supervisor /tests/components/firmata/ @DaAwesomeP /homeassistant/components/fivem/ @Sander0542 /tests/components/fivem/ @Sander0542 -/homeassistant/components/fixer/ @fabaff /homeassistant/components/fjaraskupan/ @elupus /tests/components/fjaraskupan/ @elupus /homeassistant/components/flick_electric/ @ZephireNZ diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json index 87f2370aace..4d058f82e22 100644 --- a/homeassistant/components/fixer/manifest.json +++ b/homeassistant/components/fixer/manifest.json @@ -3,7 +3,7 @@ "name": "Fixer", "documentation": "https://www.home-assistant.io/integrations/fixer", "requirements": ["fixerio==1.0.0a0"], - "codeowners": ["@fabaff"], + "codeowners": [], "iot_class": "cloud_polling", "loggers": ["fixerio"] } From 58d4ea0db9160ad69ec4cda025cd9ea976e87be8 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 5 Jun 2022 13:09:44 -0400 Subject: [PATCH 241/947] Bump aioskybell to 22.6.0 (#73073) * Bump aioskybell to 22.6.0 * uno mas --- homeassistant/components/skybell/binary_sensor.py | 6 +++++- homeassistant/components/skybell/entity.py | 8 ++++++-- homeassistant/components/skybell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index dcb5466e479..1af85398d47 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,6 +1,8 @@ """Binary sensor support for the Skybell HD Doorbell.""" from __future__ import annotations +from datetime import datetime + from aioskybell.helpers import const as CONST import voluptuous as vol @@ -68,7 +70,9 @@ class SkybellBinarySensor(SkybellEntity, BinarySensorEntity): self._event: dict[str, str] = {} @property - def extra_state_attributes(self) -> dict[str, str | int | tuple[str, str]]: + def extra_state_attributes( + self, + ) -> dict[str, str | int | datetime | tuple[str, str]]: """Return the state attributes.""" attrs = super().extra_state_attributes if event := self._event.get(CONST.CREATED_AT): diff --git a/homeassistant/components/skybell/entity.py b/homeassistant/components/skybell/entity.py index cf728cde069..6b4683dda6b 100644 --- a/homeassistant/components/skybell/entity.py +++ b/homeassistant/components/skybell/entity.py @@ -1,6 +1,8 @@ """Entity representing a Skybell HD Doorbell.""" from __future__ import annotations +from datetime import datetime + from aioskybell import SkybellDevice from homeassistant.const import ATTR_CONNECTIONS @@ -44,9 +46,11 @@ class SkybellEntity(CoordinatorEntity[SkybellDataUpdateCoordinator]): return self.coordinator.device @property - def extra_state_attributes(self) -> dict[str, str | int | tuple[str, str]]: + def extra_state_attributes( + self, + ) -> dict[str, str | int | datetime | tuple[str, str]]: """Return the state attributes.""" - attr: dict[str, str | int | tuple[str, str]] = { + attr: dict[str, str | int | datetime | tuple[str, str]] = { "device_id": self._device.device_id, "status": self._device.status, "location": self._device.location, diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index 335ff2615f8..23b29a49247 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -3,7 +3,7 @@ "name": "SkyBell", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/skybell", - "requirements": ["aioskybell==22.3.0"], + "requirements": ["aioskybell==22.6.0"], "codeowners": ["@tkdrob"], "iot_class": "cloud_polling", "loggers": ["aioskybell"] diff --git a/requirements_all.txt b/requirements_all.txt index 56411aafceb..914d02fb421 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ aiosenz==1.0.0 aioshelly==2.0.0 # homeassistant.components.skybell -aioskybell==22.3.0 +aioskybell==22.6.0 # homeassistant.components.slimproto aioslimproto==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c96a7e1e428..a18f045207b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -210,7 +210,7 @@ aiosenz==1.0.0 aioshelly==2.0.0 # homeassistant.components.skybell -aioskybell==22.3.0 +aioskybell==22.6.0 # homeassistant.components.slimproto aioslimproto==2.0.1 From c13e55ca025821767aa6e51e3da0cf789f6646e5 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 5 Jun 2022 15:56:31 -0400 Subject: [PATCH 242/947] Move Skybell attributes to their own sensors (#73089) --- .../components/skybell/binary_sensor.py | 12 --- homeassistant/components/skybell/entity.py | 20 ----- homeassistant/components/skybell/sensor.py | 80 ++++++++++++++++++- 3 files changed, 77 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/skybell/binary_sensor.py b/homeassistant/components/skybell/binary_sensor.py index 1af85398d47..05f007e9455 100644 --- a/homeassistant/components/skybell/binary_sensor.py +++ b/homeassistant/components/skybell/binary_sensor.py @@ -1,8 +1,6 @@ """Binary sensor support for the Skybell HD Doorbell.""" from __future__ import annotations -from datetime import datetime - from aioskybell.helpers import const as CONST import voluptuous as vol @@ -69,16 +67,6 @@ class SkybellBinarySensor(SkybellEntity, BinarySensorEntity): super().__init__(coordinator, description) self._event: dict[str, str] = {} - @property - def extra_state_attributes( - self, - ) -> dict[str, str | int | datetime | tuple[str, str]]: - """Return the state attributes.""" - attrs = super().extra_state_attributes - if event := self._event.get(CONST.CREATED_AT): - attrs["event_date"] = event - return attrs - @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/homeassistant/components/skybell/entity.py b/homeassistant/components/skybell/entity.py index 6b4683dda6b..0e5c246a8ed 100644 --- a/homeassistant/components/skybell/entity.py +++ b/homeassistant/components/skybell/entity.py @@ -1,8 +1,6 @@ """Entity representing a Skybell HD Doorbell.""" from __future__ import annotations -from datetime import datetime - from aioskybell import SkybellDevice from homeassistant.const import ATTR_CONNECTIONS @@ -45,24 +43,6 @@ class SkybellEntity(CoordinatorEntity[SkybellDataUpdateCoordinator]): """Return the device.""" return self.coordinator.device - @property - def extra_state_attributes( - self, - ) -> dict[str, str | int | datetime | tuple[str, str]]: - """Return the state attributes.""" - attr: dict[str, str | int | datetime | tuple[str, str]] = { - "device_id": self._device.device_id, - "status": self._device.status, - "location": self._device.location, - "motion_threshold": self._device.motion_threshold, - "video_profile": self._device.video_profile, - } - if self._device.owner: - attr["wifi_ssid"] = self._device.wifi_ssid - attr["wifi_status"] = self._device.wifi_status - attr["last_check_in"] = self._device.last_check_in - return attr - async def async_added_to_hass(self) -> None: """When entity is added to hass.""" await super().async_added_to_hass() diff --git a/homeassistant/components/skybell/sensor.py b/homeassistant/components/skybell/sensor.py index c769570b10c..eeb81e07aaf 100644 --- a/homeassistant/components/skybell/sensor.py +++ b/homeassistant/components/skybell/sensor.py @@ -1,10 +1,17 @@ """Sensor support for Skybell Doorbells.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from aioskybell import SkybellDevice +from aioskybell.helpers import const as CONST import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + SensorDeviceClass, SensorEntity, SensorEntityDescription, ) @@ -12,15 +19,79 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .entity import DOMAIN, SkybellEntity -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +@dataclass +class SkybellSensorEntityDescription(SensorEntityDescription): + """Class to describe a Skybell sensor.""" + + value_fn: Callable[[SkybellDevice], Any] = lambda val: val + + +SENSOR_TYPES: tuple[SkybellSensorEntityDescription, ...] = ( + SkybellSensorEntityDescription( key="chime_level", name="Chime Level", icon="mdi:bell-ring", + value_fn=lambda device: device.outdoor_chime_level, + ), + SkybellSensorEntityDescription( + key="last_button_event", + name="Last Button Event", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: device.latest("button").get(CONST.CREATED_AT), + ), + SkybellSensorEntityDescription( + key="last_motion_event", + name="Last Motion Event", + icon="mdi:clock", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: device.latest("motion").get(CONST.CREATED_AT), + ), + SkybellSensorEntityDescription( + key=CONST.ATTR_LAST_CHECK_IN, + name="Last Check in", + icon="mdi:clock", + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.last_check_in, + ), + SkybellSensorEntityDescription( + key="motion_threshold", + name="Motion Threshold", + icon="mdi:walk", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.motion_threshold, + ), + SkybellSensorEntityDescription( + key="video_profile", + name="Video Profile", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.video_profile, + ), + SkybellSensorEntityDescription( + key=CONST.ATTR_WIFI_SSID, + name="Wifi SSID", + icon="mdi:wifi-settings", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.wifi_ssid, + ), + SkybellSensorEntityDescription( + key=CONST.ATTR_WIFI_STATUS, + name="Wifi Status", + icon="mdi:wifi-strength-3", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda device: device.wifi_status, ), ) @@ -45,13 +116,16 @@ async def async_setup_entry( SkybellSensor(coordinator, description) for coordinator in hass.data[DOMAIN][entry.entry_id] for description in SENSOR_TYPES + if coordinator.device.owner or description.key not in CONST.ATTR_OWNER_STATS ) class SkybellSensor(SkybellEntity, SensorEntity): """A sensor implementation for Skybell devices.""" + entity_description: SkybellSensorEntityDescription + @property def native_value(self) -> int: """Return the state of the sensor.""" - return self._device.outdoor_chime_level + return self.entity_description.value_fn(self._device) From b10bbc3e1430d492f0b8d22dcbcda5754258fd06 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 5 Jun 2022 15:56:48 -0400 Subject: [PATCH 243/947] Add do not ring switch to Skybell (#73090) --- homeassistant/components/skybell/switch.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/skybell/switch.py b/homeassistant/components/skybell/switch.py index d28369e40b0..d4f2817141c 100644 --- a/homeassistant/components/skybell/switch.py +++ b/homeassistant/components/skybell/switch.py @@ -24,6 +24,10 @@ SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( key="do_not_disturb", name="Do Not Disturb", ), + SwitchEntityDescription( + key="do_not_ring", + name="Do Not Ring", + ), SwitchEntityDescription( key="motion_sensor", name="Motion Sensor", From 35a0f59ec9354bda424cbc9223e55bb7a4a01b4a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 5 Jun 2022 22:42:46 +0200 Subject: [PATCH 244/947] Bump pysensibo to 1.0.17 (#73092) --- homeassistant/components/sensibo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 203eb751dcf..18e93d5efa6 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -2,7 +2,7 @@ "domain": "sensibo", "name": "Sensibo", "documentation": "https://www.home-assistant.io/integrations/sensibo", - "requirements": ["pysensibo==1.0.16"], + "requirements": ["pysensibo==1.0.17"], "config_flow": true, "codeowners": ["@andrey-git", "@gjohansson-ST"], "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 914d02fb421..f365da06766 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1795,7 +1795,7 @@ pysaj==0.0.16 pysdcp==1 # homeassistant.components.sensibo -pysensibo==1.0.16 +pysensibo==1.0.17 # homeassistant.components.serial # homeassistant.components.zha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a18f045207b..4331cc6dc36 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1214,7 +1214,7 @@ pyruckus==0.12 pysabnzbd==1.1.1 # homeassistant.components.sensibo -pysensibo==1.0.16 +pysensibo==1.0.17 # homeassistant.components.serial # homeassistant.components.zha From f7626bd511849101d77f838aedf854c44b49aee2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jun 2022 12:28:57 -1000 Subject: [PATCH 245/947] Speed up camera tokens (#73098) --- homeassistant/components/camera/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 4a6e1546f46..627da2d1872 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -10,7 +10,6 @@ from dataclasses import dataclass from datetime import datetime, timedelta from enum import IntEnum from functools import partial -import hashlib import logging import os from random import SystemRandom @@ -675,9 +674,7 @@ class Camera(Entity): @callback def async_update_token(self) -> None: """Update the used token.""" - self.access_tokens.append( - hashlib.sha256(_RND.getrandbits(256).to_bytes(32, "little")).hexdigest() - ) + self.access_tokens.append(hex(_RND.getrandbits(256))[2:]) async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" From cac84e4160841aa931d826c1f1acf55033042917 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jun 2022 13:59:52 -1000 Subject: [PATCH 246/947] Add config flow to radiotherm (#72874) --- .coveragerc | 3 + CODEOWNERS | 3 +- .../components/radiotherm/__init__.py | 53 +++ .../components/radiotherm/climate.py | 377 ++++++++---------- .../components/radiotherm/config_flow.py | 166 ++++++++ homeassistant/components/radiotherm/const.py | 7 + .../components/radiotherm/coordinator.py | 48 +++ homeassistant/components/radiotherm/data.py | 74 ++++ .../components/radiotherm/manifest.json | 9 +- .../components/radiotherm/strings.json | 31 ++ .../radiotherm/translations/en.json | 31 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/dhcp.py | 2 + requirements_test_all.txt | 3 + tests/components/radiotherm/__init__.py | 1 + .../components/radiotherm/test_config_flow.py | 302 ++++++++++++++ 16 files changed, 900 insertions(+), 211 deletions(-) create mode 100644 homeassistant/components/radiotherm/config_flow.py create mode 100644 homeassistant/components/radiotherm/const.py create mode 100644 homeassistant/components/radiotherm/coordinator.py create mode 100644 homeassistant/components/radiotherm/data.py create mode 100644 homeassistant/components/radiotherm/strings.json create mode 100644 homeassistant/components/radiotherm/translations/en.json create mode 100644 tests/components/radiotherm/__init__.py create mode 100644 tests/components/radiotherm/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index f4cdfa1c460..0f9c3e4998a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -963,7 +963,10 @@ omit = homeassistant/components/radarr/sensor.py homeassistant/components/radio_browser/__init__.py homeassistant/components/radio_browser/media_source.py + homeassistant/components/radiotherm/__init__.py homeassistant/components/radiotherm/climate.py + homeassistant/components/radiotherm/coordinator.py + homeassistant/components/radiotherm/data.py homeassistant/components/rainbird/* homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 077f4ca9063..6db9d92ebb9 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -825,7 +825,8 @@ build.json @home-assistant/supervisor /tests/components/rachio/ @bdraco /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck -/homeassistant/components/radiotherm/ @vinnyfuria +/homeassistant/components/radiotherm/ @bdraco @vinnyfuria +/tests/components/radiotherm/ @bdraco @vinnyfuria /homeassistant/components/rainbird/ @konikvranik /homeassistant/components/raincloud/ @vanstinator /homeassistant/components/rainforest_eagle/ @gtdiehl @jcalbert @hastarin diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index adc8cdbd6ee..9e389af6719 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1 +1,54 @@ """The radiotherm component.""" +from __future__ import annotations + +from socket import timeout + +from radiotherm.validate import RadiothermTstatError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import CONF_HOLD_TEMP, DOMAIN +from .coordinator import RadioThermUpdateCoordinator +from .data import async_get_init_data + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Radio Thermostat from a config entry.""" + host = entry.data[CONF_HOST] + try: + init_data = await async_get_init_data(hass, host) + except RadiothermTstatError as ex: + raise ConfigEntryNotReady( + f"{host} was busy (invalid value returned): {ex}" + ) from ex + except timeout as ex: + raise ConfigEntryNotReady( + f"{host} timed out waiting for a response: {ex}" + ) from ex + + hold_temp = entry.options[CONF_HOLD_TEMP] + coordinator = RadioThermUpdateCoordinator(hass, init_data, hold_temp) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index dbf013ffa9a..63ee93c9c84 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,8 +1,9 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" from __future__ import annotations +from functools import partial import logging -from socket import timeout +from typing import Any import radiotherm import voluptuous as vol @@ -18,24 +19,32 @@ from homeassistant.components.climate.const import ( HVACAction, HVACMode, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, PRECISION_HALVES, TEMP_FAHRENHEIT, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util +from . import DOMAIN +from .const import CONF_HOLD_TEMP +from .coordinator import RadioThermUpdateCoordinator +from .data import RadioThermUpdate + _LOGGER = logging.getLogger(__name__) ATTR_FAN_ACTION = "fan_action" -CONF_HOLD_TEMP = "hold_temp" - PRESET_HOLIDAY = "holiday" PRESET_ALTERNATE = "alternate" @@ -74,12 +83,19 @@ CODE_TO_TEMP_STATE = {0: HVACAction.IDLE, 1: HVACAction.HEATING, 2: HVACAction.C # future this should probably made into a binary sensor for the fan. CODE_TO_FAN_STATE = {0: FAN_OFF, 1: FAN_ON} -PRESET_MODE_TO_CODE = {"home": 0, "alternate": 1, "away": 2, "holiday": 3} +PRESET_MODE_TO_CODE = { + PRESET_HOME: 0, + PRESET_ALTERNATE: 1, + PRESET_AWAY: 2, + PRESET_HOLIDAY: 3, +} -CODE_TO_PRESET_MODE = {0: "home", 1: "alternate", 2: "away", 3: "holiday"} +CODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_CODE.items()} CODE_TO_HOLD_STATE = {0: False, 1: True} +PARALLEL_UPDATES = 1 + def round_temp(temperature): """Round a temperature to the resolution of the thermostat. @@ -98,69 +114,93 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up climate for a radiotherm device.""" + coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RadioThermostat(coordinator)]) + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Radio Thermostat.""" - hosts = [] + _LOGGER.warning( + # config flow added in 2022.7 and should be removed in 2022.9 + "Configuration of the Radio Thermostat climate platform in YAML is deprecated and " + "will be removed in Home Assistant 2022.9; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hosts: list[str] = [] if CONF_HOST in config: hosts = config[CONF_HOST] else: - hosts.append(radiotherm.discover.discover_address()) + hosts.append( + await hass.async_add_executor_job(radiotherm.discover.discover_address) + ) - if hosts is None: + if not hosts: _LOGGER.error("No Radiotherm Thermostats detected") - return False - - hold_temp = config.get(CONF_HOLD_TEMP) - tstats = [] + return + hold_temp: bool = config[CONF_HOLD_TEMP] for host in hosts: - try: - tstat = radiotherm.get_thermostat(host) - tstats.append(RadioThermostat(tstat, hold_temp)) - except OSError: - _LOGGER.exception("Unable to connect to Radio Thermostat: %s", host) - - add_entities(tstats, True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_HOST: host, CONF_HOLD_TEMP: hold_temp}, + ) + ) -class RadioThermostat(ClimateEntity): +class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEntity): """Representation of a Radio Thermostat.""" _attr_hvac_modes = OPERATION_LIST - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.FAN_MODE - | ClimateEntityFeature.PRESET_MODE - ) + _attr_temperature_unit = TEMP_FAHRENHEIT + _attr_precision = PRECISION_HALVES - def __init__(self, device, hold_temp): + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" - self.device = device - self._target_temperature = None - self._current_temperature = None - self._current_humidity = None - self._current_operation = HVACMode.OFF - self._name = None - self._fmode = None - self._fstate = None - self._tmode = None - self._tstate: HVACAction | None = None - self._hold_temp = hold_temp + super().__init__(coordinator) + self.device = coordinator.init_data.tstat + self._attr_name = coordinator.init_data.name + self._hold_temp = coordinator.hold_temp self._hold_set = False - self._prev_temp = None - self._preset_mode = None - self._program_mode = None - self._is_away = False - - # Fan circulate mode is only supported by the CT80 models. + self._attr_unique_id = coordinator.init_data.mac + self._attr_device_info = DeviceInfo( + name=coordinator.init_data.name, + model=coordinator.init_data.model, + manufacturer="Radio Thermostats", + sw_version=coordinator.init_data.fw_version, + connections={(dr.CONNECTION_NETWORK_MAC, coordinator.init_data.mac)}, + ) self._is_model_ct80 = isinstance(self.device, radiotherm.thermostat.CT80) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + self._process_data() + if not self._is_model_ct80: + self._attr_fan_modes = CT30_FAN_OPERATION_LIST + return + self._attr_fan_modes = CT80_FAN_OPERATION_LIST + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + self._attr_preset_modes = PRESET_MODES - async def async_added_to_hass(self): + @property + def data(self) -> RadioThermUpdate: + """Returnt the last update.""" + return self.coordinator.data + + async def async_added_to_hass(self) -> None: """Register callbacks.""" # Set the time on the device. This shouldn't be in the # constructor because it's a network call. We can't put it in @@ -168,181 +208,93 @@ class RadioThermostat(ClimateEntity): # temperature in the thermostat. So add it as a future job # for the event loop to run. self.hass.async_add_job(self.set_time) + await super().async_added_to_hass() - @property - def name(self): - """Return the name of the Radio Thermostat.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT - - @property - def precision(self): - """Return the precision of the system.""" - return PRECISION_HALVES - - @property - def extra_state_attributes(self): - """Return the device specific state attributes.""" - return {ATTR_FAN_ACTION: self._fstate} - - @property - def fan_modes(self): - """List of available fan modes.""" - if self._is_model_ct80: - return CT80_FAN_OPERATION_LIST - return CT30_FAN_OPERATION_LIST - - @property - def fan_mode(self): - """Return whether the fan is on.""" - return self._fmode - - def set_fan_mode(self, fan_mode): + async def async_set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" - if (code := FAN_MODE_TO_CODE.get(fan_mode)) is not None: - self.device.fmode = code + if (code := FAN_MODE_TO_CODE.get(fan_mode)) is None: + raise HomeAssistantError(f"{fan_mode} is not a valid fan mode") + await self.hass.async_add_executor_job(self._set_fan_mode, code) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature + def _set_fan_mode(self, code: int) -> None: + """Turn fan on/off.""" + self.device.fmode = code - @property - def current_humidity(self): - """Return the current temperature.""" - return self._current_humidity + @callback + def _handle_coordinator_update(self) -> None: + self._process_data() + return super()._handle_coordinator_update() - @property - def hvac_mode(self) -> HVACMode: - """Return the current operation. head, cool idle.""" - return self._current_operation - - @property - def hvac_action(self) -> HVACAction | None: - """Return the current running hvac operation if supported.""" - if self.hvac_mode == HVACMode.OFF: - return None - return self._tstate - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - - @property - def preset_mode(self): - """Return the current preset mode, e.g., home, away, temp.""" - if self._program_mode == 0: - return PRESET_HOME - if self._program_mode == 1: - return PRESET_ALTERNATE - if self._program_mode == 2: - return PRESET_AWAY - if self._program_mode == 3: - return PRESET_HOLIDAY - - @property - def preset_modes(self): - """Return a list of available preset modes.""" - return PRESET_MODES - - def update(self): + @callback + def _process_data(self) -> None: """Update and validate the data from the thermostat.""" - # Radio thermostats are very slow, and sometimes don't respond - # very quickly. So we need to keep the number of calls to them - # to a bare minimum or we'll hit the Home Assistant 10 sec warning. We - # have to make one call to /tstat to get temps but we'll try and - # keep the other calls to a minimum. Even with this, these - # thermostats tend to time out sometimes when they're actively - # heating or cooling. - - try: - # First time - get the name from the thermostat. This is - # normally set in the radio thermostat web app. - if self._name is None: - self._name = self.device.name["raw"] - - # Request the current state from the thermostat. - data = self.device.tstat["raw"] - - if self._is_model_ct80: - humiditydata = self.device.humidity["raw"] - - except radiotherm.validate.RadiothermTstatError: - _LOGGER.warning( - "%s (%s) was busy (invalid value returned)", - self._name, - self.device.host, - ) - - except timeout: - _LOGGER.warning( - "Timeout waiting for response from %s (%s)", - self._name, - self.device.host, - ) - + data = self.data.tstat + if self._is_model_ct80: + self._attr_current_humidity = self.data.humidity + self._attr_preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] + # Map thermostat values into various STATE_ flags. + self._attr_current_temperature = data["temp"] + self._attr_fan_mode = CODE_TO_FAN_MODE[data["fmode"]] + self._attr_extra_state_attributes = { + ATTR_FAN_ACTION: CODE_TO_FAN_STATE[data["fstate"]] + } + self._attr_hvac_mode = CODE_TO_TEMP_MODE[data["tmode"]] + self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] + if self.hvac_mode == HVACMode.OFF: + self._attr_hvac_action = None else: - if self._is_model_ct80: - self._current_humidity = humiditydata - self._program_mode = data["program_mode"] - self._preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] + self._attr_hvac_action = CODE_TO_TEMP_STATE[data["tstate"]] + if self.hvac_mode == HVACMode.COOL: + self._attr_target_temperature = data["t_cool"] + elif self.hvac_mode == HVACMode.HEAT: + self._attr_target_temperature = data["t_heat"] + elif self.hvac_mode == HVACMode.AUTO: + # This doesn't really work - tstate is only set if the HVAC is + # active. If it's idle, we don't know what to do with the target + # temperature. + if self.hvac_action == HVACAction.COOLING: + self._attr_target_temperature = data["t_cool"] + elif self.hvac_action == HVACAction.HEATING: + self._attr_target_temperature = data["t_heat"] - # Map thermostat values into various STATE_ flags. - self._current_temperature = data["temp"] - self._fmode = CODE_TO_FAN_MODE[data["fmode"]] - self._fstate = CODE_TO_FAN_STATE[data["fstate"]] - self._tmode = CODE_TO_TEMP_MODE[data["tmode"]] - self._tstate = CODE_TO_TEMP_STATE[data["tstate"]] - self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] - - self._current_operation = self._tmode - if self._tmode == HVACMode.COOL: - self._target_temperature = data["t_cool"] - elif self._tmode == HVACMode.HEAT: - self._target_temperature = data["t_heat"] - elif self._tmode == HVACMode.AUTO: - # This doesn't really work - tstate is only set if the HVAC is - # active. If it's idle, we don't know what to do with the target - # temperature. - if self._tstate == HVACAction.COOLING: - self._target_temperature = data["t_cool"] - elif self._tstate == HVACAction.HEATING: - self._target_temperature = data["t_heat"] - else: - self._current_operation = HVACMode.OFF - - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return + hold_changed = kwargs.get("hold_changed", False) + await self.hass.async_add_executor_job( + partial(self._set_temperature, temperature, hold_changed) + ) + self._attr_target_temperature = temperature + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + def _set_temperature(self, temperature: int, hold_changed: bool) -> None: + """Set new target temperature.""" temperature = round_temp(temperature) - - if self._current_operation == HVACMode.COOL: + if self.hvac_mode == HVACMode.COOL: self.device.t_cool = temperature - elif self._current_operation == HVACMode.HEAT: + elif self.hvac_mode == HVACMode.HEAT: self.device.t_heat = temperature - elif self._current_operation == HVACMode.AUTO: - if self._tstate == HVACAction.COOLING: + elif self.hvac_mode == HVACMode.AUTO: + if self.hvac_action == HVACAction.COOLING: self.device.t_cool = temperature - elif self._tstate == HVACAction.HEATING: + elif self.hvac_action == HVACAction.HEATING: self.device.t_heat = temperature # Only change the hold if requested or if hold mode was turned # on and we haven't set it yet. - if kwargs.get("hold_changed", False) or not self._hold_set: + if hold_changed or not self._hold_set: if self._hold_temp: self.device.hold = 1 self._hold_set = True else: self.device.hold = 0 - def set_time(self): + def set_time(self) -> None: """Set device time.""" # Calling this clears any local temperature override and # reverts to the scheduled temperature. @@ -353,23 +305,32 @@ class RadioThermostat(ClimateEntity): "minute": now.minute, } - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set operation mode (auto, cool, heat, off).""" + await self.hass.async_add_executor_job(self._set_hvac_mode, hvac_mode) + self._attr_hvac_mode = hvac_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + def _set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set operation mode (auto, cool, heat, off).""" if hvac_mode in (HVACMode.OFF, HVACMode.AUTO): self.device.tmode = TEMP_MODE_TO_CODE[hvac_mode] - # Setting t_cool or t_heat automatically changes tmode. elif hvac_mode == HVACMode.COOL: - self.device.t_cool = self._target_temperature + self.device.t_cool = self.target_temperature elif hvac_mode == HVACMode.HEAT: - self.device.t_heat = self._target_temperature + self.device.t_heat = self.target_temperature - def set_preset_mode(self, preset_mode): + async def async_set_preset_mode(self, preset_mode: str) -> None: """Set Preset mode (Home, Alternate, Away, Holiday).""" - if preset_mode in PRESET_MODES: - self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] - else: - _LOGGER.error( - "Preset_mode %s not in PRESET_MODES", - preset_mode, - ) + if preset_mode not in PRESET_MODES: + raise HomeAssistantError("{preset_mode} is not a valid preset_mode") + await self.hass.async_add_executor_job(self._set_preset_mode, preset_mode) + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + def _set_preset_mode(self, preset_mode: str) -> None: + """Set Preset mode (Home, Alternate, Away, Holiday).""" + self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py new file mode 100644 index 00000000000..45fee0f7fd9 --- /dev/null +++ b/homeassistant/components/radiotherm/config_flow.py @@ -0,0 +1,166 @@ +"""Config flow for Radio Thermostat integration.""" +from __future__ import annotations + +import logging +from socket import timeout +from typing import Any + +from radiotherm.validate import RadiothermTstatError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components import dhcp +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.exceptions import HomeAssistantError + +from .const import CONF_HOLD_TEMP, DOMAIN +from .data import RadioThermInitData, async_get_init_data + +_LOGGER = logging.getLogger(__name__) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +async def validate_connection(hass: HomeAssistant, host: str) -> RadioThermInitData: + """Validate the connection.""" + try: + return await async_get_init_data(hass, host) + except (timeout, RadiothermTstatError) as ex: + raise CannotConnect(f"Failed to connect to {host}: {ex}") from ex + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Radio Thermostat.""" + + VERSION = 1 + + def __init__(self): + """Initialize ConfigFlow.""" + self.discovered_ip: str | None = None + self.discovered_init_data: RadioThermInitData | None = None + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Discover via DHCP.""" + self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + try: + init_data = await validate_connection(self.hass, discovery_info.ip) + except CannotConnect: + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(init_data.mac) + self._abort_if_unique_id_configured( + updates={CONF_HOST: discovery_info.ip}, reload_on_update=False + ) + self.discovered_init_data = init_data + self.discovered_ip = discovery_info.ip + return await self.async_step_confirm() + + async def async_step_confirm(self, user_input=None): + """Attempt to confirm.""" + ip_address = self.discovered_ip + init_data = self.discovered_init_data + assert ip_address is not None + assert init_data is not None + if user_input is not None: + return self.async_create_entry( + title=init_data.name, + data={CONF_HOST: ip_address}, + options={CONF_HOLD_TEMP: False}, + ) + + self._set_confirm_only() + placeholders = { + "name": init_data.name, + "host": self.discovered_ip, + "model": init_data.model or "Unknown", + } + self.context["title_placeholders"] = placeholders + return self.async_show_form( + step_id="confirm", + description_placeholders=placeholders, + ) + + async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: + """Import from yaml.""" + host = import_info[CONF_HOST] + self._async_abort_entries_match({CONF_HOST: host}) + _LOGGER.debug("Importing entry for host: %s", host) + try: + init_data = await validate_connection(self.hass, host) + except CannotConnect as ex: + _LOGGER.debug("Importing failed for %s", host, exc_info=ex) + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(init_data.mac, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host}, reload_on_update=False + ) + return self.async_create_entry( + title=init_data.name, + data={CONF_HOST: import_info[CONF_HOST]}, + options={CONF_HOLD_TEMP: import_info[CONF_HOLD_TEMP]}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + init_data = await validate_connection(self.hass, user_input[CONF_HOST]) + except CannotConnect: + errors[CONF_HOST] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(init_data.mac, raise_on_progress=False) + self._abort_if_unique_id_configured( + updates={CONF_HOST: user_input[CONF_HOST]}, + reload_on_update=False, + ) + return self.async_create_entry( + title=init_data.name, + data=user_input, + options={CONF_HOLD_TEMP: False}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_HOST): str}), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for radiotherm.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_HOLD_TEMP, + default=self.config_entry.options[CONF_HOLD_TEMP], + ): bool + } + ), + ) diff --git a/homeassistant/components/radiotherm/const.py b/homeassistant/components/radiotherm/const.py new file mode 100644 index 00000000000..398747c6571 --- /dev/null +++ b/homeassistant/components/radiotherm/const.py @@ -0,0 +1,7 @@ +"""Constants for the Radio Thermostat integration.""" + +DOMAIN = "radiotherm" + +CONF_HOLD_TEMP = "hold_temp" + +TIMEOUT = 25 diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py new file mode 100644 index 00000000000..264a4a8d1fd --- /dev/null +++ b/homeassistant/components/radiotherm/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for radiotherm.""" +from __future__ import annotations + +from datetime import timedelta +import logging +from socket import timeout + +from radiotherm.validate import RadiothermTstatError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .data import RadioThermInitData, RadioThermUpdate, async_get_data + +_LOGGER = logging.getLogger(__name__) + +UPDATE_INTERVAL = timedelta(seconds=15) + + +class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): + """DataUpdateCoordinator to gather data for radio thermostats.""" + + def __init__( + self, hass: HomeAssistant, init_data: RadioThermInitData, hold_temp: bool + ) -> None: + """Initialize DataUpdateCoordinator.""" + self.init_data = init_data + self.hold_temp = hold_temp + self._description = f"{init_data.name} ({init_data.host})" + super().__init__( + hass, + _LOGGER, + name=f"radiotherm {self.init_data.name}", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> RadioThermUpdate: + """Update data from the thermostat.""" + try: + return await async_get_data(self.hass, self.init_data.tstat) + except RadiothermTstatError as ex: + raise UpdateFailed( + f"{self._description} was busy (invalid value returned): {ex}" + ) from ex + except timeout as ex: + raise UpdateFailed( + f"{self._description}) timed out waiting for a response: {ex}" + ) from ex diff --git a/homeassistant/components/radiotherm/data.py b/homeassistant/components/radiotherm/data.py new file mode 100644 index 00000000000..3aa4e6b7631 --- /dev/null +++ b/homeassistant/components/radiotherm/data.py @@ -0,0 +1,74 @@ +"""The radiotherm component data.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import radiotherm +from radiotherm.thermostat import CommonThermostat + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .const import TIMEOUT + + +@dataclass +class RadioThermUpdate: + """An update from a radiotherm device.""" + + tstat: dict[str, Any] + humidity: int | None + + +@dataclass +class RadioThermInitData: + """An data needed to init the integration.""" + + tstat: CommonThermostat + host: str + name: str + mac: str + model: str | None + fw_version: str | None + api_version: int | None + + +def _get_init_data(host: str) -> RadioThermInitData: + tstat = radiotherm.get_thermostat(host) + tstat.timeout = TIMEOUT + name: str = tstat.name["raw"] + sys: dict[str, Any] = tstat.sys["raw"] + mac: str = dr.format_mac(sys["uuid"]) + model: str = tstat.model.get("raw") + return RadioThermInitData( + tstat, host, name, mac, model, sys.get("fw_version"), sys.get("api_version") + ) + + +async def async_get_init_data(hass: HomeAssistant, host: str) -> RadioThermInitData: + """Get the RadioInitData.""" + return await hass.async_add_executor_job(_get_init_data, host) + + +def _get_data(device: CommonThermostat) -> RadioThermUpdate: + # Request the current state from the thermostat. + # Radio thermostats are very slow, and sometimes don't respond + # very quickly. So we need to keep the number of calls to them + # to a bare minimum or we'll hit the Home Assistant 10 sec warning. We + # have to make one call to /tstat to get temps but we'll try and + # keep the other calls to a minimum. Even with this, these + # thermostats tend to time out sometimes when they're actively + # heating or cooling. + tstat: dict[str, Any] = device.tstat["raw"] + humidity: int | None = None + if isinstance(device, radiotherm.thermostat.CT80): + humidity = device.humidity["raw"] + return RadioThermUpdate(tstat, humidity) + + +async def async_get_data( + hass: HomeAssistant, device: CommonThermostat +) -> RadioThermUpdate: + """Fetch the data from the thermostat.""" + return await hass.async_add_executor_job(_get_data, device) diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json index 72c2c8eb300..c6ae4e5bb06 100644 --- a/homeassistant/components/radiotherm/manifest.json +++ b/homeassistant/components/radiotherm/manifest.json @@ -3,7 +3,12 @@ "name": "Radio Thermostat", "documentation": "https://www.home-assistant.io/integrations/radiotherm", "requirements": ["radiotherm==2.1.0"], - "codeowners": ["@vinnyfuria"], + "codeowners": ["@bdraco", "@vinnyfuria"], "iot_class": "local_polling", - "loggers": ["radiotherm"] + "loggers": ["radiotherm"], + "dhcp": [ + { "hostname": "thermostat*", "macaddress": "5CDAD4*" }, + { "registered_devices": true } + ], + "config_flow": true } diff --git a/homeassistant/components/radiotherm/strings.json b/homeassistant/components/radiotherm/strings.json new file mode 100644 index 00000000000..22f17224285 --- /dev/null +++ b/homeassistant/components/radiotherm/strings.json @@ -0,0 +1,31 @@ +{ + "config": { + "flow_title": "{name} {model} ({host})", + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + }, + "confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Set a permanent hold when adjusting the temperature." + } + } + } + } +} diff --git a/homeassistant/components/radiotherm/translations/en.json b/homeassistant/components/radiotherm/translations/en.json new file mode 100644 index 00000000000..b524f188e59 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/en.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Do you want to setup {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Set a permanent hold when adjusting the temperature." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 06da1eae90c..843e3f7f7a9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -280,6 +280,7 @@ FLOWS = { "qnap_qsw", "rachio", "radio_browser", + "radiotherm", "rainforest_eagle", "rainmachine", "rdw", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 015d70e2939..43bf7ca2715 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -77,6 +77,8 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '009D6B*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': 'F0038C*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '74C63B*'}, + {'domain': 'radiotherm', 'hostname': 'thermostat*', 'macaddress': '5CDAD4*'}, + {'domain': 'radiotherm', 'registered_devices': True}, {'domain': 'rainforest_eagle', 'macaddress': 'D8D5B9*'}, {'domain': 'ring', 'hostname': 'ring*', 'macaddress': '0CAE7D*'}, {'domain': 'roomba', 'hostname': 'irobot-*', 'macaddress': '501479*'}, diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4331cc6dc36..c3178a0822f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1366,6 +1366,9 @@ rachiopy==1.0.3 # homeassistant.components.radio_browser radios==0.1.1 +# homeassistant.components.radiotherm +radiotherm==2.1.0 + # homeassistant.components.rainmachine regenmaschine==2022.06.0 diff --git a/tests/components/radiotherm/__init__.py b/tests/components/radiotherm/__init__.py new file mode 100644 index 00000000000..cf8bc0c7cc5 --- /dev/null +++ b/tests/components/radiotherm/__init__.py @@ -0,0 +1 @@ +"""Tests for the Radio Thermostat integration.""" diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py new file mode 100644 index 00000000000..bc729a27e1c --- /dev/null +++ b/tests/components/radiotherm/test_config_flow.py @@ -0,0 +1,302 @@ +"""Test the Radio Thermostat config flow.""" +import socket +from unittest.mock import MagicMock, patch + +from radiotherm import CommonThermostat +from radiotherm.validate import RadiothermTstatError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp +from homeassistant.components.radiotherm.const import CONF_HOLD_TEMP, DOMAIN +from homeassistant.const import CONF_HOST + +from tests.common import MockConfigEntry + + +def _mock_radiotherm(): + tstat = MagicMock(autospec=CommonThermostat) + tstat.name = {"raw": "My Name"} + tstat.sys = { + "raw": {"uuid": "aabbccddeeff", "fw_version": "1.2.3", "api_version": "4.5.6"} + } + tstat.model = {"raw": "Model"} + return tstat + + +async def test_form(hass): + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "My Name" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_unknown_error(hass): + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=Exception, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {"base": "unknown"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=RadiothermTstatError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["errors"] == {CONF_HOST: "cannot_connect"} + + +async def test_import(hass): + """Test we get can import from yaml.""" + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_HOLD_TEMP: True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My Name" + assert result["data"] == { + CONF_HOST: "1.2.3.4", + } + assert result["options"] == { + CONF_HOLD_TEMP: True, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_import_cannot_connect(hass): + """Test we abort if we cannot connect on import from yaml.""" + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=socket.timeout, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_HOST: "1.2.3.4", CONF_HOLD_TEMP: True}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_can_confirm(hass): + """Test DHCP discovery flow can confirm right away.""" + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "host": "1.2.3.4", + "name": "My Name", + "model": "Model", + } + + with patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "My Name" + assert result2["data"] == { + "host": "1.2.3.4", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_fails_to_connect(hass): + """Test DHCP discovery flow that fails to connect.""" + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + side_effect=RadiothermTstatError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_already_exists(hass): + """Test DHCP discovery flow that fails to connect.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + hostname="radiotherm", + ip="1.2.3.4", + macaddress="aa:bb:cc:dd:ee:ff", + ), + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_user_unique_id_already_exists(hass): + """Test creating an entry where the unique_id already exists.""" + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ), patch( + "homeassistant.components.radiotherm.async_setup_entry", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.2.3.4", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result2["reason"] == "already_configured" + + +async def test_options_flow(hass): + """Test config flow options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.3.4"}, + unique_id="aa:bb:cc:dd:ee:ff", + options={CONF_HOLD_TEMP: False}, + ) + + entry.add_to_hass(hass) + with patch( + "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", + return_value=_mock_radiotherm(), + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init(entry.entry_id) + await hass.async_block_till_done() + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_HOLD_TEMP: True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert entry.options == {CONF_HOLD_TEMP: True} From 7f0091280fe6c8b116bc7abae6cd5cca30f2b28d Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 6 Jun 2022 00:21:14 +0000 Subject: [PATCH 247/947] [ci skip] Translation update --- .../components/hassio/translations/bg.json | 1 + .../here_travel_time/translations/bg.json | 6 ++ .../components/scrape/translations/bg.json | 33 +++++++++ .../components/scrape/translations/et.json | 73 +++++++++++++++++++ .../components/scrape/translations/ja.json | 19 ++++- .../components/scrape/translations/nl.json | 57 +++++++++++++++ .../components/skybell/translations/bg.json | 21 ++++++ .../components/skybell/translations/ca.json | 21 ++++++ .../components/skybell/translations/de.json | 21 ++++++ .../components/skybell/translations/el.json | 21 ++++++ .../components/skybell/translations/en.json | 36 ++++----- .../components/skybell/translations/et.json | 21 ++++++ .../components/skybell/translations/fr.json | 21 ++++++ .../components/skybell/translations/ja.json | 21 ++++++ .../components/skybell/translations/nl.json | 21 ++++++ .../skybell/translations/pt-BR.json | 21 ++++++ .../skybell/translations/zh-Hant.json | 21 ++++++ .../tankerkoenig/translations/bg.json | 8 +- .../tankerkoenig/translations/nl.json | 8 +- 19 files changed, 429 insertions(+), 22 deletions(-) create mode 100644 homeassistant/components/scrape/translations/bg.json create mode 100644 homeassistant/components/scrape/translations/et.json create mode 100644 homeassistant/components/scrape/translations/nl.json create mode 100644 homeassistant/components/skybell/translations/bg.json create mode 100644 homeassistant/components/skybell/translations/ca.json create mode 100644 homeassistant/components/skybell/translations/de.json create mode 100644 homeassistant/components/skybell/translations/el.json create mode 100644 homeassistant/components/skybell/translations/et.json create mode 100644 homeassistant/components/skybell/translations/fr.json create mode 100644 homeassistant/components/skybell/translations/ja.json create mode 100644 homeassistant/components/skybell/translations/nl.json create mode 100644 homeassistant/components/skybell/translations/pt-BR.json create mode 100644 homeassistant/components/skybell/translations/zh-Hant.json diff --git a/homeassistant/components/hassio/translations/bg.json b/homeassistant/components/hassio/translations/bg.json index 941c3601bea..a5581901d78 100644 --- a/homeassistant/components/hassio/translations/bg.json +++ b/homeassistant/components/hassio/translations/bg.json @@ -1,6 +1,7 @@ { "system_health": { "info": { + "agent_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 \u0430\u0433\u0435\u043d\u0442\u0430", "disk_total": "\u0414\u0438\u0441\u043a \u043e\u0431\u0449\u043e", "disk_used": "\u0418\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u043d \u0434\u0438\u0441\u043a", "docker_version": "\u0412\u0435\u0440\u0441\u0438\u044f \u043d\u0430 Docker", diff --git a/homeassistant/components/here_travel_time/translations/bg.json b/homeassistant/components/here_travel_time/translations/bg.json index 73cda8df2bb..75bb03c2a1f 100644 --- a/homeassistant/components/here_travel_time/translations/bg.json +++ b/homeassistant/components/here_travel_time/translations/bg.json @@ -8,6 +8,12 @@ "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { + "destination_coordinates": { + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0435\u0441\u0442\u0438\u043d\u0430\u0446\u0438\u044f" + }, + "destination_entity_id": { + "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0435\u0441\u0442\u0438\u043d\u0430\u0446\u0438\u044f" + }, "user": { "data": { "api_key": "API \u043a\u043b\u044e\u0447", diff --git a/homeassistant/components/scrape/translations/bg.json b/homeassistant/components/scrape/translations/bg.json new file mode 100644 index 00000000000..164dd477cb5 --- /dev/null +++ b/homeassistant/components/scrape/translations/bg.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "step": { + "user": { + "data": { + "attribute": "\u0410\u0442\u0440\u0438\u0431\u0443\u0442", + "authentication": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "index": "\u0418\u043d\u0434\u0435\u043a\u0441", + "name": "\u0418\u043c\u0435", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0437\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u043d\u0435", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u0410\u0442\u0440\u0438\u0431\u0443\u0442", + "authentication": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "index": "\u0418\u043d\u0434\u0435\u043a\u0441", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0437\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u043d\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/et.json b/homeassistant/components/scrape/translations/et.json new file mode 100644 index 00000000000..14daf835af8 --- /dev/null +++ b/homeassistant/components/scrape/translations/et.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud" + }, + "step": { + "user": { + "data": { + "attribute": "Atribuut", + "authentication": "Tuvastamine", + "device_class": "Seadme klass", + "headers": "P\u00e4ised", + "index": "Indeks", + "name": "Nimi", + "password": "Salas\u00f5na", + "resource": "Resurss", + "select": "Vali", + "state_class": "Oleku klass", + "unit_of_measurement": "M\u00f5\u00f5t\u00fchik", + "username": "Kasutajanimi", + "value_template": "V\u00e4\u00e4rtuse mall", + "verify_ssl": "Kontrolli SSL serti" + }, + "data_description": { + "attribute": "Hangi valitud sildi atribuudi v\u00e4\u00e4rtus", + "authentication": "HTTP-autentimise t\u00fc\u00fcp. Kas basic v\u00f5i digest", + "device_class": "Anduri t\u00fc\u00fcp/klass ikooni seadmiseks kasutajaliideses", + "headers": "Veebip\u00e4ringu jaoks kasutatavad p\u00e4ised", + "index": "M\u00e4\u00e4rab, milliseid CSS selektoriga tagastatud elemente kasutada.", + "resource": "V\u00e4\u00e4rtust sisaldava veebisaidi URL", + "select": "M\u00e4\u00e4rab, millist silti otsida. Lisateavet leiad Beautifulsoup CSS-i valijatest", + "state_class": "Anduri oleku klass", + "value_template": "M\u00e4\u00e4rab malli anduri oleku saamiseks", + "verify_ssl": "Lubab/keelab SSL/TLS-sertifikaadi kontrollimise, n\u00e4iteks kui see on ise allkirjastatud" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atribuut", + "authentication": "Tuvastamine", + "device_class": "Seadme klss", + "headers": "P\u00e4ised", + "index": "Indeks", + "name": "", + "password": "Salas\u00f5na", + "resource": "Resurss", + "select": "Vali", + "state_class": "Oleku klass", + "unit_of_measurement": "M\u00f5\u00f5t\u00fchik", + "username": "", + "value_template": "V\u00e4\u00e4rtuse mall", + "verify_ssl": "" + }, + "data_description": { + "attribute": "Hangi valitud elemendi atribuudi v\u00e4\u00e4rtus", + "authentication": "HTTP kasutaja tuvastamise meetod; algeline v\u00f5i muu", + "device_class": "Kasutajaliidesesse lisatava anduri ikooni t\u00fc\u00fcp/klass", + "headers": "Veebip\u00e4ringus kasutatav p\u00e4is", + "index": "M\u00e4\u00e4rab milline element tagastatakse kasutatava CSS valiku alusel", + "resource": "Veebilehe URL ei sisalda soovitud v\u00e4\u00e4rtusi", + "select": "M\u00e4\u00e4rab otsitava v\u00f5tmes\u00f5na. Vaata Beatifulsoup CSS valimeid", + "state_class": "Anduri olekuklass", + "value_template": "M\u00e4\u00e4rab anduri oleku saamiseks vajaliku malli", + "verify_ssl": "Lubab v\u00f5i keelab SSL/TLS serdi tuvastamise n\u00e4iteks juhul kui sert on ise allkirjastatud" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/ja.json b/homeassistant/components/scrape/translations/ja.json index 3159fa65739..554a9d2c37b 100644 --- a/homeassistant/components/scrape/translations/ja.json +++ b/homeassistant/components/scrape/translations/ja.json @@ -22,9 +22,16 @@ "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" }, "data_description": { + "attribute": "\u9078\u629e\u3057\u305f\u30bf\u30b0\u306e\u5c5e\u6027\u306e\u5024\u3092\u53d6\u5f97\u3059\u308b", + "authentication": "HTTP\u8a8d\u8a3c\u306e\u7a2e\u985e\u3002\u30d9\u30fc\u30b7\u30c3\u30af\u307e\u305f\u306f\u30c0\u30a4\u30b8\u30a7\u30b9\u30c8\u306e\u3069\u3061\u3089\u304b", + "device_class": "\u30d5\u30ed\u30f3\u30c8\u30a8\u30f3\u30c9\u306b\u30a2\u30a4\u30b3\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u30bb\u30f3\u30b5\u30fc\u306e\u30bf\u30a4\u30d7/\u30af\u30e9\u30b9", "headers": "Web\u30ea\u30af\u30a8\u30b9\u30c8\u306b\u4f7f\u7528\u3059\u308b\u30d8\u30c3\u30c0\u30fc", + "index": "CSS\u30bb\u30ec\u30af\u30bf\u304c\u8fd4\u3059\u8981\u7d20\u306e\u3046\u3061\u3001\u3069\u306e\u8981\u7d20\u3092\u4f7f\u7528\u3059\u308b\u304b\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "resource": "\u5024\u3092\u542b\u3080\u30a6\u30a7\u30d6\u30b5\u30a4\u30c8\u306eURL", "select": "\u691c\u7d22\u3059\u308b\u30bf\u30b0\u3092\u5b9a\u7fa9\u3057\u307e\u3059\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001Beautifulsoup CSS selectors\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", - "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)" + "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)", + "value_template": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u3092\u53d6\u5f97\u3059\u308b\u305f\u3081\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "verify_ssl": "SSL/TLS\u8a3c\u660e\u66f8\u306e\u691c\u8a3c\u3092\u6709\u52b9/\u7121\u52b9\u306b\u3057\u307e\u3059\u3002(\u81ea\u5df1\u7f72\u540d\u306e\u5834\u5408\u306a\u3069)" } } } @@ -49,8 +56,16 @@ "verify_ssl": "SSL\u8a3c\u660e\u66f8\u3092\u78ba\u8a8d\u3059\u308b" }, "data_description": { + "attribute": "\u9078\u629e\u3057\u305f\u30bf\u30b0\u306e\u5c5e\u6027\u306e\u5024\u3092\u53d6\u5f97\u3059\u308b", + "authentication": "HTTP\u8a8d\u8a3c\u306e\u7a2e\u985e\u3002\u30d9\u30fc\u30b7\u30c3\u30af\u307e\u305f\u306f\u30c0\u30a4\u30b8\u30a7\u30b9\u30c8\u306e\u3069\u3061\u3089\u304b", + "device_class": "\u30d5\u30ed\u30f3\u30c8\u30a8\u30f3\u30c9\u306b\u30a2\u30a4\u30b3\u30f3\u3092\u8a2d\u5b9a\u3059\u308b\u30bb\u30f3\u30b5\u30fc\u306e\u30bf\u30a4\u30d7/\u30af\u30e9\u30b9", "headers": "Web\u30ea\u30af\u30a8\u30b9\u30c8\u306b\u4f7f\u7528\u3059\u308b\u30d8\u30c3\u30c0\u30fc", - "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)" + "index": "CSS\u30bb\u30ec\u30af\u30bf\u304c\u8fd4\u3059\u8981\u7d20\u306e\u3046\u3061\u3001\u3069\u306e\u8981\u7d20\u3092\u4f7f\u7528\u3059\u308b\u304b\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "resource": "\u5024\u3092\u542b\u3080\u30a6\u30a7\u30d6\u30b5\u30a4\u30c8\u306eURL", + "select": "\u691c\u7d22\u3059\u308b\u30bf\u30b0\u3092\u5b9a\u7fa9\u3057\u307e\u3059\u3002\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001Beautifulsoup CSS selectors\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044", + "state_class": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u30af\u30e9\u30b9(state_class)", + "value_template": "\u30bb\u30f3\u30b5\u30fc\u306e\u72b6\u614b\u3092\u53d6\u5f97\u3059\u308b\u305f\u3081\u306e\u30c6\u30f3\u30d7\u30ec\u30fc\u30c8\u3092\u5b9a\u7fa9\u3057\u307e\u3059", + "verify_ssl": "SSL/TLS\u8a3c\u660e\u66f8\u306e\u691c\u8a3c\u3092\u6709\u52b9/\u7121\u52b9\u306b\u3057\u307e\u3059\u3002(\u81ea\u5df1\u7f72\u540d\u306e\u5834\u5408\u306a\u3069)" } } } diff --git a/homeassistant/components/scrape/translations/nl.json b/homeassistant/components/scrape/translations/nl.json new file mode 100644 index 00000000000..bdc26e94182 --- /dev/null +++ b/homeassistant/components/scrape/translations/nl.json @@ -0,0 +1,57 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd" + }, + "step": { + "user": { + "data": { + "attribute": "Attribuut", + "authentication": "Authenticatie", + "device_class": "Apparaatklasse", + "headers": "Headers", + "index": "Index", + "name": "Naam", + "password": "Wachtwoord", + "resource": "Bron", + "select": "Selecteer", + "state_class": "Staatklasse", + "unit_of_measurement": "Meeteenheid", + "username": "Gebruikersnaam", + "value_template": "Waardetemplate" + }, + "data_description": { + "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", + "resource": "De URL naar de website die de waarde bevat", + "state_class": "De state_class van de sensor" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attribuut", + "authentication": "Authenticatie", + "device_class": "Apparaatklasse", + "headers": "Headers", + "index": "Index", + "name": "Naam", + "password": "Wachtwoord", + "resource": "Bron", + "select": "Selecteer", + "state_class": "Staatklasse", + "unit_of_measurement": "Meeteenheid", + "username": "Gebruikersnaam", + "value_template": "Waardetemplate" + }, + "data_description": { + "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", + "resource": "De URL naar de website die de waarde bevat", + "state_class": "De state_class van de sensor" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/bg.json b/homeassistant/components/skybell/translations/bg.json new file mode 100644 index 00000000000..f3f182bbc3a --- /dev/null +++ b/homeassistant/components/skybell/translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/ca.json b/homeassistant/components/skybell/translations/ca.json new file mode 100644 index 00000000000..6aea0bdfc8f --- /dev/null +++ b/homeassistant/components/skybell/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/de.json b/homeassistant/components/skybell/translations/de.json new file mode 100644 index 00000000000..65a21e4b8f5 --- /dev/null +++ b/homeassistant/components/skybell/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto wurde bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail", + "password": "Passwort" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/el.json b/homeassistant/components/skybell/translations/el.json new file mode 100644 index 00000000000..870068a34fd --- /dev/null +++ b/homeassistant/components/skybell/translations/el.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "reauth_successful": "\u039f \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "invalid_auth": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2", + "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/en.json b/homeassistant/components/skybell/translations/en.json index b84c4ebc999..d996004e5c4 100644 --- a/homeassistant/components/skybell/translations/en.json +++ b/homeassistant/components/skybell/translations/en.json @@ -1,21 +1,21 @@ { - "config": { - "step": { - "user": { - "data": { - "email": "Email", - "password": "Password" + "config": { + "abort": { + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } } - } - }, - "error": { - "invalid_auth": "Invalid authentication", - "cannot_connect": "Failed to connect", - "unknown": "Unexpected error" - }, - "abort": { - "already_configured": "Account is already configured", - "reauth_successful": "Re-authentication was successful" } - } -} +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/et.json b/homeassistant/components/skybell/translations/et.json new file mode 100644 index 00000000000..f6f6392d7a0 --- /dev/null +++ b/homeassistant/components/skybell/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Kasutaja on juba seadistatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "invalid_auth": "Tuvastamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "step": { + "user": { + "data": { + "email": "E-posti aadress", + "password": "Salas\u00f5na" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/fr.json b/homeassistant/components/skybell/translations/fr.json new file mode 100644 index 00000000000..457e7c48157 --- /dev/null +++ b/homeassistant/components/skybell/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "invalid_auth": "Authentification non valide", + "unknown": "Erreur inattendue" + }, + "step": { + "user": { + "data": { + "email": "Courriel", + "password": "Mot de passe" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/ja.json b/homeassistant/components/skybell/translations/ja.json new file mode 100644 index 00000000000..9e2d562206f --- /dev/null +++ b/homeassistant/components/skybell/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "invalid_auth": "\u7121\u52b9\u306a\u8a8d\u8a3c", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "step": { + "user": { + "data": { + "email": "E\u30e1\u30fc\u30eb", + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/nl.json b/homeassistant/components/skybell/translations/nl.json new file mode 100644 index 00000000000..b937f595704 --- /dev/null +++ b/homeassistant/components/skybell/translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Account is al geconfigureerd", + "reauth_successful": "Herauthenticatie geslaagd" + }, + "error": { + "cannot_connect": "Verbindingsfout", + "invalid_auth": "Ongeldige authenticatie", + "unknown": "Onverwachte fout" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Wachtwoord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/pt-BR.json b/homeassistant/components/skybell/translations/pt-BR.json new file mode 100644 index 00000000000..7e7af1a011a --- /dev/null +++ b/homeassistant/components/skybell/translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "A conta j\u00e1 est\u00e1 configurada", + "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", + "unknown": "Erro inesperado" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Senha" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/zh-Hant.json b/homeassistant/components/skybell/translations/zh-Hant.json new file mode 100644 index 00000000000..1e614212c45 --- /dev/null +++ b/homeassistant/components/skybell/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6", + "password": "\u5bc6\u78bc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tankerkoenig/translations/bg.json b/homeassistant/components/tankerkoenig/translations/bg.json index 700c4f3000b..8631e4a1daa 100644 --- a/homeassistant/components/tankerkoenig/translations/bg.json +++ b/homeassistant/components/tankerkoenig/translations/bg.json @@ -1,12 +1,18 @@ { "config": { "abort": { - "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447" + } + }, "user": { "data": { "api_key": "API \u043a\u043b\u044e\u0447", diff --git a/homeassistant/components/tankerkoenig/translations/nl.json b/homeassistant/components/tankerkoenig/translations/nl.json index ea4aea3ff95..57a8a1fcfe7 100644 --- a/homeassistant/components/tankerkoenig/translations/nl.json +++ b/homeassistant/components/tankerkoenig/translations/nl.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Locatie is al geconfigureerd" + "already_configured": "Locatie is al geconfigureerd", + "reauth_successful": "Herauthenticatie geslaagd" }, "error": { "invalid_auth": "Ongeldige authenticatie", "no_stations": "Kon geen station in bereik vinden." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API-sleutel" + } + }, "select_station": { "data": { "stations": "Stations" From 9ea504dd7b51f22bcc50455e235487c6a8b862e1 Mon Sep 17 00:00:00 2001 From: hesselonline Date: Mon, 6 Jun 2022 03:31:09 +0200 Subject: [PATCH 248/947] Bump wallbox to 0.4.9 (#72978) --- .../components/wallbox/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wallbox/__init__.py | 39 ++++++++++++---- tests/components/wallbox/test_config_flow.py | 44 +++++-------------- tests/components/wallbox/test_init.py | 18 ++------ tests/components/wallbox/test_lock.py | 26 ++--------- tests/components/wallbox/test_number.py | 27 ++---------- tests/components/wallbox/test_switch.py | 29 +++--------- 9 files changed, 61 insertions(+), 128 deletions(-) diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index 2a4978b1cc1..914adda980a 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -3,7 +3,7 @@ "name": "Wallbox", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wallbox", - "requirements": ["wallbox==0.4.4"], + "requirements": ["wallbox==0.4.9"], "ssdp": [], "zeroconf": [], "homekit": {}, diff --git a/requirements_all.txt b/requirements_all.txt index f365da06766..34c3904aab9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2418,7 +2418,7 @@ vultr==0.1.2 wakeonlan==2.0.1 # homeassistant.components.wallbox -wallbox==0.4.4 +wallbox==0.4.9 # homeassistant.components.waqi waqiasync==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3178a0822f..6b8b4d0dc35 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1597,7 +1597,7 @@ vultr==0.1.2 wakeonlan==2.0.1 # homeassistant.components.wallbox -wallbox==0.4.4 +wallbox==0.4.9 # homeassistant.components.folder_watcher watchdog==2.1.8 diff --git a/tests/components/wallbox/__init__.py b/tests/components/wallbox/__init__.py index 2b35bb76b2f..8c979c42ebe 100644 --- a/tests/components/wallbox/__init__.py +++ b/tests/components/wallbox/__init__.py @@ -26,7 +26,7 @@ from homeassistant.components.wallbox.const import ( from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from .const import ERROR, JWT, STATUS, TTL, USER_ID +from .const import ERROR, STATUS, TTL, USER_ID from tests.common import MockConfigEntry @@ -54,11 +54,32 @@ test_response = json.loads( authorisation_response = json.loads( json.dumps( { - JWT: "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, + "data": { + "attributes": { + "token": "fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + ERROR: "false", + STATUS: 200, + } + } + } + ) +) + + +authorisation_response_unauthorised = json.loads( + json.dumps( + { + "data": { + "attributes": { + "token": "fakekeyhere", + USER_ID: 12345, + TTL: 145656758, + ERROR: "false", + STATUS: 404, + } + } } ) ) @@ -81,7 +102,7 @@ async def setup_integration(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=HTTPStatus.OK, ) @@ -107,7 +128,7 @@ async def setup_integration_connection_error(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=HTTPStatus.FORBIDDEN, ) @@ -133,7 +154,7 @@ async def setup_integration_read_only(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=HTTPStatus.OK, ) diff --git a/tests/components/wallbox/test_config_flow.py b/tests/components/wallbox/test_config_flow.py index a3e6a724eef..68f70878592 100644 --- a/tests/components/wallbox/test_config_flow.py +++ b/tests/components/wallbox/test_config_flow.py @@ -18,8 +18,12 @@ from homeassistant.components.wallbox.const import ( ) from homeassistant.core import HomeAssistant -from tests.components.wallbox import entry, setup_integration -from tests.components.wallbox.const import ERROR, JWT, STATUS, TTL, USER_ID +from tests.components.wallbox import ( + authorisation_response, + authorisation_response_unauthorised, + entry, + setup_integration, +) test_response = json.loads( json.dumps( @@ -34,30 +38,6 @@ test_response = json.loads( ) ) -authorisation_response = json.loads( - json.dumps( - { - JWT: "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, - } - ) -) - -authorisation_response_unauthorised = json.loads( - json.dumps( - { - JWT: "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 404, - } - ) -) - async def test_show_set_form(hass: HomeAssistant) -> None: """Test that the setup form is served.""" @@ -77,7 +57,7 @@ async def test_form_cannot_authenticate(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=HTTPStatus.FORBIDDEN, ) @@ -107,7 +87,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response_unauthorised, status_code=HTTPStatus.NOT_FOUND, ) @@ -137,7 +117,7 @@ async def test_form_validate_input(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=HTTPStatus.OK, ) @@ -166,8 +146,8 @@ async def test_form_reauth(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", - text='{"jwt":"fakekeyhere","user_id":12345,"ttl":145656758,"error":false,"status":200}', + "https://user-api.wall-box.com/users/signin", + json=authorisation_response, status_code=200, ) mock_request.get( @@ -206,7 +186,7 @@ async def test_form_reauth_invalid(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", text='{"jwt":"fakekeyhere","user_id":12345,"ttl":145656758,"error":false,"status":200}', status_code=200, ) diff --git a/tests/components/wallbox/test_init.py b/tests/components/wallbox/test_init.py index b8862531a82..5080bab87ea 100644 --- a/tests/components/wallbox/test_init.py +++ b/tests/components/wallbox/test_init.py @@ -11,24 +11,12 @@ from . import test_response from tests.components.wallbox import ( DOMAIN, + authorisation_response, entry, setup_integration, setup_integration_connection_error, setup_integration_read_only, ) -from tests.components.wallbox.const import ERROR, JWT, STATUS, TTL, USER_ID - -authorisation_response = json.loads( - json.dumps( - { - JWT: "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, - } - ) -) async def test_wallbox_setup_unload_entry(hass: HomeAssistant) -> None: @@ -59,7 +47,7 @@ async def test_wallbox_refresh_failed_invalid_auth(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=403, ) @@ -85,7 +73,7 @@ async def test_wallbox_refresh_failed_connection_error(hass: HomeAssistant) -> N with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) diff --git a/tests/components/wallbox/test_lock.py b/tests/components/wallbox/test_lock.py index d8b2fcf182c..fbcb07b0e90 100644 --- a/tests/components/wallbox/test_lock.py +++ b/tests/components/wallbox/test_lock.py @@ -10,30 +10,12 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from tests.components.wallbox import ( + authorisation_response, entry, setup_integration, setup_integration_read_only, ) -from tests.components.wallbox.const import ( - ERROR, - JWT, - MOCK_LOCK_ENTITY_ID, - STATUS, - TTL, - USER_ID, -) - -authorisation_response = json.loads( - json.dumps( - { - JWT: "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, - } - ) -) +from tests.components.wallbox.const import MOCK_LOCK_ENTITY_ID async def test_wallbox_lock_class(hass: HomeAssistant) -> None: @@ -47,7 +29,7 @@ async def test_wallbox_lock_class(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) @@ -85,7 +67,7 @@ async def test_wallbox_lock_class_connection_error(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) diff --git a/tests/components/wallbox/test_number.py b/tests/components/wallbox/test_number.py index 5c024d0f4ac..c8e8b29f28b 100644 --- a/tests/components/wallbox/test_number.py +++ b/tests/components/wallbox/test_number.py @@ -9,27 +9,8 @@ from homeassistant.components.wallbox import CHARGER_MAX_CHARGING_CURRENT_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from tests.components.wallbox import entry, setup_integration -from tests.components.wallbox.const import ( - ERROR, - JWT, - MOCK_NUMBER_ENTITY_ID, - STATUS, - TTL, - USER_ID, -) - -authorisation_response = json.loads( - json.dumps( - { - JWT: "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, - } - ) -) +from tests.components.wallbox import authorisation_response, entry, setup_integration +from tests.components.wallbox.const import MOCK_NUMBER_ENTITY_ID async def test_wallbox_number_class(hass: HomeAssistant) -> None: @@ -39,7 +20,7 @@ async def test_wallbox_number_class(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) @@ -68,7 +49,7 @@ async def test_wallbox_number_class_connection_error(hass: HomeAssistant) -> Non with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) diff --git a/tests/components/wallbox/test_switch.py b/tests/components/wallbox/test_switch.py index 6ade320319a..c57fee0353f 100644 --- a/tests/components/wallbox/test_switch.py +++ b/tests/components/wallbox/test_switch.py @@ -10,27 +10,8 @@ from homeassistant.components.wallbox.const import CHARGER_STATUS_ID_KEY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from tests.components.wallbox import entry, setup_integration -from tests.components.wallbox.const import ( - ERROR, - JWT, - MOCK_SWITCH_ENTITY_ID, - STATUS, - TTL, - USER_ID, -) - -authorisation_response = json.loads( - json.dumps( - { - JWT: "fakekeyhere", - USER_ID: 12345, - TTL: 145656758, - ERROR: "false", - STATUS: 200, - } - ) -) +from tests.components.wallbox import authorisation_response, entry, setup_integration +from tests.components.wallbox.const import MOCK_SWITCH_ENTITY_ID async def test_wallbox_switch_class(hass: HomeAssistant) -> None: @@ -44,7 +25,7 @@ async def test_wallbox_switch_class(hass: HomeAssistant) -> None: with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) @@ -82,7 +63,7 @@ async def test_wallbox_switch_class_connection_error(hass: HomeAssistant) -> Non with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) @@ -121,7 +102,7 @@ async def test_wallbox_switch_class_authentication_error(hass: HomeAssistant) -> with requests_mock.Mocker() as mock_request: mock_request.get( - "https://api.wall-box.com/auth/token/user", + "https://user-api.wall-box.com/users/signin", json=authorisation_response, status_code=200, ) From a6f6f0ac5e662751b90239872e63532b7efe9bf6 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 5 Jun 2022 19:33:27 -0600 Subject: [PATCH 249/947] Fix unhandled exception when RainMachine coordinator data doesn't exist (#73055) --- homeassistant/components/rainmachine/binary_sensor.py | 7 +++++-- homeassistant/components/rainmachine/sensor.py | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py index 1818222a8f4..7a13515db3b 100644 --- a/homeassistant/components/rainmachine/binary_sensor.py +++ b/homeassistant/components/rainmachine/binary_sensor.py @@ -139,8 +139,11 @@ async def async_setup_entry( entry, coordinator, controller, description ) for description in BINARY_SENSOR_DESCRIPTIONS - if (coordinator := coordinators[description.api_category]) is not None - and key_exists(coordinator.data, description.data_key) + if ( + (coordinator := coordinators[description.api_category]) is not None + and coordinator.data + and key_exists(coordinator.data, description.data_key) + ) ] ) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 522c57cf7a2..cc37189aa49 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -133,8 +133,11 @@ async def async_setup_entry( entry, coordinator, controller, description ) for description in SENSOR_DESCRIPTIONS - if (coordinator := coordinators[description.api_category]) is not None - and key_exists(coordinator.data, description.data_key) + if ( + (coordinator := coordinators[description.api_category]) is not None + and coordinator.data + and key_exists(coordinator.data, description.data_key) + ) ] zone_coordinator = coordinators[DATA_ZONES] From 5fe9e8cb1cf04041aca29780f696f1f191d3da3d Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Mon, 6 Jun 2022 03:39:42 +0200 Subject: [PATCH 250/947] Throttle multiple requests to the velux gateway (#72974) --- homeassistant/components/velux/cover.py | 11 ++++++----- homeassistant/components/velux/light.py | 2 ++ homeassistant/components/velux/scene.py | 2 ++ 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index f4f449509a0..26cccfce6ce 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -17,6 +17,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_VELUX, VeluxEntity +PARALLEL_UPDATES = 1 + async def async_setup_platform( hass: HomeAssistant, @@ -97,12 +99,11 @@ class VeluxCover(VeluxEntity, CoverEntity): async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" - if ATTR_POSITION in kwargs: - position_percent = 100 - kwargs[ATTR_POSITION] + position_percent = 100 - kwargs[ATTR_POSITION] - await self.node.set_position( - Position(position_percent=position_percent), wait_for_completion=False - ) + await self.node.set_position( + Position(position_percent=position_percent), wait_for_completion=False + ) async def async_stop_cover(self, **kwargs): """Stop the cover.""" diff --git a/homeassistant/components/velux/light.py b/homeassistant/components/velux/light.py index e6ca8ea5c2b..f8a52fc05c1 100644 --- a/homeassistant/components/velux/light.py +++ b/homeassistant/components/velux/light.py @@ -10,6 +10,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_VELUX, VeluxEntity +PARALLEL_UPDATES = 1 + async def async_setup_platform( hass: HomeAssistant, diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index 324bae027fc..20f94c74f0b 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -10,6 +10,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import _LOGGER, DATA_VELUX +PARALLEL_UPDATES = 1 + async def async_setup_platform( hass: HomeAssistant, From c4763031ab47d2cd9eaad965c0eed506a8f83306 Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sun, 5 Jun 2022 22:27:21 -0400 Subject: [PATCH 251/947] Fix elk attributes not being json serializable (#73096) * Fix jsonifying. * Only serialize Enums --- homeassistant/components/elkm1/__init__.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 9a7c7dbc43a..cf1cac3bdb3 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from enum import Enum import logging import re from types import MappingProxyType @@ -481,7 +482,10 @@ class ElkEntity(Entity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return the default attributes of the element.""" - return {**self._element.as_dict(), **self.initial_attrs()} + dict_as_str = {} + for key, val in self._element.as_dict().items(): + dict_as_str[key] = val.value if isinstance(val, Enum) else val + return {**dict_as_str, **self.initial_attrs()} @property def available(self) -> bool: From 1744e7224b126fd1b8bc7c5f2b3eb5f341b7e0b9 Mon Sep 17 00:00:00 2001 From: Igor Loborec Date: Mon, 6 Jun 2022 03:27:46 +0100 Subject: [PATCH 252/947] Remove available property from Kodi (#73103) --- homeassistant/components/kodi/media_player.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index e19ffc6219c..2b509ed0e08 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -636,11 +636,6 @@ class KodiEntity(MediaPlayerEntity): return None - @property - def available(self): - """Return True if entity is available.""" - return not self._connect_error - async def async_turn_on(self): """Turn the media player on.""" _LOGGER.debug("Firing event to turn on device") From db53ab5fd0e22fe109ca76953b81d0b074f54950 Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 6 Jun 2022 11:58:29 +0800 Subject: [PATCH 253/947] Add Yolink lock support (#73069) * Add yolink lock support * Update .coveragerct * extract the commons --- .coveragerc | 1 + homeassistant/components/yolink/__init__.py | 8 ++- .../components/yolink/binary_sensor.py | 5 +- homeassistant/components/yolink/const.py | 1 + homeassistant/components/yolink/entity.py | 18 +++++ homeassistant/components/yolink/lock.py | 65 +++++++++++++++++++ homeassistant/components/yolink/sensor.py | 7 +- homeassistant/components/yolink/siren.py | 17 +---- homeassistant/components/yolink/switch.py | 17 +---- 9 files changed, 105 insertions(+), 34 deletions(-) create mode 100644 homeassistant/components/yolink/lock.py diff --git a/.coveragerc b/.coveragerc index 0f9c3e4998a..75ed4663869 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1495,6 +1495,7 @@ omit = homeassistant/components/yolink/const.py homeassistant/components/yolink/coordinator.py homeassistant/components/yolink/entity.py + homeassistant/components/yolink/lock.py homeassistant/components/yolink/sensor.py homeassistant/components/yolink/siren.py homeassistant/components/yolink/switch.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 7eb6b0229f0..21d36d33a30 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -24,7 +24,13 @@ from .coordinator import YoLinkCoordinator SCAN_INTERVAL = timedelta(minutes=5) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SIREN, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index d5c9ddedb84..17b25c57d94 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -96,7 +96,7 @@ async def async_setup_entry( if description.exists_fn(binary_sensor_device_coordinator.device): entities.append( YoLinkBinarySensorEntity( - binary_sensor_device_coordinator, description + config_entry, binary_sensor_device_coordinator, description ) ) async_add_entities(entities) @@ -109,11 +109,12 @@ class YoLinkBinarySensorEntity(YoLinkEntity, BinarySensorEntity): def __init__( self, + config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkBinarySensorEntityDescription, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator) + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 16304e0de4b..44f4f3104f7 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -20,3 +20,4 @@ ATTR_DEVICE_LEAK_SENSOR = "LeakSensor" ATTR_DEVICE_VIBRATION_SENSOR = "VibrationSensor" ATTR_DEVICE_OUTLET = "Outlet" ATTR_DEVICE_SIREN = "Siren" +ATTR_DEVICE_LOCK = "Lock" diff --git a/homeassistant/components/yolink/entity.py b/homeassistant/components/yolink/entity.py index 5365681739e..02f063a282a 100644 --- a/homeassistant/components/yolink/entity.py +++ b/homeassistant/components/yolink/entity.py @@ -3,7 +3,11 @@ from __future__ import annotations from abc import abstractmethod +from yolink.exception import YoLinkAuthFailError, YoLinkClientError + +from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -16,10 +20,12 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): def __init__( self, + config_entry: ConfigEntry, coordinator: YoLinkCoordinator, ) -> None: """Init YoLink Entity.""" super().__init__(coordinator) + self.config_entry = config_entry @property def device_id(self) -> str: @@ -52,3 +58,15 @@ class YoLinkEntity(CoordinatorEntity[YoLinkCoordinator]): @abstractmethod def update_entity_state(self, state: dict) -> None: """Parse and update entity state, should be overridden.""" + + async def call_device_api(self, command: str, params: dict) -> None: + """Call device Api.""" + try: + # call_device_http_api will check result, fail by raise YoLinkClientError + await self.coordinator.device.call_device_http_api(command, params) + except YoLinkAuthFailError as yl_auth_err: + self.config_entry.async_start_reauth(self.hass) + raise HomeAssistantError(yl_auth_err) from yl_auth_err + except YoLinkClientError as yl_client_err: + self.coordinator.last_update_success = False + raise HomeAssistantError(yl_client_err) from yl_client_err diff --git a/homeassistant/components/yolink/lock.py b/homeassistant/components/yolink/lock.py new file mode 100644 index 00000000000..ca340c2e762 --- /dev/null +++ b/homeassistant/components/yolink/lock.py @@ -0,0 +1,65 @@ +"""YoLink Lock.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.lock import LockEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_COORDINATORS, ATTR_DEVICE_LOCK, DOMAIN +from .coordinator import YoLinkCoordinator +from .entity import YoLinkEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up YoLink lock from a config entry.""" + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + entities = [ + YoLinkLockEntity(config_entry, device_coordinator) + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type == ATTR_DEVICE_LOCK + ] + async_add_entities(entities) + + +class YoLinkLockEntity(YoLinkEntity, LockEntity): + """YoLink Lock Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: YoLinkCoordinator, + ) -> None: + """Init YoLink Lock.""" + super().__init__(config_entry, coordinator) + self._attr_unique_id = f"{coordinator.device.device_id}_lock_state" + self._attr_name = f"{coordinator.device.device_name}(LockState)" + + @callback + def update_entity_state(self, state: dict[str, Any]) -> None: + """Update HA Entity State.""" + state_value = state.get("state") + self._attr_is_locked = ( + state_value == "locked" if state_value is not None else None + ) + self.async_write_ha_state() + + async def call_lock_state_change(self, state: str) -> None: + """Call setState api to change lock state.""" + await self.call_device_api("setState", {"state": state}) + self._attr_is_locked = state == "lock" + self.async_write_ha_state() + + async def async_lock(self, **kwargs: Any) -> None: + """Lock device.""" + await self.call_lock_state_change("lock") + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock device.""" + await self.call_lock_state_change("unlock") diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 917a93c310d..e0d746219fb 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -21,6 +21,7 @@ from homeassistant.util import percentage from .const import ( ATTR_COORDINATORS, ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_LOCK, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, @@ -51,6 +52,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_LOCK, ] BATTERY_POWER_SENSOR = [ @@ -58,6 +60,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_LOCK, ] @@ -112,6 +115,7 @@ async def async_setup_entry( if description.exists_fn(sensor_device_coordinator.device): entities.append( YoLinkSensorEntity( + config_entry, sensor_device_coordinator, description, ) @@ -126,11 +130,12 @@ class YoLinkSensorEntity(YoLinkEntity, SensorEntity): def __init__( self, + config_entry: ConfigEntry, coordinator: YoLinkCoordinator, description: YoLinkSensorEntityDescription, ) -> None: """Init YoLink Sensor.""" - super().__init__(coordinator) + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" diff --git a/homeassistant/components/yolink/siren.py b/homeassistant/components/yolink/siren.py index 7e67dfb12f1..fd1c8e89e07 100644 --- a/homeassistant/components/yolink/siren.py +++ b/homeassistant/components/yolink/siren.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from typing import Any from yolink.device import YoLinkDevice -from yolink.exception import YoLinkAuthFailError, YoLinkClientError from homeassistant.components.siren import ( SirenEntity, @@ -15,7 +14,6 @@ from homeassistant.components.siren import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_COORDINATORS, ATTR_DEVICE_SIREN, DOMAIN @@ -79,8 +77,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): description: YoLinkSirenEntityDescription, ) -> None: """Init YoLink Siren.""" - super().__init__(coordinator) - self.config_entry = config_entry + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" @@ -102,17 +99,7 @@ class YoLinkSirenEntity(YoLinkEntity, SirenEntity): async def call_state_change(self, state: bool) -> None: """Call setState api to change siren state.""" - try: - # call_device_http_api will check result, fail by raise YoLinkClientError - await self.coordinator.device.call_device_http_api( - "setState", {"state": {"alarm": state}} - ) - except YoLinkAuthFailError as yl_auth_err: - self.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(yl_auth_err) from yl_auth_err - except YoLinkClientError as yl_client_err: - self.coordinator.last_update_success = False - raise HomeAssistantError(yl_client_err) from yl_client_err + await self.call_device_api("setState", {"state": {"alarm": state}}) self._attr_is_on = self.entity_description.value("alert" if state else "normal") self.async_write_ha_state() diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index f16dc781a9c..723043100b3 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -6,7 +6,6 @@ from dataclasses import dataclass from typing import Any from yolink.device import YoLinkDevice -from yolink.exception import YoLinkAuthFailError, YoLinkClientError from homeassistant.components.switch import ( SwitchDeviceClass, @@ -15,7 +14,6 @@ from homeassistant.components.switch import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ATTR_COORDINATORS, ATTR_DEVICE_OUTLET, DOMAIN @@ -80,8 +78,7 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): description: YoLinkSwitchEntityDescription, ) -> None: """Init YoLink Outlet.""" - super().__init__(coordinator) - self.config_entry = config_entry + super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( f"{coordinator.device.device_id} {self.entity_description.key}" @@ -100,17 +97,7 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): async def call_state_change(self, state: str) -> None: """Call setState api to change outlet state.""" - try: - # call_device_http_api will check result, fail by raise YoLinkClientError - await self.coordinator.device.call_device_http_api( - "setState", {"state": state} - ) - except YoLinkAuthFailError as yl_auth_err: - self.config_entry.async_start_reauth(self.hass) - raise HomeAssistantError(yl_auth_err) from yl_auth_err - except YoLinkClientError as yl_client_err: - self.coordinator.last_update_success = False - raise HomeAssistantError(yl_client_err) from yl_client_err + await self.call_device_api("setState", {"state": state}) self._attr_is_on = self.entity_description.value(state) self.async_write_ha_state() From 3744edc512fb9d4e4f7acf2405fe086105e114ad Mon Sep 17 00:00:00 2001 From: lymanepp <4195527+lymanepp@users.noreply.github.com> Date: Mon, 6 Jun 2022 00:10:33 -0400 Subject: [PATCH 254/947] Tomorrowio utc fix (#73102) * Discard past data using local time instead of UTC * Tweak changes to fix tests * Cleanup --- homeassistant/components/tomorrowio/weather.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index bde6e6b996b..346f673362e 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -203,13 +203,16 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): max_forecasts = MAX_FORECASTS[self.forecast_type] forecast_count = 0 + # Convert utcnow to local to be compatible with tests + today = dt_util.as_local(dt_util.utcnow()).date() + # Set default values (in cases where keys don't exist), None will be # returned. Override properties per forecast type as needed for forecast in raw_forecasts: forecast_dt = dt_util.parse_datetime(forecast[TMRW_ATTR_TIMESTAMP]) # Throw out past data - if forecast_dt.date() < dt_util.utcnow().date(): + if dt_util.as_local(forecast_dt).date() < today: continue values = forecast["values"] From c66b000d34b2a22fbaef3dfd4f6ea4b7a807bacc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jun 2022 18:13:31 -1000 Subject: [PATCH 255/947] Reduce branching in generated lambda_stmts (#73042) --- homeassistant/components/recorder/history.py | 33 ++++-- .../components/recorder/statistics.py | 103 ++++++++++++------ tests/components/recorder/test_statistics.py | 62 +++++++++++ 3 files changed, 153 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 49796bd0158..5dd5c0d3040 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -15,6 +15,7 @@ from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Subquery from homeassistant.components import recorder from homeassistant.components.websocket_api.const import ( @@ -485,6 +486,25 @@ def _get_states_for_entites_stmt( return stmt +def _generate_most_recent_states_by_date( + run_start: datetime, + utc_point_in_time: datetime, +) -> Subquery: + """Generate the sub query for the most recent states by data.""" + return ( + select( + States.entity_id.label("max_entity_id"), + func.max(States.last_updated).label("max_last_updated"), + ) + .filter( + (States.last_updated >= run_start) + & (States.last_updated < utc_point_in_time) + ) + .group_by(States.entity_id) + .subquery() + ) + + def _get_states_for_all_stmt( schema_version: int, run_start: datetime, @@ -500,17 +520,8 @@ def _get_states_for_all_stmt( # query, then filter out unwanted domains as well as applying the custom filter. # This filtering can't be done in the inner query because the domain column is # not indexed and we can't control what's in the custom filter. - most_recent_states_by_date = ( - select( - States.entity_id.label("max_entity_id"), - func.max(States.last_updated).label("max_last_updated"), - ) - .filter( - (States.last_updated >= run_start) - & (States.last_updated < utc_point_in_time) - ) - .group_by(States.entity_id) - .subquery() + most_recent_states_by_date = _generate_most_recent_states_by_date( + run_start, utc_point_in_time ) stmt += lambda q: q.where( States.state_id diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 39fcb954ee9..012b34ec0ef 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -20,6 +20,7 @@ from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal_column, true from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Subquery import voluptuous as vol from homeassistant.const import ( @@ -484,14 +485,13 @@ def _compile_hourly_statistics_summary_mean_stmt( start_time: datetime, end_time: datetime ) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN)) - stmt += ( - lambda q: q.filter(StatisticsShortTerm.start >= start_time) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN) + .filter(StatisticsShortTerm.start >= start_time) .filter(StatisticsShortTerm.start < end_time) .group_by(StatisticsShortTerm.metadata_id) .order_by(StatisticsShortTerm.metadata_id) ) - return stmt def compile_hourly_statistics( @@ -985,26 +985,43 @@ def _statistics_during_period_stmt( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, - table: type[Statistics | StatisticsShortTerm], ) -> StatementLambdaElement: """Prepare a database query for statistics during a given period. This prepares a lambda_stmt query, so we don't insert the parameters yet. """ - if table == StatisticsShortTerm: - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) - else: - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS)) - - stmt += lambda q: q.filter(table.start >= start_time) - + stmt = lambda_stmt( + lambda: select(*QUERY_STATISTICS).filter(Statistics.start >= start_time) + ) if end_time is not None: - stmt += lambda q: q.filter(table.start < end_time) - + stmt += lambda q: q.filter(Statistics.start < end_time) if metadata_ids: - stmt += lambda q: q.filter(table.metadata_id.in_(metadata_ids)) + stmt += lambda q: q.filter(Statistics.metadata_id.in_(metadata_ids)) + stmt += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) + return stmt - stmt += lambda q: q.order_by(table.metadata_id, table.start) + +def _statistics_during_period_stmt_short_term( + start_time: datetime, + end_time: datetime | None, + metadata_ids: list[int] | None, +) -> StatementLambdaElement: + """Prepare a database query for short term statistics during a given period. + + This prepares a lambda_stmt query, so we don't insert the parameters yet. + """ + stmt = lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM).filter( + StatisticsShortTerm.start >= start_time + ) + ) + if end_time is not None: + stmt += lambda q: q.filter(StatisticsShortTerm.start < end_time) + if metadata_ids: + stmt += lambda q: q.filter(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + stmt += lambda q: q.order_by( + StatisticsShortTerm.metadata_id, StatisticsShortTerm.start + ) return stmt @@ -1034,10 +1051,12 @@ def statistics_during_period( if period == "5minute": table = StatisticsShortTerm + stmt = _statistics_during_period_stmt_short_term( + start_time, end_time, metadata_ids + ) else: table = Statistics - - stmt = _statistics_during_period_stmt(start_time, end_time, metadata_ids, table) + stmt = _statistics_during_period_stmt(start_time, end_time, metadata_ids) stats = execute_stmt_lambda_element(session, stmt) if not stats: @@ -1069,19 +1088,27 @@ def statistics_during_period( def _get_last_statistics_stmt( metadata_id: int, number_of_stats: int, - table: type[Statistics | StatisticsShortTerm], ) -> StatementLambdaElement: """Generate a statement for number_of_stats statistics for a given statistic_id.""" - if table == StatisticsShortTerm: - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) - else: - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS)) - stmt += ( - lambda q: q.filter_by(metadata_id=metadata_id) - .order_by(table.metadata_id, table.start.desc()) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS) + .filter_by(metadata_id=metadata_id) + .order_by(Statistics.metadata_id, Statistics.start.desc()) + .limit(number_of_stats) + ) + + +def _get_last_statistics_short_term_stmt( + metadata_id: int, + number_of_stats: int, +) -> StatementLambdaElement: + """Generate a statement for number_of_stats short term statistics for a given statistic_id.""" + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM) + .filter_by(metadata_id=metadata_id) + .order_by(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc()) .limit(number_of_stats) ) - return stmt def _get_last_statistics( @@ -1099,7 +1126,10 @@ def _get_last_statistics( if not metadata: return {} metadata_id = metadata[statistic_id][0] - stmt = _get_last_statistics_stmt(metadata_id, number_of_stats, table) + if table == Statistics: + stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) + else: + stmt = _get_last_statistics_short_term_stmt(metadata_id, number_of_stats) stats = execute_stmt_lambda_element(session, stmt) if not stats: @@ -1136,12 +1166,9 @@ def get_last_short_term_statistics( ) -def _latest_short_term_statistics_stmt( - metadata_ids: list[int], -) -> StatementLambdaElement: - """Create the statement for finding the latest short term stat rows.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) - most_recent_statistic_row = ( +def _generate_most_recent_statistic_row(metadata_ids: list[int]) -> Subquery: + """Generate the subquery to find the most recent statistic row.""" + return ( select( StatisticsShortTerm.metadata_id, func.max(StatisticsShortTerm.start).label("start_max"), @@ -1149,6 +1176,14 @@ def _latest_short_term_statistics_stmt( .where(StatisticsShortTerm.metadata_id.in_(metadata_ids)) .group_by(StatisticsShortTerm.metadata_id) ).subquery() + + +def _latest_short_term_statistics_stmt( + metadata_ids: list[int], +) -> StatementLambdaElement: + """Create the statement for finding the latest short term stat rows.""" + stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) + most_recent_statistic_row = _generate_most_recent_statistic_row(metadata_ids) stmt += lambda s: s.join( most_recent_statistic_row, ( diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 882f00d2940..97e64716f49 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -100,6 +100,15 @@ def test_compile_hourly_statistics(hass_recorder): stats = statistics_during_period(hass, zero, period="5minute") assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + # Test statistics_during_period with a far future start and end date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period(hass, future, end_time=future, period="5minute") + assert stats == {} + + # Test statistics_during_period with a far future end date + stats = statistics_during_period(hass, zero, end_time=future, period="5minute") + assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + stats = statistics_during_period( hass, zero, statistic_ids=["sensor.test2"], period="5minute" ) @@ -814,6 +823,59 @@ def test_monthly_statistics(hass_recorder, caplog, timezone): ] } + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["not", "the", "same", "test:total_energy_import"], + period="month", + ) + sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) + sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) + oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) + assert stats == { + "test:total_energy_import": [ + { + "statistic_id": "test:total_energy_import", + "start": sep_start.isoformat(), + "end": sep_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(1.0), + "sum": approx(3.0), + }, + { + "statistic_id": "test:total_energy_import", + "start": oct_start.isoformat(), + "end": oct_end.isoformat(), + "max": None, + "mean": None, + "min": None, + "last_reset": None, + "state": approx(3.0), + "sum": approx(5.0), + }, + ] + } + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["test:total_energy_import", "with_other"], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="month" + ) + assert stats == {} + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) From 6b2e5858b3a833d2792b20917a8ba787060b5c22 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jun 2022 18:14:47 -1000 Subject: [PATCH 256/947] Send an empty logbook response when all requested entity_ids are filtered away (#73046) --- .../components/logbook/websocket_api.py | 38 ++++- .../components/logbook/test_websocket_api.py | 157 +++++++++++++++++- 2 files changed, 184 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 1af44440803..b27ae65b70c 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -67,6 +67,23 @@ async def _async_wait_for_recorder_sync(hass: HomeAssistant) -> None: ) +@callback +def _async_send_empty_response( + connection: ActiveConnection, msg_id: int, start_time: dt, end_time: dt | None +) -> None: + """Send an empty response. + + The current case for this is when they ask for entity_ids + that will all be filtered away because they have UOMs or + state_class. + """ + connection.send_result(msg_id) + stream_end_time = end_time or dt_util.utcnow() + empty_stream_message = _generate_stream_message([], start_time, stream_end_time) + empty_response = messages.event_message(msg_id, empty_stream_message) + connection.send_message(JSON_DUMP(empty_response)) + + async def _async_send_historical_events( hass: HomeAssistant, connection: ActiveConnection, @@ -171,6 +188,17 @@ async def _async_get_ws_stream_events( ) +def _generate_stream_message( + events: list[dict[str, Any]], start_day: dt, end_day: dt +) -> dict[str, Any]: + """Generate a logbook stream message response.""" + return { + "events": events, + "start_time": dt_util.utc_to_timestamp(start_day), + "end_time": dt_util.utc_to_timestamp(end_day), + } + + def _ws_stream_get_events( msg_id: int, start_day: dt, @@ -184,11 +212,7 @@ def _ws_stream_get_events( last_time = None if events: last_time = dt_util.utc_from_timestamp(events[-1]["when"]) - message = { - "events": events, - "start_time": dt_util.utc_to_timestamp(start_day), - "end_time": dt_util.utc_to_timestamp(end_day), - } + message = _generate_stream_message(events, start_day, end_day) if partial: # This is a hint to consumers of the api that # we are about to send a another block of historical @@ -275,6 +299,10 @@ async def ws_event_stream( entity_ids = msg.get("entity_ids") if entity_ids: entity_ids = async_filter_entities(hass, entity_ids) + if not entity_ids: + _async_send_empty_response(connection, msg_id, start_time, end_time) + return + event_types = async_determine_event_types(hass, entity_ids, device_ids) event_processor = EventProcessor( hass, diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 2dd08ec44ce..2623a5b17d5 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2102,11 +2102,17 @@ async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_clie ] ) await async_wait_recording_done(hass) + entity_ids = ("sensor.uom", "sensor.uom_two") + + def _cycle_entities(): + for entity_id in entity_ids: + for state in ("1", "2", "3"): + hass.states.async_set( + entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} + ) init_count = sum(hass.bus.async_listeners().values()) - hass.states.async_set("sensor.uom", "1", {ATTR_UNIT_OF_MEASUREMENT: "any"}) - hass.states.async_set("sensor.uom", "2", {ATTR_UNIT_OF_MEASUREMENT: "any"}) - hass.states.async_set("sensor.uom", "3", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + _cycle_entities() await async_wait_recording_done(hass) websocket_client = await hass_ws_client() @@ -2124,9 +2130,61 @@ async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_clie assert msg["type"] == TYPE_RESULT assert msg["success"] - hass.states.async_set("sensor.uom", "1", {ATTR_UNIT_OF_MEASUREMENT: "any"}) - hass.states.async_set("sensor.uom", "2", {ATTR_UNIT_OF_MEASUREMENT: "any"}) - hass.states.async_set("sensor.uom", "3", {ATTR_UNIT_OF_MEASUREMENT: "any"}) + _cycle_entities() + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + + await websocket_client.close() + await hass.async_block_till_done() + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_all_entities_have_uom_multiple( + hass, recorder_mock, hass_ws_client +): + """Test logbook stream with specific request for multiple entities that are always filtered.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + await async_wait_recording_done(hass) + entity_ids = ("sensor.uom", "sensor.uom_two") + + def _cycle_entities(): + for entity_id in entity_ids: + for state in ("1", "2", "3"): + hass.states.async_set( + entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} + ) + + init_count = sum(hass.bus.async_listeners().values()) + _cycle_entities() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": [*entity_ids], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + _cycle_entities() msg = await asyncio.wait_for(websocket_client.receive_json(), 2) assert msg["id"] == 7 @@ -2138,3 +2196,90 @@ async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_clie # Check our listener got unsubscribed assert sum(hass.bus.async_listeners().values()) == init_count + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_entities_some_have_uom_multiple( + hass, recorder_mock, hass_ws_client +): + """Test logbook stream with uom filtered entities and non-fitlered entities.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + await async_wait_recording_done(hass) + filtered_entity_ids = ("sensor.uom", "sensor.uom_two") + non_filtered_entity_ids = ("sensor.keep", "sensor.keep_two") + + def _cycle_entities(): + for entity_id in filtered_entity_ids: + for state in ("1", "2", "3"): + hass.states.async_set( + entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} + ) + for entity_id in non_filtered_entity_ids: + for state in (STATE_ON, STATE_OFF): + hass.states.async_set(entity_id, state) + + init_count = sum(hass.bus.async_listeners().values()) + _cycle_entities() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": [*filtered_entity_ids, *non_filtered_entity_ids], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + _cycle_entities() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["partial"] is True + assert msg["event"]["events"] == [ + {"entity_id": "sensor.keep", "state": "off", "when": ANY}, + {"entity_id": "sensor.keep_two", "state": "off", "when": ANY}, + ] + + _cycle_entities() + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" not in msg["event"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"entity_id": "sensor.keep", "state": "on", "when": ANY}, + {"entity_id": "sensor.keep", "state": "off", "when": ANY}, + {"entity_id": "sensor.keep_two", "state": "on", "when": ANY}, + {"entity_id": "sensor.keep_two", "state": "off", "when": ANY}, + {"entity_id": "sensor.keep", "state": "on", "when": ANY}, + {"entity_id": "sensor.keep", "state": "off", "when": ANY}, + {"entity_id": "sensor.keep_two", "state": "on", "when": ANY}, + {"entity_id": "sensor.keep_two", "state": "off", "when": ANY}, + ] + assert "partial" not in msg["event"] + + await websocket_client.close() + await hass.async_block_till_done() + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count From 7536586bed51998d86a0df6cabfeed3890b72b70 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 6 Jun 2022 06:58:48 +0200 Subject: [PATCH 257/947] Add binary sensors for Pure devices Boost Config (#73032) --- .../components/sensibo/binary_sensor.py | 48 +++++++++++++++++++ homeassistant/components/sensibo/sensor.py | 3 +- .../components/sensibo/strings.sensor.json | 8 ++++ .../sensibo/translations/sensor.en.json | 8 ++++ .../components/sensibo/test_binary_sensor.py | 13 ++++- tests/components/sensibo/test_sensor.py | 2 + 6 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/sensibo/strings.sensor.json create mode 100644 homeassistant/components/sensibo/translations/sensor.en.json diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index e8d83f04593..3a7deadc405 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -87,6 +87,48 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( ), ) +PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( + SensiboDeviceBinarySensorEntityDescription( + key="pure_boost_enabled", + device_class=BinarySensorDeviceClass.RUNNING, + name="Pure Boost Enabled", + icon="mdi:wind-power-outline", + value_fn=lambda data: data.pure_boost_enabled, + ), + SensiboDeviceBinarySensorEntityDescription( + key="pure_ac_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with AC", + icon="mdi:connection", + value_fn=lambda data: data.pure_ac_integration, + ), + SensiboDeviceBinarySensorEntityDescription( + key="pure_geo_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with Presence", + icon="mdi:connection", + value_fn=lambda data: data.pure_geo_integration, + ), + SensiboDeviceBinarySensorEntityDescription( + key="pure_measure_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with Indoor Air Quality", + icon="mdi:connection", + value_fn=lambda data: data.pure_measure_integration, + ), + SensiboDeviceBinarySensorEntityDescription( + key="pure_prime_integration", + entity_category=EntityCategory.DIAGNOSTIC, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + name="Pure Boost linked with Outdoor Air Quality", + icon="mdi:connection", + value_fn=lambda data: data.pure_prime_integration, + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -112,6 +154,12 @@ async def async_setup_entry( for device_id, device_data in coordinator.data.parsed.items() if getattr(device_data, description.key) is not None ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for description in PURE_SENSOR_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.model == "pure" + ) async_add_entities(entities) diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 7254948bdad..f37a054b606 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -116,7 +116,8 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( key="pure_sensitivity", name="Pure Sensitivity", icon="mdi:air-filter", - value_fn=lambda data: data.pure_sensitivity, + value_fn=lambda data: str(data.pure_sensitivity).lower(), + device_class="sensibo__sensitivity", ), ) diff --git a/homeassistant/components/sensibo/strings.sensor.json b/homeassistant/components/sensibo/strings.sensor.json new file mode 100644 index 00000000000..2e4e05fba5b --- /dev/null +++ b/homeassistant/components/sensibo/strings.sensor.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensitive" + } + } +} diff --git a/homeassistant/components/sensibo/translations/sensor.en.json b/homeassistant/components/sensibo/translations/sensor.en.json new file mode 100644 index 00000000000..9ea1818b37c --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.en.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensitive" + } + } +} \ No newline at end of file diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index 3a84dc99ca5..efa6c5bdb2a 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations from datetime import timedelta -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysensibo.model import SensiboData from pytest import MonkeyPatch @@ -16,6 +16,7 @@ from tests.common import async_fire_time_changed async def test_binary_sensor( hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, load_int: ConfigEntry, monkeypatch: MonkeyPatch, get_data: SensiboData, @@ -26,10 +27,20 @@ async def test_binary_sensor( state2 = hass.states.get("binary_sensor.hallway_motion_sensor_main_sensor") state3 = hass.states.get("binary_sensor.hallway_motion_sensor_motion") state4 = hass.states.get("binary_sensor.hallway_room_occupied") + state5 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") + state6 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" + ) + state7 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" + ) assert state1.state == "on" assert state2.state == "on" assert state3.state == "on" assert state4.state == "on" + assert state5.state == "off" + assert state6.state == "on" + assert state7.state == "off" monkeypatch.setattr( get_data.parsed["ABC999111"].motion_sensors["AABBCC"], "alive", False diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 413b62f6b9f..426416ae2b9 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -24,8 +24,10 @@ async def test_sensor( state1 = hass.states.get("sensor.hallway_motion_sensor_battery_voltage") state2 = hass.states.get("sensor.kitchen_pm2_5") + state3 = hass.states.get("sensor.kitchen_pure_sensitivity") assert state1.state == "3000" assert state2.state == "1" + assert state3.state == "n" assert state2.attributes == { "state_class": "measurement", "unit_of_measurement": "µg/m³", From 457c7a4ddcf092771cd1cb8eee7636c205031c7d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jun 2022 19:06:49 -1000 Subject: [PATCH 258/947] Fix incompatiblity with live logbook and google_assistant (#73063) --- homeassistant/components/logbook/const.py | 10 +- homeassistant/components/logbook/helpers.py | 179 ++++++++++++------ homeassistant/components/logbook/processor.py | 33 +--- .../components/logbook/websocket_api.py | 14 +- tests/components/logbook/common.py | 1 - .../components/logbook/test_websocket_api.py | 153 +++++++++++++-- 6 files changed, 271 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/logbook/const.py b/homeassistant/components/logbook/const.py index 3f0c6599724..d20acb553cc 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -30,13 +30,11 @@ LOGBOOK_ENTRY_NAME = "name" LOGBOOK_ENTRY_STATE = "state" LOGBOOK_ENTRY_WHEN = "when" -ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED = {EVENT_LOGBOOK_ENTRY, EVENT_CALL_SERVICE} -ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY = { - EVENT_LOGBOOK_ENTRY, - EVENT_AUTOMATION_TRIGGERED, - EVENT_SCRIPT_STARTED, -} +# Automation events that can affect an entity_id or device_id +AUTOMATION_EVENTS = {EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED} +# Events that are built-in to the logbook or core +BUILT_IN_EVENTS = {EVENT_LOGBOOK_ENTRY, EVENT_CALL_SERVICE} LOGBOOK_FILTERS = "logbook_filters" LOGBOOK_ENTITIES_FILTER = "entities_filter" diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index de021994b8d..eec60ebe740 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -7,6 +7,7 @@ from typing import Any from homeassistant.components.sensor import ATTR_STATE_CLASS from homeassistant.const import ( ATTR_DEVICE_ID, + ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, EVENT_LOGBOOK_ENTRY, @@ -21,13 +22,10 @@ from homeassistant.core import ( is_callback, ) from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event -from .const import ( - ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, - DOMAIN, - ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY, -) +from .const import AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN from .models import LazyEventPartialState @@ -41,6 +39,25 @@ def async_filter_entities(hass: HomeAssistant, entity_ids: list[str]) -> list[st ] +@callback +def _async_config_entries_for_ids( + hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None +) -> set[str]: + """Find the config entry ids for a set of entities or devices.""" + config_entry_ids: set[str] = set() + if entity_ids: + eng_reg = er.async_get(hass) + for entity_id in entity_ids: + if (entry := eng_reg.async_get(entity_id)) and entry.config_entry_id: + config_entry_ids.add(entry.config_entry_id) + if device_ids: + dev_reg = dr.async_get(hass) + for device_id in device_ids: + if (device := dev_reg.async_get(device_id)) and device.config_entries: + config_entry_ids |= device.config_entries + return config_entry_ids + + def async_determine_event_types( hass: HomeAssistant, entity_ids: list[str] | None, device_ids: list[str] | None ) -> tuple[str, ...]: @@ -49,42 +66,91 @@ def async_determine_event_types( str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] ] = hass.data.get(DOMAIN, {}) if not entity_ids and not device_ids: - return (*ALL_EVENT_TYPES_EXCEPT_STATE_CHANGED, *external_events) - config_entry_ids: set[str] = set() - intrested_event_types: set[str] = set() + return (*BUILT_IN_EVENTS, *external_events) + interested_domains: set[str] = set() + for entry_id in _async_config_entries_for_ids(hass, entity_ids, device_ids): + if entry := hass.config_entries.async_get_entry(entry_id): + interested_domains.add(entry.domain) + + # + # automations and scripts can refer to entities or devices + # but they do not have a config entry so we need + # to add them since we have historically included + # them when matching only on entities + # + intrested_event_types: set[str] = { + external_event + for external_event, domain_call in external_events.items() + if domain_call[0] in interested_domains + } | AUTOMATION_EVENTS if entity_ids: - # - # Home Assistant doesn't allow firing events from - # entities so we have a limited list to check - # - # automations and scripts can refer to entities - # but they do not have a config entry so we need - # to add them. - # - # We also allow entity_ids to be recorded via - # manual logbook entries. - # - intrested_event_types |= ENTITY_EVENTS_WITHOUT_CONFIG_ENTRY + # We also allow entity_ids to be recorded via manual logbook entries. + intrested_event_types.add(EVENT_LOGBOOK_ENTRY) - if device_ids: - dev_reg = dr.async_get(hass) - for device_id in device_ids: - if (device := dev_reg.async_get(device_id)) and device.config_entries: - config_entry_ids |= device.config_entries - interested_domains: set[str] = set() - for entry_id in config_entry_ids: - if entry := hass.config_entries.async_get_entry(entry_id): - interested_domains.add(entry.domain) - for external_event, domain_call in external_events.items(): - if domain_call[0] in interested_domains: - intrested_event_types.add(external_event) + return tuple(intrested_event_types) - return tuple( - event_type - for event_type in (EVENT_LOGBOOK_ENTRY, *external_events) - if event_type in intrested_event_types - ) + +@callback +def extract_attr(source: dict[str, Any], attr: str) -> list[str]: + """Extract an attribute as a list or string.""" + if (value := source.get(attr)) is None: + return [] + if isinstance(value, list): + return value + return str(value).split(",") + + +@callback +def event_forwarder_filtered( + target: Callable[[Event], None], + entities_filter: EntityFilter | None, + entity_ids: list[str] | None, + device_ids: list[str] | None, +) -> Callable[[Event], None]: + """Make a callable to filter events.""" + if not entities_filter and not entity_ids and not device_ids: + # No filter + # - Script Trace (context ids) + # - Automation Trace (context ids) + return target + + if entities_filter: + # We have an entity filter: + # - Logbook panel + + @callback + def _forward_events_filtered_by_entities_filter(event: Event) -> None: + assert entities_filter is not None + event_data = event.data + entity_ids = extract_attr(event_data, ATTR_ENTITY_ID) + if entity_ids and not any( + entities_filter(entity_id) for entity_id in entity_ids + ): + return + domain = event_data.get(ATTR_DOMAIN) + if domain and not entities_filter(f"{domain}._"): + return + target(event) + + return _forward_events_filtered_by_entities_filter + + # We are filtering on entity_ids and/or device_ids: + # - Areas + # - Devices + # - Logbook Card + entity_ids_set = set(entity_ids) if entity_ids else set() + device_ids_set = set(device_ids) if device_ids else set() + + @callback + def _forward_events_filtered_by_device_entity_ids(event: Event) -> None: + event_data = event.data + if entity_ids_set.intersection( + extract_attr(event_data, ATTR_ENTITY_ID) + ) or device_ids_set.intersection(extract_attr(event_data, ATTR_DEVICE_ID)): + target(event) + + return _forward_events_filtered_by_device_entity_ids @callback @@ -93,6 +159,7 @@ def async_subscribe_events( subscriptions: list[CALLBACK_TYPE], target: Callable[[Event], None], event_types: tuple[str, ...], + entities_filter: EntityFilter | None, entity_ids: list[str] | None, device_ids: list[str] | None, ) -> None: @@ -103,41 +170,31 @@ def async_subscribe_events( """ ent_reg = er.async_get(hass) assert is_callback(target), "target must be a callback" - event_forwarder = target - - if entity_ids or device_ids: - entity_ids_set = set(entity_ids) if entity_ids else set() - device_ids_set = set(device_ids) if device_ids else set() - - @callback - def _forward_events_filtered(event: Event) -> None: - event_data = event.data - if ( - entity_ids_set and event_data.get(ATTR_ENTITY_ID) in entity_ids_set - ) or (device_ids_set and event_data.get(ATTR_DEVICE_ID) in device_ids_set): - target(event) - - event_forwarder = _forward_events_filtered - + event_forwarder = event_forwarder_filtered( + target, entities_filter, entity_ids, device_ids + ) for event_type in event_types: subscriptions.append( hass.bus.async_listen(event_type, event_forwarder, run_immediately=True) ) - @callback - def _forward_state_events_filtered(event: Event) -> None: - if event.data.get("old_state") is None or event.data.get("new_state") is None: - return - state: State = event.data["new_state"] - if not _is_state_filtered(ent_reg, state): - target(event) - if device_ids and not entity_ids: # No entities to subscribe to but we are filtering # on device ids so we do not want to get any state # changed events return + @callback + def _forward_state_events_filtered(event: Event) -> None: + if event.data.get("old_state") is None or event.data.get("new_state") is None: + return + state: State = event.data["new_state"] + if _is_state_filtered(ent_reg, state) or ( + entities_filter and not entities_filter(state.entity_id) + ): + return + target(event) + if entity_ids: subscriptions.append( async_track_state_change_event( diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index e5cc0f124b0..82225df8364 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -5,8 +5,6 @@ from collections.abc import Callable, Generator from contextlib import suppress from dataclasses import dataclass from datetime import datetime as dt -import logging -import re from typing import Any from sqlalchemy.engine.row import Row @@ -30,7 +28,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, split_entity_id from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entityfilter import EntityFilter import homeassistant.util.dt as dt_util from .const import ( @@ -46,7 +43,6 @@ from .const import ( CONTEXT_STATE, CONTEXT_USER_ID, DOMAIN, - LOGBOOK_ENTITIES_FILTER, LOGBOOK_ENTRY_DOMAIN, LOGBOOK_ENTRY_ENTITY_ID, LOGBOOK_ENTRY_ICON, @@ -62,11 +58,6 @@ from .models import EventAsRow, LazyEventPartialState, async_event_to_row from .queries import statement_for_request from .queries.common import PSUEDO_EVENT_STATE_CHANGED -_LOGGER = logging.getLogger(__name__) - -ENTITY_ID_JSON_EXTRACT = re.compile('"entity_id": ?"([^"]+)"') -DOMAIN_JSON_EXTRACT = re.compile('"domain": ?"([^"]+)"') - @dataclass class LogbookRun: @@ -106,10 +97,6 @@ class EventProcessor: self.device_ids = device_ids self.context_id = context_id self.filters: Filters | None = hass.data[LOGBOOK_FILTERS] - if self.limited_select: - self.entities_filter: EntityFilter | Callable[[str], bool] | None = None - else: - self.entities_filter = hass.data[LOGBOOK_ENTITIES_FILTER] format_time = ( _row_time_fired_timestamp if timestamp else _row_time_fired_isoformat ) @@ -183,7 +170,6 @@ class EventProcessor: return list( _humanify( row_generator, - self.entities_filter, self.ent_reg, self.logbook_run, self.context_augmenter, @@ -193,7 +179,6 @@ class EventProcessor: def _humanify( rows: Generator[Row | EventAsRow, None, None], - entities_filter: EntityFilter | Callable[[str], bool] | None, ent_reg: er.EntityRegistry, logbook_run: LogbookRun, context_augmenter: ContextAugmenter, @@ -208,29 +193,13 @@ def _humanify( include_entity_name = logbook_run.include_entity_name format_time = logbook_run.format_time - def _keep_row(row: EventAsRow) -> bool: - """Check if the entity_filter rejects a row.""" - assert entities_filter is not None - if entity_id := row.entity_id: - return entities_filter(entity_id) - if entity_id := row.data.get(ATTR_ENTITY_ID): - return entities_filter(entity_id) - if domain := row.data.get(ATTR_DOMAIN): - return entities_filter(f"{domain}._") - return True - # Process rows for row in rows: context_id = context_lookup.memorize(row) if row.context_only: continue event_type = row.event_type - if event_type == EVENT_CALL_SERVICE or ( - entities_filter - # We literally mean is EventAsRow not a subclass of EventAsRow - and type(row) is EventAsRow # pylint: disable=unidiomatic-typecheck - and not _keep_row(row) - ): + if event_type == EVENT_CALL_SERVICE: continue if event_type is PSUEDO_EVENT_STATE_CHANGED: entity_id = row.entity_id diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index b27ae65b70c..a8f9bc50920 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -16,9 +16,11 @@ from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_point_in_utc_time import homeassistant.util.dt as dt_util +from .const import LOGBOOK_ENTITIES_FILTER from .helpers import ( async_determine_event_types, async_filter_entities, @@ -365,8 +367,18 @@ async def ws_event_stream( ) _unsub() + entities_filter: EntityFilter | None = None + if not event_processor.limited_select: + entities_filter = hass.data[LOGBOOK_ENTITIES_FILTER] + async_subscribe_events( - hass, subscriptions, _queue_or_cancel, event_types, entity_ids, device_ids + hass, + subscriptions, + _queue_or_cancel, + event_types, + entities_filter, + entity_ids, + device_ids, ) subscriptions_setup_complete_time = dt_util.utcnow() connection.subscriptions[msg_id] = _unsub diff --git a/tests/components/logbook/common.py b/tests/components/logbook/common.py index b88c3854967..a41f983bfed 100644 --- a/tests/components/logbook/common.py +++ b/tests/components/logbook/common.py @@ -68,7 +68,6 @@ def mock_humanify(hass_, rows): return list( processor._humanify( rows, - None, ent_reg, logbook_run, context_augmenter, diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 2623a5b17d5..ae1f7968e3b 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -27,8 +27,8 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import Event, HomeAssistant, State -from homeassistant.helpers import device_registry +from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.helpers import device_registry, entity_registry from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -51,22 +51,8 @@ def set_utc(hass): hass.config.set_time_zone("UTC") -async def _async_mock_device_with_logbook_platform(hass): - """Mock an integration that provides a device that are described by the logbook.""" - entry = MockConfigEntry(domain="test", data={"first": True}, options=None) - entry.add_to_hass(hass) - dev_reg = device_registry.async_get(hass) - device = dev_reg.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - identifiers={("bridgeid", "0123")}, - sw_version="sw-version", - name="device name", - manufacturer="manufacturer", - model="model", - suggested_area="Game Room", - ) - +@callback +async def _async_mock_logbook_platform(hass: HomeAssistant) -> None: class MockLogbookPlatform: """Mock a logbook platform.""" @@ -90,6 +76,40 @@ async def _async_mock_device_with_logbook_platform(hass): async_describe_event("test", "mock_event", async_describe_test_event) await logbook._process_logbook_platform(hass, "test", MockLogbookPlatform) + + +async def _async_mock_entity_with_logbook_platform(hass): + """Mock an integration that provides an entity that are described by the logbook.""" + entry = MockConfigEntry(domain="test", data={"first": True}, options=None) + entry.add_to_hass(hass) + ent_reg = entity_registry.async_get(hass) + entry = ent_reg.async_get_or_create( + platform="test", + domain="sensor", + config_entry=entry, + unique_id="1234", + suggested_object_id="test", + ) + await _async_mock_logbook_platform(hass) + return entry + + +async def _async_mock_device_with_logbook_platform(hass): + """Mock an integration that provides a device that are described by the logbook.""" + entry = MockConfigEntry(domain="test", data={"first": True}, options=None) + entry.add_to_hass(hass) + dev_reg = device_registry.async_get(hass) + device = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bridgeid", "0123")}, + sw_version="sw-version", + name="device name", + manufacturer="manufacturer", + model="model", + suggested_area="Game Room", + ) + await _async_mock_logbook_platform(hass) return device @@ -1786,6 +1806,103 @@ async def test_event_stream_bad_start_time(hass, hass_ws_client, recorder_mock): assert response["error"]["code"] == "invalid_start_time" +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_logbook_stream_match_multiple_entities( + hass, recorder_mock, hass_ws_client +): + """Test logbook stream with a described integration that uses multiple entities.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + entry = await _async_mock_entity_with_logbook_platform(hass) + entity_id = entry.entity_id + hass.states.async_set(entity_id, STATE_ON) + + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": [entity_id], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # There are no answers to our initial query + # so we get an empty reply. This is to ensure + # consumers of the api know there are no results + # and its not a failure case. This is useful + # in the frontend so we can tell the user there + # are no results vs waiting for them to appear + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + await async_wait_recording_done(hass) + + hass.states.async_set("binary_sensor.should_not_appear", STATE_ON) + hass.states.async_set("binary_sensor.should_not_appear", STATE_OFF) + context = core.Context( + id="ac5bd62de45711eaaeb351041eec8dd9", + user_id="b400facee45711eaa9308bfd3d19e474", + ) + hass.bus.async_fire( + "mock_event", {"entity_id": ["sensor.any", entity_id]}, context=context + ) + hass.bus.async_fire("mock_event", {"entity_id": [f"sensor.any,{entity_id}"]}) + hass.bus.async_fire("mock_event", {"entity_id": ["sensor.no_match", "light.off"]}) + hass.states.async_set(entity_id, STATE_OFF, context=context) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "context_user_id": "b400facee45711eaa9308bfd3d19e474", + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + { + "context_domain": "test", + "context_event_type": "mock_event", + "context_message": "is on fire", + "context_name": "device name", + "context_user_id": "b400facee45711eaa9308bfd3d19e474", + "entity_id": "sensor.test", + "state": "off", + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count + + async def test_event_stream_bad_end_time(hass, hass_ws_client, recorder_mock): """Test event_stream bad end time.""" await async_setup_component(hass, "logbook", {}) From 0b62944148747d6421ced68ed1e41558cdc45408 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 5 Jun 2022 21:25:26 -1000 Subject: [PATCH 259/947] Mark counter domain as continuous to exclude it from logbook (#73101) --- homeassistant/components/logbook/const.py | 9 ++++ homeassistant/components/logbook/helpers.py | 9 ++-- .../components/logbook/queries/common.py | 45 +++++++++++++------ homeassistant/components/recorder/filters.py | 9 +++- tests/components/logbook/test_init.py | 6 +++ .../components/logbook/test_websocket_api.py | 8 +++- 6 files changed, 66 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/logbook/const.py b/homeassistant/components/logbook/const.py index d20acb553cc..e1abd987659 100644 --- a/homeassistant/components/logbook/const.py +++ b/homeassistant/components/logbook/const.py @@ -2,9 +2,18 @@ from __future__ import annotations from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED +from homeassistant.components.counter import DOMAIN as COUNTER_DOMAIN +from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN from homeassistant.components.script import EVENT_SCRIPT_STARTED +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import EVENT_CALL_SERVICE, EVENT_LOGBOOK_ENTRY +# Domains that are always continuous +ALWAYS_CONTINUOUS_DOMAINS = {COUNTER_DOMAIN, PROXIMITY_DOMAIN} + +# Domains that are continuous if there is a UOM set on the entity +CONDITIONALLY_CONTINUOUS_DOMAINS = {SENSOR_DOMAIN} + ATTR_MESSAGE = "message" DOMAIN = "logbook" diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index eec60ebe740..ef322c44e05 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -20,12 +20,13 @@ from homeassistant.core import ( State, callback, is_callback, + split_entity_id, ) from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event -from .const import AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN +from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN from .models import LazyEventPartialState @@ -235,7 +236,8 @@ def _is_state_filtered(ent_reg: er.EntityRegistry, state: State) -> bool: we only get significant changes (state.last_changed != state.last_updated) """ return bool( - state.last_changed != state.last_updated + split_entity_id(state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS + or state.last_changed != state.last_updated or ATTR_UNIT_OF_MEASUREMENT in state.attributes or is_sensor_continuous(ent_reg, state.entity_id) ) @@ -250,7 +252,8 @@ def _is_entity_id_filtered( from the database when a list of entities is requested. """ return bool( - (state := hass.states.get(entity_id)) + split_entity_id(entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS + or (state := hass.states.get(entity_id)) and (ATTR_UNIT_OF_MEASUREMENT in state.attributes) or is_sensor_continuous(ent_reg, entity_id) ) diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index a7a4f84a59e..56925b60e62 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -10,7 +10,7 @@ from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select -from homeassistant.components.proximity import DOMAIN as PROXIMITY_DOMAIN +from homeassistant.components.recorder.filters import like_domain_matchers from homeassistant.components.recorder.models import ( EVENTS_CONTEXT_ID_INDEX, OLD_FORMAT_ATTRS_JSON, @@ -22,15 +22,19 @@ from homeassistant.components.recorder.models import ( StateAttributes, States, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -CONTINUOUS_DOMAINS = {PROXIMITY_DOMAIN, SENSOR_DOMAIN} -CONTINUOUS_ENTITY_ID_LIKE = [f"{domain}.%" for domain in CONTINUOUS_DOMAINS] +from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS + +# Domains that are continuous if there is a UOM set on the entity +CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE = like_domain_matchers( + CONDITIONALLY_CONTINUOUS_DOMAINS +) +# Domains that are always continuous +ALWAYS_CONTINUOUS_ENTITY_ID_LIKE = like_domain_matchers(ALWAYS_CONTINUOUS_DOMAINS) UNIT_OF_MEASUREMENT_JSON = '"unit_of_measurement":' UNIT_OF_MEASUREMENT_JSON_LIKE = f"%{UNIT_OF_MEASUREMENT_JSON}%" - PSUEDO_EVENT_STATE_CHANGED = None # Since we don't store event_types and None # and we don't store state_changed in events @@ -220,29 +224,44 @@ def _missing_state_matcher() -> sqlalchemy.and_: def _not_continuous_entity_matcher() -> sqlalchemy.or_: """Match non continuous entities.""" return sqlalchemy.or_( - _not_continuous_domain_matcher(), + # First exclude domains that may be continuous + _not_possible_continuous_domain_matcher(), + # But let in the entities in the possible continuous domains + # that are not actually continuous sensors because they lack a UOM sqlalchemy.and_( - _continuous_domain_matcher, _not_uom_attributes_matcher() + _conditionally_continuous_domain_matcher, _not_uom_attributes_matcher() ).self_group(), ) -def _not_continuous_domain_matcher() -> sqlalchemy.and_: - """Match not continuous domains.""" +def _not_possible_continuous_domain_matcher() -> sqlalchemy.and_: + """Match not continuous domains. + + This matches domain that are always considered continuous + and domains that are conditionally (if they have a UOM) + continuous domains. + """ return sqlalchemy.and_( *[ ~States.entity_id.like(entity_domain) - for entity_domain in CONTINUOUS_ENTITY_ID_LIKE + for entity_domain in ( + *ALWAYS_CONTINUOUS_ENTITY_ID_LIKE, + *CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE, + ) ], ).self_group() -def _continuous_domain_matcher() -> sqlalchemy.or_: - """Match continuous domains.""" +def _conditionally_continuous_domain_matcher() -> sqlalchemy.or_: + """Match conditionally continuous domains. + + This matches domain that are only considered + continuous if a UOM is set. + """ return sqlalchemy.or_( *[ States.entity_id.like(entity_domain) - for entity_domain in CONTINUOUS_ENTITY_ID_LIKE + for entity_domain in CONDITIONALLY_CONTINUOUS_ENTITY_ID_LIKE ], ).self_group() diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 90851e9f251..0b3e0e68030 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -248,8 +248,13 @@ def _domain_matcher( domains: Iterable[str], columns: Iterable[Column], encoder: Callable[[Any], Any] ) -> ClauseList: matchers = [ - (column.is_not(None) & cast(column, Text()).like(encoder(f"{domain}.%"))) - for domain in domains + (column.is_not(None) & cast(column, Text()).like(encoder(domain_matcher))) + for domain_matcher in like_domain_matchers(domains) for column in columns ] return or_(*matchers) if matchers else or_(False) + + +def like_domain_matchers(domains: Iterable[str]) -> list[str]: + """Convert a list of domains to sql LIKE matchers.""" + return [f"{domain}.%" for domain in domains] diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 651a00fb0cf..d16b3476d84 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -745,6 +745,12 @@ async def test_filter_continuous_sensor_values( entity_id_third = "light.bla" hass.states.async_set(entity_id_third, STATE_OFF, {"unit_of_measurement": "foo"}) hass.states.async_set(entity_id_third, STATE_ON, {"unit_of_measurement": "foo"}) + entity_id_proximity = "proximity.bla" + hass.states.async_set(entity_id_proximity, STATE_OFF) + hass.states.async_set(entity_id_proximity, STATE_ON) + entity_id_counter = "counter.bla" + hass.states.async_set(entity_id_counter, STATE_OFF) + hass.states.async_set(entity_id_counter, STATE_ON) await async_wait_recording_done(hass) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index ae1f7968e3b..4df2f456eb6 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2209,7 +2209,9 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) -async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_client): +async def test_subscribe_all_entities_are_continuous( + hass, recorder_mock, hass_ws_client +): """Test subscribe/unsubscribe logbook stream with entities that are always filtered.""" now = dt_util.utcnow() await asyncio.gather( @@ -2227,6 +2229,8 @@ async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_clie hass.states.async_set( entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} ) + hass.states.async_set("counter.any", state) + hass.states.async_set("proximity.any", state) init_count = sum(hass.bus.async_listeners().values()) _cycle_entities() @@ -2238,7 +2242,7 @@ async def test_subscribe_all_entities_have_uom(hass, recorder_mock, hass_ws_clie "id": 7, "type": "logbook/event_stream", "start_time": now.isoformat(), - "entity_ids": ["sensor.uom"], + "entity_ids": ["sensor.uom", "counter.any", "proximity.any"], } ) From e5b447839af38d5c10e3205ef095aa205cb6dd16 Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 6 Jun 2022 13:20:16 +0200 Subject: [PATCH 260/947] Fix fibaro cover detection (#72986) --- homeassistant/components/fibaro/cover.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index fc6d0a67d3c..e898cd7ead9 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -46,6 +46,8 @@ class FibaroCover(FibaroDevice, CoverEntity): self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) + if "stop" in self.fibaro_device.actions: + self._attr_supported_features |= CoverEntityFeature.STOP @staticmethod def bound(position): From a9e4673affbae34d8ddaeb1f6e8fb98a3bcf259c Mon Sep 17 00:00:00 2001 From: Igor Loborec Date: Mon, 6 Jun 2022 18:07:02 +0100 Subject: [PATCH 261/947] Bump holidays to 0.14.2 (#73121) --- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 6028e6e6fc2..186ae56bbdc 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -2,7 +2,7 @@ "domain": "workday", "name": "Workday", "documentation": "https://www.home-assistant.io/integrations/workday", - "requirements": ["holidays==0.13"], + "requirements": ["holidays==0.14.2"], "codeowners": ["@fabaff"], "quality_scale": "internal", "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 34c3904aab9..edb9131f9a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -822,7 +822,7 @@ hlk-sw16==0.0.9 hole==0.7.0 # homeassistant.components.workday -holidays==0.13 +holidays==0.14.2 # homeassistant.components.frontend home-assistant-frontend==20220601.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b8b4d0dc35..b3ca7cf27b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -589,7 +589,7 @@ hlk-sw16==0.0.9 hole==0.7.0 # homeassistant.components.workday -holidays==0.13 +holidays==0.14.2 # homeassistant.components.frontend home-assistant-frontend==20220601.0 From ed54cea3f26dfd4475b7be1d2971cd61313cacf3 Mon Sep 17 00:00:00 2001 From: Jan Stienstra <65826735+j-stienstra@users.noreply.github.com> Date: Mon, 6 Jun 2022 21:17:21 +0200 Subject: [PATCH 262/947] Jellyfin: Add support for movie collections (#73086) --- homeassistant/components/jellyfin/const.py | 5 +- .../components/jellyfin/manifest.json | 2 +- .../components/jellyfin/media_source.py | 81 ++++++++++++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 66 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/jellyfin/const.py b/homeassistant/components/jellyfin/const.py index d8379859e54..1f679fd43c8 100644 --- a/homeassistant/components/jellyfin/const.py +++ b/homeassistant/components/jellyfin/const.py @@ -7,7 +7,6 @@ DOMAIN: Final = "jellyfin" CLIENT_VERSION: Final = "1.0" COLLECTION_TYPE_MOVIES: Final = "movies" -COLLECTION_TYPE_TVSHOWS: Final = "tvshows" COLLECTION_TYPE_MUSIC: Final = "music" DATA_CLIENT: Final = "client" @@ -24,6 +23,7 @@ ITEM_TYPE_ALBUM: Final = "MusicAlbum" ITEM_TYPE_ARTIST: Final = "MusicArtist" ITEM_TYPE_AUDIO: Final = "Audio" ITEM_TYPE_LIBRARY: Final = "CollectionFolder" +ITEM_TYPE_MOVIE: Final = "Movie" MAX_IMAGE_WIDTH: Final = 500 MAX_STREAMING_BITRATE: Final = "140000000" @@ -33,8 +33,9 @@ MEDIA_SOURCE_KEY_PATH: Final = "Path" MEDIA_TYPE_AUDIO: Final = "Audio" MEDIA_TYPE_NONE: Final = "" +MEDIA_TYPE_VIDEO: Final = "Video" -SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC] +SUPPORTED_COLLECTION_TYPES: Final = [COLLECTION_TYPE_MUSIC, COLLECTION_TYPE_MOVIES] USER_APP_NAME: Final = "Home Assistant" USER_AGENT: Final = f"Home-Assistant/{CLIENT_VERSION}" diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index 993d2520484..48f4cf0c837 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -3,7 +3,7 @@ "name": "Jellyfin", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/jellyfin", - "requirements": ["jellyfin-apiclient-python==1.7.2"], + "requirements": ["jellyfin-apiclient-python==1.8.1"], "iot_class": "local_polling", "codeowners": ["@j-stienstra"], "loggers": ["jellyfin_apiclient_python"] diff --git a/homeassistant/components/jellyfin/media_source.py b/homeassistant/components/jellyfin/media_source.py index dbd79612378..879f4a4d4c8 100644 --- a/homeassistant/components/jellyfin/media_source.py +++ b/homeassistant/components/jellyfin/media_source.py @@ -4,7 +4,6 @@ from __future__ import annotations import logging import mimetypes from typing import Any -import urllib.parse from jellyfin_apiclient_python.api import jellyfin_url from jellyfin_apiclient_python.client import JellyfinClient @@ -13,6 +12,7 @@ from homeassistant.components.media_player.const import ( MEDIA_CLASS_ALBUM, MEDIA_CLASS_ARTIST, MEDIA_CLASS_DIRECTORY, + MEDIA_CLASS_MOVIE, MEDIA_CLASS_TRACK, ) from homeassistant.components.media_player.errors import BrowseError @@ -25,6 +25,7 @@ from homeassistant.components.media_source.models import ( from homeassistant.core import HomeAssistant from .const import ( + COLLECTION_TYPE_MOVIES, COLLECTION_TYPE_MUSIC, DATA_CLIENT, DOMAIN, @@ -39,11 +40,12 @@ from .const import ( ITEM_TYPE_ARTIST, ITEM_TYPE_AUDIO, ITEM_TYPE_LIBRARY, + ITEM_TYPE_MOVIE, MAX_IMAGE_WIDTH, - MAX_STREAMING_BITRATE, MEDIA_SOURCE_KEY_PATH, MEDIA_TYPE_AUDIO, MEDIA_TYPE_NONE, + MEDIA_TYPE_VIDEO, SUPPORTED_COLLECTION_TYPES, ) @@ -147,6 +149,8 @@ class JellyfinSource(MediaSource): if collection_type == COLLECTION_TYPE_MUSIC: return await self._build_music_library(library, include_children) + if collection_type == COLLECTION_TYPE_MOVIES: + return await self._build_movie_library(library, include_children) raise BrowseError(f"Unsupported collection type {collection_type}") @@ -270,6 +274,55 @@ class JellyfinSource(MediaSource): return result + async def _build_movie_library( + self, library: dict[str, Any], include_children: bool + ) -> BrowseMediaSource: + """Return a single movie library as a browsable media source.""" + library_id = library[ITEM_KEY_ID] + library_name = library[ITEM_KEY_NAME] + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=library_id, + media_class=MEDIA_CLASS_DIRECTORY, + media_content_type=MEDIA_TYPE_NONE, + title=library_name, + can_play=False, + can_expand=True, + ) + + if include_children: + result.children_media_class = MEDIA_CLASS_MOVIE + result.children = await self._build_movies(library_id) # type: ignore[assignment] + + return result + + async def _build_movies(self, library_id: str) -> list[BrowseMediaSource]: + """Return all movies in the movie library.""" + movies = await self._get_children(library_id, ITEM_TYPE_MOVIE) + movies = sorted(movies, key=lambda k: k[ITEM_KEY_NAME]) # type: ignore[no-any-return] + return [self._build_movie(movie) for movie in movies] + + def _build_movie(self, movie: dict[str, Any]) -> BrowseMediaSource: + """Return a single movie as a browsable media source.""" + movie_id = movie[ITEM_KEY_ID] + movie_title = movie[ITEM_KEY_NAME] + mime_type = _media_mime_type(movie) + thumbnail_url = self._get_thumbnail_url(movie) + + result = BrowseMediaSource( + domain=DOMAIN, + identifier=movie_id, + media_class=MEDIA_CLASS_MOVIE, + media_content_type=mime_type, + title=movie_title, + can_play=True, + can_expand=False, + thumbnail=thumbnail_url, + ) + + return result + async def _get_children( self, parent_id: str, item_type: str ) -> list[dict[str, Any]]: @@ -279,7 +332,7 @@ class JellyfinSource(MediaSource): "ParentId": parent_id, "IncludeItemTypes": item_type, } - if item_type == ITEM_TYPE_AUDIO: + if item_type in {ITEM_TYPE_AUDIO, ITEM_TYPE_MOVIE}: params["Fields"] = ITEM_KEY_MEDIA_SOURCES result = await self.hass.async_add_executor_job(self.api.user_items, "", params) @@ -298,29 +351,15 @@ class JellyfinSource(MediaSource): def _get_stream_url(self, media_item: dict[str, Any]) -> str: """Return the stream URL for a media item.""" media_type = media_item[ITEM_KEY_MEDIA_TYPE] + item_id = media_item[ITEM_KEY_ID] if media_type == MEDIA_TYPE_AUDIO: - return self._get_audio_stream_url(media_item) + return self.api.audio_url(item_id) # type: ignore[no-any-return] + if media_type == MEDIA_TYPE_VIDEO: + return self.api.video_url(item_id) # type: ignore[no-any-return] raise BrowseError(f"Unsupported media type {media_type}") - def _get_audio_stream_url(self, media_item: dict[str, Any]) -> str: - """Return the stream URL for a music media item.""" - item_id = media_item[ITEM_KEY_ID] - user_id = self.client.config.data["auth.user_id"] - device_id = self.client.config.data["app.device_id"] - api_key = self.client.config.data["auth.token"] - - params = urllib.parse.urlencode( - { - "UserId": user_id, - "DeviceId": device_id, - "api_key": api_key, - "MaxStreamingBitrate": MAX_STREAMING_BITRATE, - } - ) - return f"{self.url}Audio/{item_id}/universal?{params}" - def _media_mime_type(media_item: dict[str, Any]) -> str: """Return the mime type of a media item.""" diff --git a/requirements_all.txt b/requirements_all.txt index edb9131f9a5..2bea1a28b50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -903,7 +903,7 @@ iperf3==0.1.11 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.7.2 +jellyfin-apiclient-python==1.8.1 # homeassistant.components.rest jsonpath==0.82 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3ca7cf27b1..15242eaad94 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -643,7 +643,7 @@ iotawattpy==0.1.0 ismartgate==4.0.4 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.7.2 +jellyfin-apiclient-python==1.8.1 # homeassistant.components.rest jsonpath==0.82 From 983a76a91ce5ae2ada276886bb5876530761d09d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Jun 2022 21:43:47 +0200 Subject: [PATCH 263/947] Update pylint to 2.14.0 (#73119) --- homeassistant/components/hassio/http.py | 2 ++ homeassistant/components/hassio/websocket_api.py | 2 ++ homeassistant/components/sentry/__init__.py | 1 - homeassistant/helpers/update_coordinator.py | 2 +- pylint/plugins/hass_constructor.py | 5 +---- pylint/plugins/hass_enforce_type_hints.py | 7 ++----- pylint/plugins/hass_imports.py | 7 ++----- pylint/plugins/hass_logger.py | 7 ++----- pyproject.toml | 4 +--- requirements_test.txt | 2 +- 10 files changed, 14 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 63ac1521cc5..497e246ea77 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__) MAX_UPLOAD_SIZE = 1024 * 1024 * 1024 +# pylint: disable=implicit-str-concat NO_TIMEOUT = re.compile( r"^(?:" r"|homeassistant/update" @@ -48,6 +49,7 @@ NO_AUTH = re.compile( ) NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") +# pylint: enable=implicit-str-concat class HassIOView(HomeAssistantView): diff --git a/homeassistant/components/hassio/websocket_api.py b/homeassistant/components/hassio/websocket_api.py index 4af090d7154..7eb037d8432 100644 --- a/homeassistant/components/hassio/websocket_api.py +++ b/homeassistant/components/hassio/websocket_api.py @@ -38,9 +38,11 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema( ) # Endpoints needed for ingress can't require admin because addons can set `panel_admin: false` +# pylint: disable=implicit-str-concat WS_NO_ADMIN_ENDPOINTS = re.compile( r"^(?:" r"|/ingress/(session|validate_session)" r"|/addons/[^/]+/info" r")$" ) +# pylint: enable=implicit-str-concat _LOGGER: logging.Logger = logging.getLogger(__package__) diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py index 3037f1dc374..092358e82f6 100644 --- a/homeassistant/components/sentry/__init__.py +++ b/homeassistant/components/sentry/__init__.py @@ -80,7 +80,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ), } - # pylint: disable-next=abstract-class-instantiated sentry_sdk.init( dsn=entry.data[CONF_DSN], environment=entry.options.get(CONF_ENVIRONMENT), diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index f671e1b973a..fc619469500 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable, Generator from datetime import datetime, timedelta import logging from time import monotonic -from typing import Any, Generic, TypeVar # pylint: disable=unused-import +from typing import Any, Generic, TypeVar import urllib.error import aiohttp diff --git a/pylint/plugins/hass_constructor.py b/pylint/plugins/hass_constructor.py index 525dcfed2e2..23496b68de3 100644 --- a/pylint/plugins/hass_constructor.py +++ b/pylint/plugins/hass_constructor.py @@ -3,19 +3,16 @@ from __future__ import annotations from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter class HassConstructorFormatChecker(BaseChecker): # type: ignore[misc] """Checker for __init__ definitions.""" - __implements__ = IAstroidChecker - name = "hass_constructor" priority = -1 msgs = { - "W0006": ( + "W7411": ( '__init__ should have explicit return type "None"', "hass-constructor-return", "Used when __init__ has all arguments typed " diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 05a52faab17..d8d3c76a028 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -6,7 +6,6 @@ import re from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter from homeassistant.const import Platform @@ -540,17 +539,15 @@ def _get_module_platform(module_name: str) -> str | None: class HassTypeHintChecker(BaseChecker): # type: ignore[misc] """Checker for setup type hints.""" - __implements__ = IAstroidChecker - name = "hass_enforce_type_hints" priority = -1 msgs = { - "W0020": ( + "W7431": ( "Argument %d should be of type %s", "hass-argument-type", "Used when method argument type is incorrect", ), - "W0021": ( + "W7432": ( "Return type should be %s", "hass-return-type", "Used when method return type is incorrect", diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index a8b3fa8fc76..cc160c1cfbd 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -6,7 +6,6 @@ import re from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter @@ -233,17 +232,15 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { class HassImportsFormatChecker(BaseChecker): # type: ignore[misc] """Checker for imports.""" - __implements__ = IAstroidChecker - name = "hass_imports" priority = -1 msgs = { - "W0011": ( + "W7421": ( "Relative import should be used", "hass-relative-import", "Used when absolute import should be replaced with relative import", ), - "W0012": ( + "W7422": ( "%s is deprecated, %s", "hass-deprecated-import", "Used when import is deprecated", diff --git a/pylint/plugins/hass_logger.py b/pylint/plugins/hass_logger.py index 125c927ec42..0135720a792 100644 --- a/pylint/plugins/hass_logger.py +++ b/pylint/plugins/hass_logger.py @@ -3,7 +3,6 @@ from __future__ import annotations from astroid import nodes from pylint.checkers import BaseChecker -from pylint.interfaces import IAstroidChecker from pylint.lint import PyLinter LOGGER_NAMES = ("LOGGER", "_LOGGER") @@ -13,17 +12,15 @@ LOG_LEVEL_ALLOWED_LOWER_START = ("debug",) class HassLoggerFormatChecker(BaseChecker): # type: ignore[misc] """Checker for logger invocations.""" - __implements__ = IAstroidChecker - name = "hass_logger" priority = -1 msgs = { - "W0001": ( + "W7401": ( "User visible logger messages must not end with a period", "hass-logger-period", "Periods are not permitted at the end of logger messages", ), - "W0002": ( + "W7402": ( "User visible logger messages must start with a capital letter or downgrade to debug", "hass-logger-capital", "All logger messages must start with a capital letter", diff --git a/pyproject.toml b/pyproject.toml index cc745f58ad6..cf5ac7a37c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,7 +87,7 @@ forced_separate = [ ] combine_as_imports = true -[tool.pylint.MASTER] +[tool.pylint.MAIN] py-version = "3.9" ignore = [ "tests", @@ -152,7 +152,6 @@ good-names = [ # too-many-ancestors - it's too strict. # wrong-import-order - isort guards this # consider-using-f-string - str.format sometimes more readable -# no-self-use - little added value with too many false-positives # --- # Enable once current issues are fixed: # consider-using-namedtuple-or-dataclass (Pylint CodeStyle extension) @@ -179,7 +178,6 @@ disable = [ "unused-argument", "wrong-import-order", "consider-using-f-string", - "no-self-use", "consider-using-namedtuple-or-dataclass", "consider-using-assignment-expr", ] diff --git a/requirements_test.txt b/requirements_test.txt index e888d715cd4..406ce96d50d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.2.1 mock-open==1.4.0 mypy==0.960 pre-commit==2.19.0 -pylint==2.13.9 +pylint==2.14.0 pipdeptree==2.2.1 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 From 861de5c0f0761306412e073f4c65cfe922ffdc01 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 6 Jun 2022 12:49:15 -0700 Subject: [PATCH 264/947] Point iAlarm XR at PyPI fork (#73143) --- homeassistant/components/ialarm_xr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ialarm_xr/manifest.json b/homeassistant/components/ialarm_xr/manifest.json index f863f360242..5befca3b95d 100644 --- a/homeassistant/components/ialarm_xr/manifest.json +++ b/homeassistant/components/ialarm_xr/manifest.json @@ -2,7 +2,7 @@ "domain": "ialarm_xr", "name": "Antifurto365 iAlarmXR", "documentation": "https://www.home-assistant.io/integrations/ialarm_xr", - "requirements": ["pyialarmxr==1.0.18"], + "requirements": ["pyialarmxr-homeassistant==1.0.18"], "codeowners": ["@bigmoby"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 2bea1a28b50..4c72de261e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1553,7 +1553,7 @@ pyhomeworks==0.0.6 pyialarm==1.9.0 # homeassistant.components.ialarm_xr -pyialarmxr==1.0.18 +pyialarmxr-homeassistant==1.0.18 # homeassistant.components.icloud pyicloud==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15242eaad94..3a300ea3f78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1041,7 +1041,7 @@ pyhomematic==0.1.77 pyialarm==1.9.0 # homeassistant.components.ialarm_xr -pyialarmxr==1.0.18 +pyialarmxr-homeassistant==1.0.18 # homeassistant.components.icloud pyicloud==1.0.0 From de2e9b6d77adb7f86c6ec4aa0a50428ec8606dc3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jun 2022 09:50:52 -1000 Subject: [PATCH 265/947] Fix state_changes_during_period history query when no entities are passed (#73139) --- homeassistant/components/recorder/history.py | 17 ++++++------ tests/components/recorder/test_history.py | 29 ++++++++++++++++++++ 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 5dd5c0d3040..37285f66d1d 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -352,7 +352,8 @@ def _state_changed_during_period_stmt( ) if end_time: stmt += lambda q: q.filter(States.last_updated < end_time) - stmt += lambda q: q.filter(States.entity_id == entity_id) + if entity_id: + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -378,6 +379,7 @@ def state_changes_during_period( ) -> MutableMapping[str, list[State]]: """Return states changes during UTC period start_time - end_time.""" entity_id = entity_id.lower() if entity_id is not None else None + entity_ids = [entity_id] if entity_id is not None else None with session_scope(hass=hass) as session: stmt = _state_changed_during_period_stmt( @@ -392,8 +394,6 @@ def state_changes_during_period( states = execute_stmt_lambda_element( session, stmt, None if entity_id else start_time, end_time ) - entity_ids = [entity_id] if entity_id is not None else None - return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -408,14 +408,16 @@ def state_changes_during_period( def _get_last_state_changes_stmt( - schema_version: int, number_of_states: int, entity_id: str + schema_version: int, number_of_states: int, entity_id: str | None ) -> StatementLambdaElement: stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, False, include_last_changed=False ) stmt += lambda q: q.filter( (States.last_changed == States.last_updated) | States.last_changed.is_(None) - ).filter(States.entity_id == entity_id) + ) + if entity_id: + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id @@ -427,19 +429,18 @@ def _get_last_state_changes_stmt( def get_last_state_changes( - hass: HomeAssistant, number_of_states: int, entity_id: str + hass: HomeAssistant, number_of_states: int, entity_id: str | None ) -> MutableMapping[str, list[State]]: """Return the last number_of_states.""" start_time = dt_util.utcnow() entity_id = entity_id.lower() if entity_id is not None else None + entity_ids = [entity_id] if entity_id is not None else None with session_scope(hass=hass) as session: stmt = _get_last_state_changes_stmt( _schema_version(hass), number_of_states, entity_id ) states = list(execute_stmt_lambda_element(session, stmt)) - entity_ids = [entity_id] if entity_id is not None else None - return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index da6c3a8af35..ee02ffbec49 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -878,3 +878,32 @@ async def test_get_full_significant_states_handles_empty_last_changed( assert db_sensor_one_states[0].last_updated is not None assert db_sensor_one_states[1].last_updated is not None assert db_sensor_one_states[0].last_updated != db_sensor_one_states[1].last_updated + + +def test_state_changes_during_period_multiple_entities_single_test(hass_recorder): + """Test state change during period with multiple entities in the same test. + + This test ensures the sqlalchemy query cache does not + generate incorrect results. + """ + hass = hass_recorder() + start = dt_util.utcnow() + test_entites = {f"sensor.{i}": str(i) for i in range(30)} + for entity_id, value in test_entites.items(): + hass.states.set(entity_id, value) + + wait_recording_done(hass) + end = dt_util.utcnow() + + hist = history.state_changes_during_period(hass, start, end, None) + for entity_id, value in test_entites.items(): + hist[entity_id][0].state == value + + for entity_id, value in test_entites.items(): + hist = history.state_changes_during_period(hass, start, end, entity_id) + assert len(hist) == 1 + hist[entity_id][0].state == value + + hist = history.state_changes_during_period(hass, start, end, None) + for entity_id, value in test_entites.items(): + hist[entity_id][0].state == value From caed0a486f21821bde0176cc03d8280293dabe05 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 6 Jun 2022 22:03:52 +0200 Subject: [PATCH 266/947] Update mypy to 0.961 (#73142) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 406ce96d50d..afef38074ee 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -11,7 +11,7 @@ codecov==2.1.12 coverage==6.4 freezegun==1.2.1 mock-open==1.4.0 -mypy==0.960 +mypy==0.961 pre-commit==2.19.0 pylint==2.14.0 pipdeptree==2.2.1 From dbd3ca5ecd8ee0119f6225751f9abf0efc2f0cb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 6 Jun 2022 22:19:15 +0200 Subject: [PATCH 267/947] airzone: update aioairzone to v0.4.5 (#73127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index e0d3bd6df09..e189ae741ad 100644 --- a/homeassistant/components/airzone/manifest.json +++ b/homeassistant/components/airzone/manifest.json @@ -3,7 +3,7 @@ "name": "Airzone", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airzone", - "requirements": ["aioairzone==0.4.4"], + "requirements": ["aioairzone==0.4.5"], "codeowners": ["@Noltari"], "iot_class": "local_polling", "loggers": ["aioairzone"] diff --git a/requirements_all.txt b/requirements_all.txt index 4c72de261e1..296febf7519 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -107,7 +107,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.7 # homeassistant.components.airzone -aioairzone==0.4.4 +aioairzone==0.4.5 # homeassistant.components.ambient_station aioambient==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a300ea3f78..b3ad7f54c7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -94,7 +94,7 @@ aio_geojson_nsw_rfs_incidents==0.4 aio_georss_gdacs==0.7 # homeassistant.components.airzone -aioairzone==0.4.4 +aioairzone==0.4.5 # homeassistant.components.ambient_station aioambient==2021.11.0 From 6c9408aef5cc53852e9ec8077e383cb6f17877c8 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Mon, 6 Jun 2022 23:46:52 +0200 Subject: [PATCH 268/947] Bump async-upnp-client==0.31.1 (#73135) Co-authored-by: J. Nick Koston --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 15beea714da..cc72f5d4778 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.30.1"], + "requirements": ["async-upnp-client==0.31.1"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 21329440788..590d1b8370a 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Server", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dms", - "requirements": ["async-upnp-client==0.30.1"], + "requirements": ["async-upnp-client==0.31.1"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index fd97eb12e54..ce65af7d8bb 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -7,7 +7,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.5.0", "wakeonlan==2.0.1", - "async-upnp-client==0.30.1" + "async-upnp-client==0.31.1" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index 5cbd3d0d10e..f0db05d9015 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.30.1"], + "requirements": ["async-upnp-client==0.31.1"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 2e76dac4adb..dc87e73fdee 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.30.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.31.1", "getmac==0.8.2"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman", "@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 5ec224498be..57d32f315ba 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.10", "async-upnp-client==0.30.1"], + "requirements": ["yeelight==0.7.10", "async-upnp-client==0.31.1"], "codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 076b58b5185..bec79680e0d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.11 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.30.1 +async-upnp-client==0.31.1 async_timeout==4.0.2 atomicwrites==1.4.0 attrs==21.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 296febf7519..c968971cb54 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.30.1 +async-upnp-client==0.31.1 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3ad7f54c7b..47f0c9336c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -281,7 +281,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.30.1 +async-upnp-client==0.31.1 # homeassistant.components.sleepiq asyncsleepiq==1.2.3 From 4f75de2345030786ee5e9849e6219241209be8ec Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 6 Jun 2022 17:18:07 -0500 Subject: [PATCH 269/947] Fix errors when unjoining multiple Sonos devices simultaneously (#73133) --- .../components/sonos/media_player.py | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index f331f980bb4..938a651c34d 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -751,17 +751,23 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): media_content_type, ) - def join_players(self, group_members): + async def async_join_players(self, group_members): """Join `group_members` as a player group with the current player.""" - speakers = [] - for entity_id in group_members: - if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get(entity_id): - speakers.append(speaker) - else: - raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") + async with self.hass.data[DATA_SONOS].topology_condition: + speakers = [] + for entity_id in group_members: + if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get( + entity_id + ): + speakers.append(speaker) + else: + raise HomeAssistantError( + f"Not a known Sonos entity_id: {entity_id}" + ) - self.speaker.join(speakers) + await self.hass.async_add_executor_job(self.speaker.join, speakers) - def unjoin_player(self): + async def async_unjoin_player(self): """Remove this player from any group.""" - self.speaker.unjoin() + async with self.hass.data[DATA_SONOS].topology_condition: + await self.hass.async_add_executor_job(self.speaker.unjoin) From ca54eaf40dbe08e407e4ae46f9786cdeb558b15f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Tue, 7 Jun 2022 11:02:08 +1200 Subject: [PATCH 270/947] Fix KeyError from ESPHome media players on startup (#73149) --- .../components/esphome/media_player.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/esphome/media_player.py b/homeassistant/components/esphome/media_player.py index f9027142ae2..d7ce73976e7 100644 --- a/homeassistant/components/esphome/media_player.py +++ b/homeassistant/components/esphome/media_player.py @@ -25,7 +25,12 @@ from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import EsphomeEntity, EsphomeEnumMapper, platform_async_setup_entry +from . import ( + EsphomeEntity, + EsphomeEnumMapper, + esphome_state_property, + platform_async_setup_entry, +) async def async_setup_entry( @@ -54,6 +59,10 @@ _STATES: EsphomeEnumMapper[MediaPlayerState, str] = EsphomeEnumMapper( ) +# https://github.com/PyCQA/pylint/issues/3150 for all @esphome_state_property +# pylint: disable=invalid-overridden-method + + class EsphomeMediaPlayer( EsphomeEntity[MediaPlayerInfo, MediaPlayerEntityState], MediaPlayerEntity ): @@ -61,17 +70,17 @@ class EsphomeMediaPlayer( _attr_device_class = MediaPlayerDeviceClass.SPEAKER - @property + @esphome_state_property def state(self) -> str | None: """Return current state.""" return _STATES.from_esphome(self._state.state) - @property + @esphome_state_property def is_volume_muted(self) -> bool: """Return true if volume is muted.""" return self._state.muted - @property + @esphome_state_property def volume_level(self) -> float | None: """Volume level of the media player (0..1).""" return self._state.volume From 05e5dd7baf07ece1e0deae603a4f10c21e508fde Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 7 Jun 2022 00:20:09 +0000 Subject: [PATCH 271/947] [ci skip] Translation update --- .../airvisual/translations/sensor.he.json | 3 + .../components/emonitor/translations/fr.json | 2 +- .../components/flux_led/translations/fr.json | 2 +- .../components/harmony/translations/fr.json | 2 +- .../humidifier/translations/he.json | 2 +- .../translations/fr.json | 2 +- .../components/lookin/translations/fr.json | 2 +- .../components/plugwise/translations/hu.json | 4 +- .../components/plugwise/translations/pl.json | 10 ++- .../radiotherm/translations/ca.json | 22 ++++++ .../radiotherm/translations/de.json | 31 ++++++++ .../radiotherm/translations/el.json | 31 ++++++++ .../radiotherm/translations/fr.json | 31 ++++++++ .../radiotherm/translations/hu.json | 31 ++++++++ .../radiotherm/translations/id.json | 31 ++++++++ .../radiotherm/translations/it.json | 31 ++++++++ .../radiotherm/translations/nl.json | 22 ++++++ .../radiotherm/translations/pl.json | 31 ++++++++ .../radiotherm/translations/pt-BR.json | 31 ++++++++ .../radiotherm/translations/zh-Hant.json | 31 ++++++++ .../components/scrape/translations/id.json | 42 +++++++++-- .../components/scrape/translations/it.json | 73 +++++++++++++++++++ .../components/scrape/translations/nl.json | 6 +- .../components/scrape/translations/pl.json | 73 +++++++++++++++++++ .../components/skybell/translations/hu.json | 21 ++++++ .../components/skybell/translations/id.json | 21 ++++++ .../components/skybell/translations/it.json | 21 ++++++ .../components/skybell/translations/pl.json | 21 ++++++ .../synology_dsm/translations/fr.json | 2 +- .../tankerkoenig/translations/it.json | 8 +- .../tankerkoenig/translations/pl.json | 8 +- .../components/tplink/translations/fr.json | 2 +- .../components/twinkly/translations/fr.json | 2 +- .../components/zha/translations/fr.json | 2 +- 34 files changed, 630 insertions(+), 24 deletions(-) create mode 100644 homeassistant/components/radiotherm/translations/ca.json create mode 100644 homeassistant/components/radiotherm/translations/de.json create mode 100644 homeassistant/components/radiotherm/translations/el.json create mode 100644 homeassistant/components/radiotherm/translations/fr.json create mode 100644 homeassistant/components/radiotherm/translations/hu.json create mode 100644 homeassistant/components/radiotherm/translations/id.json create mode 100644 homeassistant/components/radiotherm/translations/it.json create mode 100644 homeassistant/components/radiotherm/translations/nl.json create mode 100644 homeassistant/components/radiotherm/translations/pl.json create mode 100644 homeassistant/components/radiotherm/translations/pt-BR.json create mode 100644 homeassistant/components/radiotherm/translations/zh-Hant.json create mode 100644 homeassistant/components/scrape/translations/it.json create mode 100644 homeassistant/components/scrape/translations/pl.json create mode 100644 homeassistant/components/skybell/translations/hu.json create mode 100644 homeassistant/components/skybell/translations/id.json create mode 100644 homeassistant/components/skybell/translations/it.json create mode 100644 homeassistant/components/skybell/translations/pl.json diff --git a/homeassistant/components/airvisual/translations/sensor.he.json b/homeassistant/components/airvisual/translations/sensor.he.json index 7ed68fa47ca..5745fb051f6 100644 --- a/homeassistant/components/airvisual/translations/sensor.he.json +++ b/homeassistant/components/airvisual/translations/sensor.he.json @@ -1,5 +1,8 @@ { "state": { + "airvisual__pollutant_label": { + "p1": "PM10" + }, "airvisual__pollutant_level": { "good": "\u05d8\u05d5\u05d1", "unhealthy": "\u05dc\u05d0 \u05d1\u05e8\u05d9\u05d0", diff --git a/homeassistant/components/emonitor/translations/fr.json b/homeassistant/components/emonitor/translations/fr.json index aaacc8bf140..ce6070be8b8 100644 --- a/homeassistant/components/emonitor/translations/fr.json +++ b/homeassistant/components/emonitor/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Voulez-vous configurer {name} ( {host} )?", + "description": "Voulez-vous configurer {name} ({host})\u00a0?", "title": "Configurer SiteSage Emonitor" }, "user": { diff --git a/homeassistant/components/flux_led/translations/fr.json b/homeassistant/components/flux_led/translations/fr.json index baea6899999..691c470c6aa 100644 --- a/homeassistant/components/flux_led/translations/fr.json +++ b/homeassistant/components/flux_led/translations/fr.json @@ -11,7 +11,7 @@ "flow_title": "{model} {id} ( {ipaddr} )", "step": { "discovery_confirm": { - "description": "Voulez-vous configurer {model} {id} ( {ipaddr} )\u00a0?" + "description": "Voulez-vous configurer {model} {id} ({ipaddr})\u00a0?" }, "user": { "data": { diff --git a/homeassistant/components/harmony/translations/fr.json b/homeassistant/components/harmony/translations/fr.json index 077405be95f..9c9b8a7b7e1 100644 --- a/homeassistant/components/harmony/translations/fr.json +++ b/homeassistant/components/harmony/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name}", "step": { "link": { - "description": "Voulez-vous configurer {name} ( {host} ) ?", + "description": "Voulez-vous configurer {name} ({host})\u00a0?", "title": "Configuration de Logitech Harmony Hub" }, "user": { diff --git a/homeassistant/components/humidifier/translations/he.json b/homeassistant/components/humidifier/translations/he.json index 5a4c58c7934..4cd7b4d8196 100644 --- a/homeassistant/components/humidifier/translations/he.json +++ b/homeassistant/components/humidifier/translations/he.json @@ -2,7 +2,7 @@ "device_automation": { "action_type": { "set_mode": "\u05e9\u05e0\u05d4 \u05de\u05e6\u05d1 \u05d1-{entity_name}", - "toggle": "\u05d4\u05d7\u05dc\u05e3 \u05d0\u05ea {entity_name}", + "toggle": "\u05d4\u05d7\u05dc\u05e4\u05ea {entity_name}", "turn_off": "\u05db\u05d1\u05d4 \u05d0\u05ea {entity_name}", "turn_on": "\u05d4\u05e4\u05e2\u05dc \u05d0\u05ea {entity_name}" }, diff --git a/homeassistant/components/hunterdouglas_powerview/translations/fr.json b/homeassistant/components/hunterdouglas_powerview/translations/fr.json index 9eb8edda7db..c6ad82dc7ab 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/fr.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name} ({host})", "step": { "link": { - "description": "Voulez-vous configurer {name} ({host})?", + "description": "Voulez-vous configurer {name} ({host})\u00a0?", "title": "Connectez-vous au concentrateur PowerView" }, "user": { diff --git a/homeassistant/components/lookin/translations/fr.json b/homeassistant/components/lookin/translations/fr.json index 7276af22624..2ceb9bd6600 100644 --- a/homeassistant/components/lookin/translations/fr.json +++ b/homeassistant/components/lookin/translations/fr.json @@ -19,7 +19,7 @@ } }, "discovery_confirm": { - "description": "Voulez-vous configurer {name} ( {host} )?" + "description": "Voulez-vous configurer {name} ({host})\u00a0?" }, "user": { "data": { diff --git a/homeassistant/components/plugwise/translations/hu.json b/homeassistant/components/plugwise/translations/hu.json index 8b9b619f728..b622109797c 100644 --- a/homeassistant/components/plugwise/translations/hu.json +++ b/homeassistant/components/plugwise/translations/hu.json @@ -19,8 +19,8 @@ "port": "Port", "username": "Smile Felhaszn\u00e1l\u00f3n\u00e9v" }, - "description": "Term\u00e9k:", - "title": "Plugwise t\u00edpus" + "description": "K\u00e9rem, adja meg", + "title": "Csatlakoz\u00e1s a Smile-hoz" }, "user_gateway": { "data": { diff --git a/homeassistant/components/plugwise/translations/pl.json b/homeassistant/components/plugwise/translations/pl.json index c4a2efe95c3..3d6a3a3b354 100644 --- a/homeassistant/components/plugwise/translations/pl.json +++ b/homeassistant/components/plugwise/translations/pl.json @@ -13,10 +13,14 @@ "step": { "user": { "data": { - "flow_type": "Typ po\u0142\u0105czenia" + "flow_type": "Typ po\u0142\u0105czenia", + "host": "Adres IP", + "password": "Identyfikator Smile", + "port": "Port", + "username": "Nazwa u\u017cytkownika Smile" }, - "description": "Wybierz produkt:", - "title": "Wybierz typ Plugwise" + "description": "Wprowad\u017a:", + "title": "Po\u0142\u0105czenie ze Smile" }, "user_gateway": { "data": { diff --git a/homeassistant/components/radiotherm/translations/ca.json b/homeassistant/components/radiotherm/translations/ca.json new file mode 100644 index 00000000000..1008a0e4988 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3", + "unknown": "Error inesperat" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Vols configurar {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Amfitri\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/de.json b/homeassistant/components/radiotherm/translations/de.json new file mode 100644 index 00000000000..50315afce52 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen", + "unknown": "Unerwarteter Fehler" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "M\u00f6chtest du {name} {model} ({host}) einrichten?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Stelle beim Einstellen der Temperatur eine permanente Sperre ein." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/el.json b/homeassistant/components/radiotherm/translations/el.json new file mode 100644 index 00000000000..9b276da3670 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/el.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", + "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u0398\u03ad\u03bb\u03b5\u03c4\u03b5 \u03bd\u03b1 \u03c1\u03c5\u03b8\u03bc\u03af\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf {name} {model} ({host});" + }, + "user": { + "data": { + "host": "\u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u039f\u03c1\u03af\u03c3\u03c4\u03b5 \u03bc\u03b9\u03b1 \u03bc\u03cc\u03bd\u03b9\u03bc\u03b7 \u03b1\u03bd\u03b1\u03bc\u03bf\u03bd\u03ae \u03ba\u03b1\u03c4\u03ac \u03c4\u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b8\u03b5\u03c1\u03bc\u03bf\u03ba\u03c1\u03b1\u03c3\u03af\u03b1\u03c2." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/fr.json b/homeassistant/components/radiotherm/translations/fr.json new file mode 100644 index 00000000000..47705ce5138 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/fr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion", + "unknown": "Erreur inattendue" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Voulez-vous configurer {name} {model} ({host})\u00a0?" + }, + "user": { + "data": { + "host": "H\u00f4te" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "D\u00e9finissez un maintien permanent lors du r\u00e9glage de la temp\u00e9rature." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/hu.json b/homeassistant/components/radiotherm/translations/hu.json new file mode 100644 index 00000000000..f55ea666f5f --- /dev/null +++ b/homeassistant/components/radiotherm/translations/hu.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Szeretn\u00e9 be\u00e1ll\u00edtani: {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "C\u00edm" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u00c1ll\u00edtson be \u00e1lland\u00f3 tart\u00e1st a h\u0151m\u00e9rs\u00e9klet be\u00e1ll\u00edt\u00e1sakor." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/id.json b/homeassistant/components/radiotherm/translations/id.json new file mode 100644 index 00000000000..1e454cc8cc8 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/id.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Ingin menyiapkan {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Atur penahan permanen saat menyesuaikan suhu." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/it.json b/homeassistant/components/radiotherm/translations/it.json new file mode 100644 index 00000000000..1fd9c7152ff --- /dev/null +++ b/homeassistant/components/radiotherm/translations/it.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "unknown": "Errore imprevisto" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Vuoi configurare {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Impostare una sospensione permanente durante la regolazione della temperatura." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/nl.json b/homeassistant/components/radiotherm/translations/nl.json new file mode 100644 index 00000000000..ec2d0fc6554 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/nl.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd" + }, + "error": { + "cannot_connect": "Kan geen verbinding maken", + "unknown": "Onverwachte fout" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Wilt u {name} {model} ({host}) instellen?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/pl.json b/homeassistant/components/radiotherm/translations/pl.json new file mode 100644 index 00000000000..e69568131d6 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/pl.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Nazwa hosta lub adres IP" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Ustaw podtrzymanie podczas ustawiania temperatury." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/pt-BR.json b/homeassistant/components/radiotherm/translations/pt-BR.json new file mode 100644 index 00000000000..59b4b32d965 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/pt-BR.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falhou ao conectar", + "unknown": "Erro inesperado" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "Deseja configurar {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Defina uma reten\u00e7\u00e3o permanente ao ajustar a temperatura." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/zh-Hant.json b/homeassistant/components/radiotherm/translations/zh-Hant.json new file mode 100644 index 00000000000..7a5a40817f7 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/zh-Hant.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a {name} {model} ({host})\uff1f" + }, + "user": { + "data": { + "host": "\u4e3b\u6a5f\u7aef" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u8abf\u6574\u6eab\u5ea6\u6642\u8a2d\u5b9a\u6c38\u4e45\u4fdd\u6301\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/id.json b/homeassistant/components/scrape/translations/id.json index b6cecd6007c..e7761f73a1f 100644 --- a/homeassistant/components/scrape/translations/id.json +++ b/homeassistant/components/scrape/translations/id.json @@ -10,16 +10,27 @@ "authentication": "Autentikasi", "device_class": "Kelas Perangkat", "headers": "Header", + "index": "Indeks", + "name": "Nama", "password": "Kata Sandi", - "resource": "Sumber daya", + "resource": "Sumber Daya", "select": "Pilihan", + "state_class": "Kelas Status", "unit_of_measurement": "Satuan Pengukuran", - "value_template": "T" + "username": "Nama Pengguna", + "value_template": "Nilai Templat", + "verify_ssl": "Verifikasi sertifikat SSL" }, "data_description": { "attribute": "Dapatkan nilai atribut pada tag yang dipilih", "authentication": "Jenis autentikasi HTTP. Salah satu dari basic atau digest", "device_class": "Jenis/kelas sensor untuk mengatur ikon di antarmuka", + "headers": "Header yang digunakan untuk permintaan web", + "index": "Menentukan elemen mana yang dikembalikan oleh selektor CSS untuk digunakan", + "resource": "URL ke situs web yang mengandung nilai", + "select": "Menentukan tag yang harus dicari. Periksa selektor CSS Beautifulsoup untuk melihat detailnya", + "state_class": "Nilai state_class dari sensor", + "value_template": "Mendefinisikan templat untuk mendapatkan status sensor", "verify_ssl": "Mengaktifkan/menonaktifkan verifikasi sertifikat SSL/TLS, misalnya jika sertifikat ditandatangani sendiri" } } @@ -29,11 +40,32 @@ "step": { "init": { "data": { - "resource": "Sumber daya", - "unit_of_measurement": "Satuan Pengukuran" + "attribute": "Atribut", + "authentication": "Autentikasi", + "device_class": "Kelas Perangkat", + "headers": "Header", + "index": "Indeks", + "name": "Nama", + "password": "Kata Sandi", + "resource": "Sumber Daya", + "select": "Pilihan", + "state_class": "Kelas Status", + "unit_of_measurement": "Satuan Pengukuran", + "username": "Nama Pengguna", + "value_template": "Nilai Templat", + "verify_ssl": "Verifikasi sertifikat SSL" }, "data_description": { - "attribute": "Dapatkan nilai atribut pada tag yang dipilih" + "attribute": "Dapatkan nilai atribut pada tag yang dipilih", + "authentication": "Jenis autentikasi HTTP. Salah satu dari basic atau digest", + "device_class": "Jenis/kelas sensor untuk mengatur ikon di antarmuka", + "headers": "Header yang digunakan untuk permintaan web", + "index": "Menentukan elemen mana yang dikembalikan oleh selektor CSS untuk digunakan", + "resource": "URL ke situs web yang mengandung nilai", + "select": "Menentukan tag yang harus dicari. Periksa selektor CSS Beautifulsoup untuk melihat detailnya", + "state_class": "Nilai state_class dari sensor", + "value_template": "Mendefinisikan templat untuk mendapatkan status sensor", + "verify_ssl": "Mengaktifkan/menonaktifkan verifikasi sertifikat SSL/TLS, misalnya jika sertifikat ditandatangani sendiri" } } } diff --git a/homeassistant/components/scrape/translations/it.json b/homeassistant/components/scrape/translations/it.json new file mode 100644 index 00000000000..5fd8428335f --- /dev/null +++ b/homeassistant/components/scrape/translations/it.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "step": { + "user": { + "data": { + "attribute": "Attributo", + "authentication": "Autenticazione", + "device_class": "Classe del dispositivo", + "headers": "Intestazioni", + "index": "Indice", + "name": "Nome", + "password": "Password", + "resource": "Risorsa", + "select": "Seleziona", + "state_class": "Classe di Stato", + "unit_of_measurement": "Unit\u00e0 di misura", + "username": "Nome utente", + "value_template": "Modello di valore", + "verify_ssl": "Verifica il certificato SSL" + }, + "data_description": { + "attribute": "Ottieni il valore di un attributo sull'etichetta selezionata", + "authentication": "Tipo di autenticazione HTTP. Base o digest", + "device_class": "Il tipo/classe del sensore per impostare l'icona nel frontend", + "headers": "Intestazioni da utilizzare per la richiesta web", + "index": "Definisce quale degli elementi restituiti dal selettore CSS utilizzare", + "resource": "L'URL del sito Web che contiene il valore", + "select": "Definisce quale etichetta cercare. Controlla i selettori CSS di Beautifulsoup per i dettagli", + "state_class": "La state_class del sensore", + "value_template": "Definisce un modello per ottenere lo stato del sensore", + "verify_ssl": "Abilita/disabilita la verifica del certificato SSL/TLS, ad esempio se \u00e8 autofirmato" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attributo", + "authentication": "Autenticazione", + "device_class": "Classe del dispositivo", + "headers": "Intestazioni", + "index": "Indice", + "name": "Nome", + "password": "Password", + "resource": "Risorsa", + "select": "Seleziona", + "state_class": "Classe di Stato", + "unit_of_measurement": "Unit\u00e0 di misura", + "username": "Nome utente", + "value_template": "Modello di valore", + "verify_ssl": "Verifica il certificato SSL" + }, + "data_description": { + "attribute": "Ottieni il valore di un attributo sull'etichetta selezionata", + "authentication": "Tipo di autenticazione HTTP. Base o digest", + "device_class": "Il tipo/classe del sensore per impostare l'icona nel frontend", + "headers": "Intestazioni da utilizzare per la richiesta web", + "index": "Definisce quale degli elementi restituiti dal selettore CSS utilizzare", + "resource": "L'URL del sito Web che contiene il valore", + "select": "Definisce quale etichetta cercare. Controlla i selettori CSS di Beautifulsoup per i dettagli", + "state_class": "La state_class del sensore", + "value_template": "Definisce un modello per ottenere lo stato del sensore", + "verify_ssl": "Abilita/disabilita la verifica del certificato SSL/TLS, ad esempio se \u00e8 autofirmato" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/nl.json b/homeassistant/components/scrape/translations/nl.json index bdc26e94182..81d41d6ff26 100644 --- a/homeassistant/components/scrape/translations/nl.json +++ b/homeassistant/components/scrape/translations/nl.json @@ -18,7 +18,8 @@ "state_class": "Staatklasse", "unit_of_measurement": "Meeteenheid", "username": "Gebruikersnaam", - "value_template": "Waardetemplate" + "value_template": "Waardetemplate", + "verify_ssl": "SSL-certificaat verifi\u00ebren" }, "data_description": { "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", @@ -44,7 +45,8 @@ "state_class": "Staatklasse", "unit_of_measurement": "Meeteenheid", "username": "Gebruikersnaam", - "value_template": "Waardetemplate" + "value_template": "Waardetemplate", + "verify_ssl": "SSL-certificaat verifi\u00ebren" }, "data_description": { "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", diff --git a/homeassistant/components/scrape/translations/pl.json b/homeassistant/components/scrape/translations/pl.json new file mode 100644 index 00000000000..67b2a3db685 --- /dev/null +++ b/homeassistant/components/scrape/translations/pl.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane" + }, + "step": { + "user": { + "data": { + "attribute": "Atrybut", + "authentication": "Uwierzytelnianie", + "device_class": "Klasa urz\u0105dzenia", + "headers": "Nag\u0142\u00f3wki", + "index": "Indeks", + "name": "Nazwa", + "password": "Has\u0142o", + "resource": "Zas\u00f3b", + "select": "Wybierz", + "state_class": "Klasa stanu", + "unit_of_measurement": "Jednostka miary", + "username": "Nazwa u\u017cytkownika", + "value_template": "Szablon warto\u015bci", + "verify_ssl": "Weryfikacja certyfikatu SSL" + }, + "data_description": { + "attribute": "Pobierz warto\u015b\u0107 atrybutu w wybranym tagu", + "authentication": "Typ uwierzytelniania HTTP. Podstawowy lub digest.", + "device_class": "Typ/klasa sensora do ustawienia ikony w interfejsie u\u017cytkownika", + "headers": "Nag\u0142\u00f3wki do u\u017cycia w \u017c\u0105daniu internetowym", + "index": "Okre\u015bla, kt\u00f3rego z element\u00f3w zwracanych przez selektor CSS nale\u017cy u\u017cy\u0107", + "resource": "Adres URL strony internetowej zawieraj\u0105cej t\u0105 warto\u015b\u0107", + "select": "Okre\u015bla jakiego taga szuka\u0107. Sprawd\u017a selektory CSS Beautifulsoup, aby uzyska\u0107 szczeg\u00f3\u0142owe informacje.", + "state_class": "state_class sensora", + "value_template": "Szablon, kt\u00f3ry pozwala uzyska\u0107 stan czujnika", + "verify_ssl": "W\u0142\u0105cza/wy\u0142\u0105cza weryfikacj\u0119 certyfikatu SSL/TLS, na przyk\u0142ad, je\u015bli jest on samopodpisany." + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Atrybut", + "authentication": "Uwierzytelnianie", + "device_class": "Klasa urz\u0105dzenia", + "headers": "Nag\u0142\u00f3wki", + "index": "Indeks", + "name": "Nazwa", + "password": "Has\u0142o", + "resource": "Zas\u00f3b", + "select": "Wybierz", + "state_class": "Klasa stanu", + "unit_of_measurement": "Jednostka miary", + "username": "Nazwa u\u017cytkownika", + "value_template": "Szablon warto\u015bci", + "verify_ssl": "Weryfikacja certyfikatu SSL" + }, + "data_description": { + "attribute": "Pobierz warto\u015b\u0107 atrybutu w wybranym tagu", + "authentication": "Typ uwierzytelniania HTTP. Podstawowy lub digest.", + "device_class": "Typ/klasa sensora do ustawienia ikony w interfejsie u\u017cytkownika", + "headers": "Nag\u0142\u00f3wki do u\u017cycia w \u017c\u0105daniu internetowym", + "index": "Okre\u015bla, kt\u00f3rego z element\u00f3w zwracanych przez selektor CSS nale\u017cy u\u017cy\u0107", + "resource": "Adres URL strony internetowej zawieraj\u0105cej t\u0105 warto\u015b\u0107", + "select": "Okre\u015bla jakiego taga szuka\u0107. Sprawd\u017a selektory CSS Beautifulsoup, aby uzyska\u0107 szczeg\u00f3\u0142owe informacje.", + "state_class": "state_class sensora", + "value_template": "Szablon, kt\u00f3ry pozwala uzyska\u0107 stan czujnika", + "verify_ssl": "W\u0142\u0105cza/wy\u0142\u0105cza weryfikacj\u0119 certyfikatu SSL/TLS, na przyk\u0142ad, je\u015bli jest on samopodpisany." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/hu.json b/homeassistant/components/skybell/translations/hu.json new file mode 100644 index 00000000000..98b9ee3f016 --- /dev/null +++ b/homeassistant/components/skybell/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s", + "invalid_auth": "\u00c9rv\u00e9nytelen hiteles\u00edt\u00e9s", + "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" + }, + "step": { + "user": { + "data": { + "email": "E-mail", + "password": "Jelsz\u00f3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/id.json b/homeassistant/components/skybell/translations/id.json new file mode 100644 index 00000000000..d59082eb80b --- /dev/null +++ b/homeassistant/components/skybell/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Akun sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" + }, + "error": { + "cannot_connect": "Gagal terhubung", + "invalid_auth": "Autentikasi tidak valid", + "unknown": "Kesalahan yang tidak diharapkan" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Kata Sandi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/it.json b/homeassistant/components/skybell/translations/it.json new file mode 100644 index 00000000000..39d4856dbe4 --- /dev/null +++ b/homeassistant/components/skybell/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" + }, + "error": { + "cannot_connect": "Impossibile connettersi", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/pl.json b/homeassistant/components/skybell/translations/pl.json new file mode 100644 index 00000000000..32e23d406ab --- /dev/null +++ b/homeassistant/components/skybell/translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", + "invalid_auth": "Niepoprawne uwierzytelnienie", + "unknown": "Nieoczekiwany b\u0142\u0105d" + }, + "step": { + "user": { + "data": { + "email": "Adres e-mail", + "password": "Has\u0142o" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json index 3917d9f800e..1ad2f52199c 100644 --- a/homeassistant/components/synology_dsm/translations/fr.json +++ b/homeassistant/components/synology_dsm/translations/fr.json @@ -28,7 +28,7 @@ "username": "Nom d'utilisateur", "verify_ssl": "V\u00e9rifier le certificat SSL" }, - "description": "Voulez-vous configurer {name} ({host})?" + "description": "Voulez-vous configurer {name} ({host})\u00a0?" }, "reauth_confirm": { "data": { diff --git a/homeassistant/components/tankerkoenig/translations/it.json b/homeassistant/components/tankerkoenig/translations/it.json index 4b4cbf6d390..b98598d10a3 100644 --- a/homeassistant/components/tankerkoenig/translations/it.json +++ b/homeassistant/components/tankerkoenig/translations/it.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "La posizione \u00e8 gi\u00e0 configurata" + "already_configured": "La posizione \u00e8 gi\u00e0 configurata", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "invalid_auth": "Autenticazione non valida", "no_stations": "Impossibile trovare nessuna stazione nel raggio d'azione." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Chiave API" + } + }, "select_station": { "data": { "stations": "Stazioni" diff --git a/homeassistant/components/tankerkoenig/translations/pl.json b/homeassistant/components/tankerkoenig/translations/pl.json index 282c87cf3c8..288b4f8aae7 100644 --- a/homeassistant/components/tankerkoenig/translations/pl.json +++ b/homeassistant/components/tankerkoenig/translations/pl.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Lokalizacja jest ju\u017c skonfigurowana" + "already_configured": "Lokalizacja jest ju\u017c skonfigurowana", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "invalid_auth": "Niepoprawne uwierzytelnienie", "no_stations": "Nie mo\u017cna znale\u017a\u0107 \u017cadnej stacji w zasi\u0119gu." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "Klucz API" + } + }, "select_station": { "data": { "stations": "Stacje" diff --git a/homeassistant/components/tplink/translations/fr.json b/homeassistant/components/tplink/translations/fr.json index e1105ea00e0..75b15dbacef 100644 --- a/homeassistant/components/tplink/translations/fr.json +++ b/homeassistant/components/tplink/translations/fr.json @@ -10,7 +10,7 @@ "flow_title": "{name} {model} ( {host} )", "step": { "discovery_confirm": { - "description": "Voulez-vous configurer {name} {model} ( {host} )\u00a0?" + "description": "Voulez-vous configurer {name} {model} ({host})\u00a0?" }, "pick_device": { "data": { diff --git a/homeassistant/components/twinkly/translations/fr.json b/homeassistant/components/twinkly/translations/fr.json index cf6fe97ce88..d076be1f399 100644 --- a/homeassistant/components/twinkly/translations/fr.json +++ b/homeassistant/components/twinkly/translations/fr.json @@ -8,7 +8,7 @@ }, "step": { "discovery_confirm": { - "description": "Voulez-vous configurer {name} - {model} ( {host} )\u00a0?" + "description": "Voulez-vous configurer {name} - {model} ({host})\u00a0?" }, "user": { "data": { diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json index 1044b726b27..f69e4fb36ff 100644 --- a/homeassistant/components/zha/translations/fr.json +++ b/homeassistant/components/zha/translations/fr.json @@ -11,7 +11,7 @@ "flow_title": "{name}", "step": { "confirm": { - "description": "Voulez-vous configurer {name} ?" + "description": "Voulez-vous configurer {name}\u00a0?" }, "pick_radio": { "data": { From 16bf6903bd2b0fd4a70c961edb929a9a796eb5f6 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 7 Jun 2022 00:07:49 -0400 Subject: [PATCH 272/947] Bump pyeight to 0.3.0 (#73151) --- homeassistant/components/eight_sleep/__init__.py | 14 ++++++++------ .../components/eight_sleep/binary_sensor.py | 2 +- homeassistant/components/eight_sleep/manifest.json | 2 +- homeassistant/components/eight_sleep/sensor.py | 8 ++++---- requirements_all.txt | 2 +- 5 files changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index e986a7f6d60..1bf22defd74 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -63,9 +63,10 @@ CONFIG_SCHEMA = vol.Schema( def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: """Get the device's unique ID.""" - unique_id = eight.deviceid + unique_id = eight.device_id + assert unique_id if user_obj: - unique_id = f"{unique_id}.{user_obj.userid}.{user_obj.side}" + unique_id = f"{unique_id}.{user_obj.user_id}.{user_obj.side}" return unique_id @@ -133,9 +134,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: for sens in sensor: side = sens.split("_")[1] - userid = eight.fetch_userid(side) - usrobj = eight.users[userid] - await usrobj.set_heating_level(target, duration) + user_id = eight.fetch_user_id(side) + assert user_id + usr_obj = eight.users[user_id] + await usr_obj.set_heating_level(target, duration) await heat_coordinator.async_request_refresh() @@ -163,7 +165,7 @@ class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): self._user_id = user_id self._sensor = sensor self._user_obj: EightUser | None = None - if self._user_id: + if user_id: self._user_obj = self._eight.users[user_id] mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title()) diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index 868a5177cfe..94ec423390f 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -36,7 +36,7 @@ async def async_setup_platform( entities = [] for user in eight.users.values(): entities.append( - EightHeatSensor(heat_coordinator, eight, user.userid, "bed_presence") + EightHeatSensor(heat_coordinator, eight, user.user_id, "bed_presence") ) async_add_entities(entities) diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index e4c5a1e0029..e83b2977b77 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -2,7 +2,7 @@ "domain": "eight_sleep", "name": "Eight Sleep", "documentation": "https://www.home-assistant.io/integrations/eight_sleep", - "requirements": ["pyeight==0.2.0"], + "requirements": ["pyeight==0.3.0"], "codeowners": ["@mezz64", "@raman325"], "iot_class": "cloud_polling", "loggers": ["pyeight"] diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index b405617e276..b2afa496149 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -73,11 +73,11 @@ async def async_setup_platform( for obj in eight.users.values(): for sensor in EIGHT_USER_SENSORS: all_sensors.append( - EightUserSensor(user_coordinator, eight, obj.userid, sensor) + EightUserSensor(user_coordinator, eight, obj.user_id, sensor) ) for sensor in EIGHT_HEAT_SENSORS: all_sensors.append( - EightHeatSensor(heat_coordinator, eight, obj.userid, sensor) + EightHeatSensor(heat_coordinator, eight, obj.user_id, sensor) ) for sensor in EIGHT_ROOM_SENSORS: all_sensors.append(EightRoomSensor(user_coordinator, eight, sensor)) @@ -109,7 +109,7 @@ class EightHeatSensor(EightSleepBaseEntity, SensorEntity): ) @property - def native_value(self) -> int: + def native_value(self) -> int | None: """Return the state of the sensor.""" assert self._user_obj return self._user_obj.heating_level @@ -270,4 +270,4 @@ class EightRoomSensor(EightSleepBaseEntity, SensorEntity): @property def native_value(self) -> int | float | None: """Return the state of the sensor.""" - return self._eight.room_temperature() + return self._eight.room_temperature diff --git a/requirements_all.txt b/requirements_all.txt index c968971cb54..6ae6cdbaa1a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1468,7 +1468,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.eight_sleep -pyeight==0.2.0 +pyeight==0.3.0 # homeassistant.components.emby pyemby==1.8 From 0c21bf7c25ffe758c25b54bf495cc2e3048bc926 Mon Sep 17 00:00:00 2001 From: BigMoby Date: Tue, 7 Jun 2022 07:23:10 +0200 Subject: [PATCH 273/947] Remove iAlarm XR integration (#73083) * fixing after MartinHjelmare review * fixing after MartinHjelmare review conversion alarm state to hass state * fixing after MartinHjelmare review conversion alarm state to hass state * manage the status in the alarm control * simplyfing return function * Removing iAlarm XR integration because of Antifurto365 explicit request to remove after some issues in their cloud service --- .coveragerc | 1 - .strict-typing | 1 - CODEOWNERS | 2 - .../components/ialarm_xr/__init__.py | 101 ----------- .../ialarm_xr/alarm_control_panel.py | 79 --------- .../components/ialarm_xr/config_flow.py | 94 ---------- homeassistant/components/ialarm_xr/const.py | 3 - .../components/ialarm_xr/manifest.json | 10 -- .../components/ialarm_xr/strings.json | 22 --- .../components/ialarm_xr/translations/bg.json | 21 --- .../components/ialarm_xr/translations/ca.json | 22 --- .../components/ialarm_xr/translations/de.json | 22 --- .../components/ialarm_xr/translations/el.json | 22 --- .../components/ialarm_xr/translations/en.json | 22 --- .../components/ialarm_xr/translations/es.json | 22 --- .../components/ialarm_xr/translations/et.json | 22 --- .../components/ialarm_xr/translations/fr.json | 22 --- .../components/ialarm_xr/translations/he.json | 21 --- .../components/ialarm_xr/translations/hu.json | 22 --- .../components/ialarm_xr/translations/id.json | 22 --- .../components/ialarm_xr/translations/it.json | 22 --- .../components/ialarm_xr/translations/ja.json | 22 --- .../components/ialarm_xr/translations/nl.json | 22 --- .../components/ialarm_xr/translations/no.json | 22 --- .../components/ialarm_xr/translations/pl.json | 22 --- .../ialarm_xr/translations/pt-BR.json | 22 --- .../components/ialarm_xr/translations/tr.json | 21 --- .../ialarm_xr/translations/zh-Hant.json | 22 --- homeassistant/components/ialarm_xr/utils.py | 18 -- homeassistant/generated/config_flows.py | 1 - mypy.ini | 11 -- requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/ialarm_xr/__init__.py | 1 - .../components/ialarm_xr/test_config_flow.py | 167 ------------------ tests/components/ialarm_xr/test_init.py | 110 ------------ 36 files changed, 1042 deletions(-) delete mode 100644 homeassistant/components/ialarm_xr/__init__.py delete mode 100644 homeassistant/components/ialarm_xr/alarm_control_panel.py delete mode 100644 homeassistant/components/ialarm_xr/config_flow.py delete mode 100644 homeassistant/components/ialarm_xr/const.py delete mode 100644 homeassistant/components/ialarm_xr/manifest.json delete mode 100644 homeassistant/components/ialarm_xr/strings.json delete mode 100644 homeassistant/components/ialarm_xr/translations/bg.json delete mode 100644 homeassistant/components/ialarm_xr/translations/ca.json delete mode 100644 homeassistant/components/ialarm_xr/translations/de.json delete mode 100644 homeassistant/components/ialarm_xr/translations/el.json delete mode 100644 homeassistant/components/ialarm_xr/translations/en.json delete mode 100644 homeassistant/components/ialarm_xr/translations/es.json delete mode 100644 homeassistant/components/ialarm_xr/translations/et.json delete mode 100644 homeassistant/components/ialarm_xr/translations/fr.json delete mode 100644 homeassistant/components/ialarm_xr/translations/he.json delete mode 100644 homeassistant/components/ialarm_xr/translations/hu.json delete mode 100644 homeassistant/components/ialarm_xr/translations/id.json delete mode 100644 homeassistant/components/ialarm_xr/translations/it.json delete mode 100644 homeassistant/components/ialarm_xr/translations/ja.json delete mode 100644 homeassistant/components/ialarm_xr/translations/nl.json delete mode 100644 homeassistant/components/ialarm_xr/translations/no.json delete mode 100644 homeassistant/components/ialarm_xr/translations/pl.json delete mode 100644 homeassistant/components/ialarm_xr/translations/pt-BR.json delete mode 100644 homeassistant/components/ialarm_xr/translations/tr.json delete mode 100644 homeassistant/components/ialarm_xr/translations/zh-Hant.json delete mode 100644 homeassistant/components/ialarm_xr/utils.py delete mode 100644 tests/components/ialarm_xr/__init__.py delete mode 100644 tests/components/ialarm_xr/test_config_flow.py delete mode 100644 tests/components/ialarm_xr/test_init.py diff --git a/.coveragerc b/.coveragerc index 75ed4663869..5fa747267a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -518,7 +518,6 @@ omit = homeassistant/components/hvv_departures/__init__.py homeassistant/components/hydrawise/* homeassistant/components/ialarm/alarm_control_panel.py - homeassistant/components/ialarm_xr/alarm_control_panel.py homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py homeassistant/components/iaqualink/climate.py diff --git a/.strict-typing b/.strict-typing index a2c6cd2d9da..264b28408f9 100644 --- a/.strict-typing +++ b/.strict-typing @@ -128,7 +128,6 @@ homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* homeassistant.components.hyperion.* -homeassistant.components.ialarm_xr.* homeassistant.components.image_processing.* homeassistant.components.input_button.* homeassistant.components.input_select.* diff --git a/CODEOWNERS b/CODEOWNERS index 6db9d92ebb9..9d0ba851339 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -475,8 +475,6 @@ build.json @home-assistant/supervisor /tests/components/hyperion/ @dermotduffy /homeassistant/components/ialarm/ @RyuzakiKK /tests/components/ialarm/ @RyuzakiKK -/homeassistant/components/ialarm_xr/ @bigmoby -/tests/components/ialarm_xr/ @bigmoby /homeassistant/components/iammeter/ @lewei50 /homeassistant/components/iaqualink/ @flz /tests/components/iaqualink/ @flz diff --git a/homeassistant/components/ialarm_xr/__init__.py b/homeassistant/components/ialarm_xr/__init__.py deleted file mode 100644 index 193bbe4fffc..00000000000 --- a/homeassistant/components/ialarm_xr/__init__.py +++ /dev/null @@ -1,101 +0,0 @@ -"""iAlarmXR integration.""" -from __future__ import annotations - -import asyncio -import logging - -from async_timeout import timeout -from pyialarmxr import ( - IAlarmXR, - IAlarmXRGenericException, - IAlarmXRSocketTimeoutException, -) - -from homeassistant.components.alarm_control_panel import SCAN_INTERVAL -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_HOST, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DOMAIN -from .utils import async_get_ialarmxr_mac - -PLATFORMS = [Platform.ALARM_CONTROL_PANEL] -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up iAlarmXR config.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] - username = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - - ialarmxr = IAlarmXR(username, password, host, port) - - try: - async with timeout(10): - ialarmxr_mac = await async_get_ialarmxr_mac(hass, ialarmxr) - except ( - asyncio.TimeoutError, - ConnectionError, - IAlarmXRGenericException, - IAlarmXRSocketTimeoutException, - ) as ex: - raise ConfigEntryNotReady from ex - - coordinator = IAlarmXRDataUpdateCoordinator(hass, ialarmxr, ialarmxr_mac) - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload iAlarmXR config.""" - if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok - - -class IAlarmXRDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching iAlarmXR data.""" - - def __init__(self, hass: HomeAssistant, ialarmxr: IAlarmXR, mac: str) -> None: - """Initialize global iAlarm data updater.""" - self.ialarmxr: IAlarmXR = ialarmxr - self.state: int | None = None - self.host: str = ialarmxr.host - self.mac: str = mac - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - def _update_data(self) -> None: - """Fetch data from iAlarmXR via sync functions.""" - status: int = self.ialarmxr.get_status() - _LOGGER.debug("iAlarmXR status: %s", status) - - self.state = status - - async def _async_update_data(self) -> None: - """Fetch data from iAlarmXR.""" - try: - async with timeout(10): - await self.hass.async_add_executor_job(self._update_data) - except ConnectionError as error: - raise UpdateFailed(error) from error diff --git a/homeassistant/components/ialarm_xr/alarm_control_panel.py b/homeassistant/components/ialarm_xr/alarm_control_panel.py deleted file mode 100644 index b64edb74391..00000000000 --- a/homeassistant/components/ialarm_xr/alarm_control_panel.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Interfaces with iAlarmXR control panels.""" -from __future__ import annotations - -from pyialarmxr import IAlarmXR - -from homeassistant.components.alarm_control_panel import ( - AlarmControlPanelEntity, - AlarmControlPanelEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_HOME, - STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import device_registry -from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from . import IAlarmXRDataUpdateCoordinator -from .const import DOMAIN - -IALARMXR_TO_HASS = { - IAlarmXR.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - IAlarmXR.ARMED_STAY: STATE_ALARM_ARMED_HOME, - IAlarmXR.DISARMED: STATE_ALARM_DISARMED, - IAlarmXR.TRIGGERED: STATE_ALARM_TRIGGERED, -} - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up a iAlarmXR alarm control panel based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id] - async_add_entities([IAlarmXRPanel(coordinator)]) - - -class IAlarmXRPanel( - CoordinatorEntity[IAlarmXRDataUpdateCoordinator], AlarmControlPanelEntity -): - """Representation of an iAlarmXR device.""" - - _attr_supported_features = ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - ) - _attr_name = "iAlarm_XR" - _attr_icon = "mdi:security" - - def __init__(self, coordinator: IAlarmXRDataUpdateCoordinator) -> None: - """Initialize the alarm panel.""" - super().__init__(coordinator) - self._attr_unique_id = coordinator.mac - self._attr_device_info = DeviceInfo( - manufacturer="Antifurto365 - Meian", - name=self.name, - connections={(device_registry.CONNECTION_NETWORK_MAC, coordinator.mac)}, - ) - - @property - def state(self) -> str | None: - """Return the state of the device.""" - return IALARMXR_TO_HASS.get(self.coordinator.state) - - def alarm_disarm(self, code: str | None = None) -> None: - """Send disarm command.""" - self.coordinator.ialarmxr.disarm() - - def alarm_arm_home(self, code: str | None = None) -> None: - """Send arm home command.""" - self.coordinator.ialarmxr.arm_stay() - - def alarm_arm_away(self, code: str | None = None) -> None: - """Send arm away command.""" - self.coordinator.ialarmxr.arm_away() diff --git a/homeassistant/components/ialarm_xr/config_flow.py b/homeassistant/components/ialarm_xr/config_flow.py deleted file mode 100644 index 2a9cc406733..00000000000 --- a/homeassistant/components/ialarm_xr/config_flow.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Config flow for Antifurto365 iAlarmXR integration.""" -from __future__ import annotations - -import logging -from logging import Logger -from typing import Any - -from pyialarmxr import ( - IAlarmXR, - IAlarmXRGenericException, - IAlarmXRSocketTimeoutException, -) -import voluptuous as vol - -from homeassistant import config_entries, core -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.data_entry_flow import FlowResult - -from .const import DOMAIN -from .utils import async_get_ialarmxr_mac - -_LOGGER: Logger = logging.getLogger(__name__) - -DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST, default=IAlarmXR.IALARM_P2P_DEFAULT_HOST): str, - vol.Required(CONF_PORT, default=IAlarmXR.IALARM_P2P_DEFAULT_PORT): int, - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) - - -async def _async_get_device_formatted_mac( - hass: core.HomeAssistant, username: str, password: str, host: str, port: int -) -> str: - """Return iAlarmXR mac address.""" - - ialarmxr = IAlarmXR(username, password, host, port) - return await async_get_ialarmxr_mac(hass, ialarmxr) - - -class IAlarmConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Antifurto365 iAlarmXR.""" - - VERSION = 1 - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - - errors = {} - - if user_input is not None: - mac = None - host = user_input[CONF_HOST] - port = user_input[CONF_PORT] - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] - - try: - # If we are able to get the MAC address, we are able to establish - # a connection to the device. - mac = await _async_get_device_formatted_mac( - self.hass, username, password, host, port - ) - except ConnectionError: - errors["base"] = "cannot_connect" - except IAlarmXRGenericException as ialarmxr_exception: - _LOGGER.debug( - "IAlarmXRGenericException with message: [ %s ]", - ialarmxr_exception.message, - ) - errors["base"] = "cannot_connect" - except IAlarmXRSocketTimeoutException as ialarmxr_socket_timeout_exception: - _LOGGER.debug( - "IAlarmXRSocketTimeoutException with message: [ %s ]", - ialarmxr_socket_timeout_exception.message, - ) - errors["base"] = "timeout" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - - if not errors: - await self.async_set_unique_id(mac) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input - ) - return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors - ) diff --git a/homeassistant/components/ialarm_xr/const.py b/homeassistant/components/ialarm_xr/const.py deleted file mode 100644 index 12122277340..00000000000 --- a/homeassistant/components/ialarm_xr/const.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Constants for the iAlarmXR integration.""" - -DOMAIN = "ialarm_xr" diff --git a/homeassistant/components/ialarm_xr/manifest.json b/homeassistant/components/ialarm_xr/manifest.json deleted file mode 100644 index 5befca3b95d..00000000000 --- a/homeassistant/components/ialarm_xr/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "ialarm_xr", - "name": "Antifurto365 iAlarmXR", - "documentation": "https://www.home-assistant.io/integrations/ialarm_xr", - "requirements": ["pyialarmxr-homeassistant==1.0.18"], - "codeowners": ["@bigmoby"], - "config_flow": true, - "iot_class": "cloud_polling", - "loggers": ["pyialarmxr"] -} diff --git a/homeassistant/components/ialarm_xr/strings.json b/homeassistant/components/ialarm_xr/strings.json deleted file mode 100644 index ea4f91fdbb9..00000000000 --- a/homeassistant/components/ialarm_xr/strings.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "step": { - "user": { - "data": { - "host": "[%key:common::config_flow::data::host%]", - "port": "[%key:common::config_flow::data::port%]", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "timeout": "[%key:common::config_flow::error::timeout_connect%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" - } - } -} diff --git a/homeassistant/components/ialarm_xr/translations/bg.json b/homeassistant/components/ialarm_xr/translations/bg.json deleted file mode 100644 index 2189a9653ed..00000000000 --- a/homeassistant/components/ialarm_xr/translations/bg.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" - }, - "error": { - "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", - "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" - }, - "step": { - "user": { - "data": { - "host": "\u0425\u043e\u0441\u0442", - "password": "\u041f\u0430\u0440\u043e\u043b\u0430", - "port": "\u041f\u043e\u0440\u0442", - "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/ca.json b/homeassistant/components/ialarm_xr/translations/ca.json deleted file mode 100644 index 957004fc152..00000000000 --- a/homeassistant/components/ialarm_xr/translations/ca.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" - }, - "error": { - "cannot_connect": "Ha fallat la connexi\u00f3", - "timeout": "Temps m\u00e0xim d'espera per establir la connexi\u00f3 esgotat", - "unknown": "Error inesperat" - }, - "step": { - "user": { - "data": { - "host": "Amfitri\u00f3", - "password": "Contrasenya", - "port": "Port", - "username": "Nom d'usuari" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/de.json b/homeassistant/components/ialarm_xr/translations/de.json deleted file mode 100644 index 32b35294072..00000000000 --- a/homeassistant/components/ialarm_xr/translations/de.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" - }, - "error": { - "cannot_connect": "Verbindung fehlgeschlagen", - "timeout": "Zeit\u00fcberschreitung beim Verbindungsaufbau", - "unknown": "Unerwarteter Fehler" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Passwort", - "port": "Port", - "username": "Benutzername" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/el.json b/homeassistant/components/ialarm_xr/translations/el.json deleted file mode 100644 index acc75012a67..00000000000 --- a/homeassistant/components/ialarm_xr/translations/el.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" - }, - "error": { - "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", - "timeout": "\u03a7\u03c1\u03bf\u03bd\u03b9\u03ba\u03cc \u03cc\u03c1\u03b9\u03bf \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", - "unknown": "\u0391\u03c0\u03c1\u03cc\u03c3\u03bc\u03b5\u03bd\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1" - }, - "step": { - "user": { - "data": { - "host": "\u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2", - "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", - "port": "\u0398\u03cd\u03c1\u03b1", - "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/en.json b/homeassistant/components/ialarm_xr/translations/en.json deleted file mode 100644 index be59a5a1dc4..00000000000 --- a/homeassistant/components/ialarm_xr/translations/en.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Device is already configured" - }, - "error": { - "cannot_connect": "Failed to connect", - "timeout": "Timeout establishing connection", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Password", - "port": "Port", - "username": "Username" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/es.json b/homeassistant/components/ialarm_xr/translations/es.json deleted file mode 100644 index 41cc88c7e6f..00000000000 --- a/homeassistant/components/ialarm_xr/translations/es.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "El dispositivo ya est\u00e1 configurado" - }, - "error": { - "cannot_connect": "Fallo en la conexi\u00f3n", - "timeout": "Tiempo de espera para establecer la conexi\u00f3n", - "unknown": "Error inesperado" - }, - "step": { - "user": { - "data": { - "host": "Anfitri\u00f3n", - "password": "Contrase\u00f1a", - "port": "Puerto", - "username": "Nombre de usuario" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/et.json b/homeassistant/components/ialarm_xr/translations/et.json deleted file mode 100644 index 3679dd47f2e..00000000000 --- a/homeassistant/components/ialarm_xr/translations/et.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" - }, - "error": { - "cannot_connect": "\u00dchendamine nurjus", - "timeout": "\u00dchenduse ajal\u00f5pp", - "unknown": "Ootamatu t\u00f5rge" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Salas\u00f5na", - "port": "Port", - "username": "Kasutajanimi" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/fr.json b/homeassistant/components/ialarm_xr/translations/fr.json deleted file mode 100644 index 2ade23c9f4e..00000000000 --- a/homeassistant/components/ialarm_xr/translations/fr.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" - }, - "error": { - "cannot_connect": "\u00c9chec de connexion", - "timeout": "D\u00e9lai d'attente pour \u00e9tablir la connexion expir\u00e9", - "unknown": "Erreur inattendue" - }, - "step": { - "user": { - "data": { - "host": "H\u00f4te", - "password": "Mot de passe", - "port": "Port", - "username": "Nom d'utilisateur" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/he.json b/homeassistant/components/ialarm_xr/translations/he.json deleted file mode 100644 index b3fb785d55e..00000000000 --- a/homeassistant/components/ialarm_xr/translations/he.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" - }, - "error": { - "cannot_connect": "\u05d4\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05e0\u05db\u05e9\u05dc\u05d4", - "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" - }, - "step": { - "user": { - "data": { - "host": "\u05de\u05d0\u05e8\u05d7", - "password": "\u05e1\u05d9\u05e1\u05de\u05d4", - "port": "\u05e4\u05ea\u05d7\u05d4", - "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/hu.json b/homeassistant/components/ialarm_xr/translations/hu.json deleted file mode 100644 index 6f72253aae6..00000000000 --- a/homeassistant/components/ialarm_xr/translations/hu.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" - }, - "error": { - "cannot_connect": "Sikertelen csatlakoz\u00e1s", - "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a kapcsolat l\u00e9trehoz\u00e1sa sor\u00e1n", - "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt" - }, - "step": { - "user": { - "data": { - "host": "C\u00edm", - "password": "Jelsz\u00f3", - "port": "Port", - "username": "Felhaszn\u00e1l\u00f3n\u00e9v" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/id.json b/homeassistant/components/ialarm_xr/translations/id.json deleted file mode 100644 index e4688af2b37..00000000000 --- a/homeassistant/components/ialarm_xr/translations/id.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" - }, - "error": { - "cannot_connect": "Gagal terhubung", - "timeout": "Tenggang waktu membuat koneksi habis", - "unknown": "Kesalahan yang tidak diharapkan" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Kata Sandi", - "port": "Port", - "username": "Nama Pengguna" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/it.json b/homeassistant/components/ialarm_xr/translations/it.json deleted file mode 100644 index ae8437aaa86..00000000000 --- a/homeassistant/components/ialarm_xr/translations/it.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" - }, - "error": { - "cannot_connect": "Impossibile connettersi", - "timeout": "Tempo scaduto per stabile la connessione.", - "unknown": "Errore imprevisto" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Password", - "port": "Porta", - "username": "Nome utente" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/ja.json b/homeassistant/components/ialarm_xr/translations/ja.json deleted file mode 100644 index e6f384ed615..00000000000 --- a/homeassistant/components/ialarm_xr/translations/ja.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" - }, - "error": { - "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", - "timeout": "\u63a5\u7d9a\u78ba\u7acb\u6642\u306b\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8", - "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" - }, - "step": { - "user": { - "data": { - "host": "\u30db\u30b9\u30c8", - "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", - "port": "\u30dd\u30fc\u30c8", - "username": "\u30e6\u30fc\u30b6\u30fc\u540d" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/nl.json b/homeassistant/components/ialarm_xr/translations/nl.json deleted file mode 100644 index a32ee7ccfbe..00000000000 --- a/homeassistant/components/ialarm_xr/translations/nl.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Apparaat is al geconfigureerd" - }, - "error": { - "cannot_connect": "Kan geen verbinding maken", - "timeout": "Time-out bij het maken van verbinding", - "unknown": "Onverwachte fout" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Wachtwoord", - "port": "Poort", - "username": "Gebruikersnaam" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/no.json b/homeassistant/components/ialarm_xr/translations/no.json deleted file mode 100644 index ccadf2f9972..00000000000 --- a/homeassistant/components/ialarm_xr/translations/no.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Enheten er allerede konfigurert" - }, - "error": { - "cannot_connect": "Tilkobling mislyktes", - "timeout": "Tidsavbrudd oppretter forbindelse", - "unknown": "Uventet feil" - }, - "step": { - "user": { - "data": { - "host": "Vert", - "password": "Passord", - "port": "Port", - "username": "Brukernavn" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/pl.json b/homeassistant/components/ialarm_xr/translations/pl.json deleted file mode 100644 index 3880b4a6e09..00000000000 --- a/homeassistant/components/ialarm_xr/translations/pl.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" - }, - "error": { - "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", - "timeout": "Limit czasu na nawi\u0105zanie po\u0142\u0105czenia", - "unknown": "Nieoczekiwany b\u0142\u0105d" - }, - "step": { - "user": { - "data": { - "host": "Nazwa hosta lub adres IP", - "password": "Has\u0142o", - "port": "Port", - "username": "Nazwa u\u017cytkownika" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/pt-BR.json b/homeassistant/components/ialarm_xr/translations/pt-BR.json deleted file mode 100644 index 37dd49dc9d8..00000000000 --- a/homeassistant/components/ialarm_xr/translations/pt-BR.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" - }, - "error": { - "cannot_connect": "Falhou ao se conectar", - "timeout": "Tempo limite estabelecendo conex\u00e3o", - "unknown": "Erro inesperado" - }, - "step": { - "user": { - "data": { - "host": "Host", - "password": "Senha", - "port": "Porta", - "username": "Nome de usu\u00e1rio" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/tr.json b/homeassistant/components/ialarm_xr/translations/tr.json deleted file mode 100644 index f15e4339e3c..00000000000 --- a/homeassistant/components/ialarm_xr/translations/tr.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" - }, - "error": { - "cannot_connect": "Ba\u011flanma hatas\u0131", - "unknown": "Beklenmeyen hata" - }, - "step": { - "user": { - "data": { - "host": "Sunucu", - "password": "Parola", - "port": "Port", - "username": "Kullan\u0131c\u0131 Ad\u0131" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/translations/zh-Hant.json b/homeassistant/components/ialarm_xr/translations/zh-Hant.json deleted file mode 100644 index b47b6268af1..00000000000 --- a/homeassistant/components/ialarm_xr/translations/zh-Hant.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "config": { - "abort": { - "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" - }, - "error": { - "cannot_connect": "\u9023\u7dda\u5931\u6557", - "timeout": "\u5efa\u7acb\u9023\u7dda\u903e\u6642", - "unknown": "\u672a\u9810\u671f\u932f\u8aa4" - }, - "step": { - "user": { - "data": { - "host": "\u4e3b\u6a5f\u7aef", - "password": "\u5bc6\u78bc", - "port": "\u901a\u8a0a\u57e0", - "username": "\u4f7f\u7528\u8005\u540d\u7a31" - } - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/ialarm_xr/utils.py b/homeassistant/components/ialarm_xr/utils.py deleted file mode 100644 index db82a3fcd44..00000000000 --- a/homeassistant/components/ialarm_xr/utils.py +++ /dev/null @@ -1,18 +0,0 @@ -"""iAlarmXR utils.""" -import logging - -from pyialarmxr import IAlarmXR - -from homeassistant import core -from homeassistant.helpers.device_registry import format_mac - -_LOGGER = logging.getLogger(__name__) - - -async def async_get_ialarmxr_mac(hass: core.HomeAssistant, ialarmxr: IAlarmXR) -> str: - """Retrieve iAlarmXR MAC address.""" - _LOGGER.debug("Retrieving ialarmxr mac address") - - mac = await hass.async_add_executor_job(ialarmxr.get_mac) - - return format_mac(mac) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 843e3f7f7a9..1cf8ba743bc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -161,7 +161,6 @@ FLOWS = { "hvv_departures", "hyperion", "ialarm", - "ialarm_xr", "iaqualink", "icloud", "ifttt", diff --git a/mypy.ini b/mypy.ini index fb47638d59d..522cda67e79 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1171,17 +1171,6 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true -[mypy-homeassistant.components.ialarm_xr.*] -check_untyped_defs = true -disallow_incomplete_defs = true -disallow_subclassing_any = true -disallow_untyped_calls = true -disallow_untyped_decorators = true -disallow_untyped_defs = true -no_implicit_optional = true -warn_return_any = true -warn_unreachable = true - [mypy-homeassistant.components.image_processing.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 6ae6cdbaa1a..70a29067a7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1552,9 +1552,6 @@ pyhomeworks==0.0.6 # homeassistant.components.ialarm pyialarm==1.9.0 -# homeassistant.components.ialarm_xr -pyialarmxr-homeassistant==1.0.18 - # homeassistant.components.icloud pyicloud==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47f0c9336c9..850a379eaf6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1040,9 +1040,6 @@ pyhomematic==0.1.77 # homeassistant.components.ialarm pyialarm==1.9.0 -# homeassistant.components.ialarm_xr -pyialarmxr-homeassistant==1.0.18 - # homeassistant.components.icloud pyicloud==1.0.0 diff --git a/tests/components/ialarm_xr/__init__.py b/tests/components/ialarm_xr/__init__.py deleted file mode 100644 index 4097867f70b..00000000000 --- a/tests/components/ialarm_xr/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Antifurto365 iAlarmXR integration.""" diff --git a/tests/components/ialarm_xr/test_config_flow.py b/tests/components/ialarm_xr/test_config_flow.py deleted file mode 100644 index 804249dd5cb..00000000000 --- a/tests/components/ialarm_xr/test_config_flow.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Test the Antifurto365 iAlarmXR config flow.""" - -from unittest.mock import patch - -from pyialarmxr import IAlarmXRGenericException, IAlarmXRSocketTimeoutException - -from homeassistant import config_entries, data_entry_flow -from homeassistant.components.ialarm_xr.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME - -from tests.common import MockConfigEntry - -TEST_DATA = { - CONF_HOST: "1.1.1.1", - CONF_PORT: 18034, - CONF_USERNAME: "000ZZZ0Z00", - CONF_PASSWORD: "00000000", -} - -TEST_MAC = "00:00:54:12:34:56" - - -async def test_form(hass): - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["handler"] == "ialarm_xr" - assert result["data_schema"].schema.get("host") == str - assert result["data_schema"].schema.get("port") == int - assert result["data_schema"].schema.get("password") == str - assert result["data_schema"].schema.get("username") == str - assert result["errors"] == {} - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_status", - return_value=1, - ), patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - return_value=TEST_MAC, - ), patch( - "homeassistant.components.ialarm_xr.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - await hass.async_block_till_done() - - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == TEST_DATA["host"] - assert result2["data"] == TEST_DATA - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_exception(hass): - """Test we handle unknown exception.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "unknown"} - - -async def test_form_cannot_connect_throwing_connection_error(hass): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=ConnectionError, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_cannot_connect_throwing_socket_timeout_exception(hass): - """Test we handle cannot connect error because of socket timeout.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=IAlarmXRSocketTimeoutException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "timeout"} - - -async def test_form_cannot_connect_throwing_generic_exception(hass): - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - side_effect=IAlarmXRGenericException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_already_exists(hass): - """Test that a flow with an existing host aborts.""" - entry = MockConfigEntry( - domain=DOMAIN, - unique_id=TEST_MAC, - data=TEST_DATA, - ) - - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.ialarm_xr.config_flow.IAlarmXR.get_mac", - return_value=TEST_MAC, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], TEST_DATA - ) - - assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result2["reason"] == "already_configured" - - -async def test_flow_user_step_no_input(hass): - """Test appropriate error when no input is provided.""" - _result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await hass.config_entries.flow.async_configure( - _result["flow_id"], user_input=None - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == config_entries.SOURCE_USER - assert result["errors"] == {} diff --git a/tests/components/ialarm_xr/test_init.py b/tests/components/ialarm_xr/test_init.py deleted file mode 100644 index 0898b6bebf8..00000000000 --- a/tests/components/ialarm_xr/test_init.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Test the Antifurto365 iAlarmXR init.""" -import asyncio -from datetime import timedelta -from unittest.mock import Mock, patch -from uuid import uuid4 - -import pytest - -from homeassistant.components.ialarm_xr.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.util.dt import utcnow - -from tests.common import MockConfigEntry, async_fire_time_changed - - -@pytest.fixture(name="ialarmxr_api") -def ialarmxr_api_fixture(): - """Set up IAlarmXR API fixture.""" - with patch("homeassistant.components.ialarm_xr.IAlarmXR") as mock_ialarm_api: - yield mock_ialarm_api - - -@pytest.fixture(name="mock_config_entry") -def mock_config_fixture(): - """Return a fake config entry.""" - return MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "192.168.10.20", - CONF_PORT: 18034, - CONF_USERNAME: "000ZZZ0Z00", - CONF_PASSWORD: "00000000", - }, - entry_id=str(uuid4()), - ) - - -async def test_setup_entry(hass, ialarmxr_api, mock_config_entry): - """Test setup entry.""" - ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - ialarmxr_api.return_value.get_mac.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.LOADED - - -async def test_unload_entry(hass, ialarmxr_api, mock_config_entry): - """Test being able to unload an entry.""" - ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - assert mock_config_entry.state is ConfigEntryState.LOADED - assert await hass.config_entries.async_unload(mock_config_entry.entry_id) - assert mock_config_entry.state is ConfigEntryState.NOT_LOADED - - -async def test_setup_not_ready_connection_error(hass, ialarmxr_api, mock_config_entry): - """Test setup failed because we can't connect to the alarm system.""" - ialarmxr_api.return_value.get_status = Mock(side_effect=ConnectionError) - - mock_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - future = utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_not_ready_timeout(hass, ialarmxr_api, mock_config_entry): - """Test setup failed because we can't connect to the alarm system.""" - ialarmxr_api.return_value.get_status = Mock(side_effect=asyncio.TimeoutError) - - mock_config_entry.add_to_hass(hass) - assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - future = utcnow() + timedelta(seconds=30) - async_fire_time_changed(hass, future) - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_setup_entry_and_then_fail_on_update( - hass, ialarmxr_api, mock_config_entry -): - """Test setup entry.""" - ialarmxr_api.return_value.get_mac = Mock(return_value="00:00:54:12:34:56") - ialarmxr_api.return_value.get_status = Mock(value=ialarmxr_api.DISARMED) - - mock_config_entry.add_to_hass(hass) - assert await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - - ialarmxr_api.return_value.get_mac.assert_called_once() - ialarmxr_api.return_value.get_status.assert_called_once() - assert mock_config_entry.state is ConfigEntryState.LOADED - - ialarmxr_api.return_value.get_status = Mock(side_effect=asyncio.TimeoutError) - future = utcnow() + timedelta(seconds=60) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - ialarmxr_api.return_value.get_status.assert_called_once() - assert hass.states.get("alarm_control_panel.ialarm_xr").state == "unavailable" From 2e47cee72a12253ee66d5d4bd1c1f2df47b000d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 6 Jun 2022 19:48:49 -1000 Subject: [PATCH 274/947] Fix setup race when config entry is in a setup retry state (#73145) --- homeassistant/config_entries.py | 6 +++ tests/test_config_entries.py | 72 ++++++++++++++++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index df633009138..bb24b24a7fb 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -90,6 +90,8 @@ class ConfigEntryState(Enum): """The config entry has not been loaded""" FAILED_UNLOAD = "failed_unload", False """An error occurred when trying to unload the entry""" + SETUP_IN_PROGRESS = "setup_in_progress", True + """The config entry is setting up.""" _recoverable: bool @@ -294,6 +296,10 @@ class ConfigEntry: if integration is None: integration = await loader.async_get_integration(hass, self.domain) + # Only store setup result as state if it was not forwarded. + if self.domain == integration.domain: + self.state = ConfigEntryState.SETUP_IN_PROGRESS + self.supports_unload = await support_entry_unload(hass, self.domain) self.supports_remove_device = await support_remove_from_device( hass, self.domain diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 09951b4f34e..dbbf542d36c 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -16,7 +16,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import CoreState, Event, callback +from homeassistant.core import CoreState, Event, HomeAssistant, callback from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, BaseServiceInfo, FlowResult from homeassistant.exceptions import ( ConfigEntryAuthFailed, @@ -3190,3 +3190,73 @@ async def test_entry_reload_concurrency(hass, manager): await asyncio.gather(*tasks) assert entry.state is config_entries.ConfigEntryState.LOADED assert loaded == 1 + + +async def test_unique_id_update_while_setup_in_progress( + hass: HomeAssistant, manager: config_entries.ConfigEntries +) -> None: + """Test we handle the case where the config entry is updated while setup is in progress.""" + + async def mock_setup_entry(hass, entry): + """Mock setting up entry.""" + await asyncio.sleep(0.1) + return True + + async def mock_unload_entry(hass, entry): + """Mock unloading an entry.""" + return True + + hass.config.components.add("comp") + entry = MockConfigEntry( + domain="comp", + data={"additional": "data", "host": "0.0.0.0"}, + unique_id="mock-unique-id", + state=config_entries.ConfigEntryState.SETUP_RETRY, + ) + entry.add_to_hass(hass) + + mock_integration( + hass, + MockModule( + "comp", + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + ), + ) + mock_entity_platform(hass, "config_flow.comp", None) + updates = {"host": "1.1.1.1"} + + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + await asyncio.sleep(0) + assert entry.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS + + class TestFlow(config_entries.ConfigFlow): + """Test flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Test user step.""" + await self.async_set_unique_id("mock-unique-id") + await self._abort_if_unique_id_configured( + updates=updates, reload_on_update=True + ) + + with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch( + "homeassistant.config_entries.ConfigEntries.async_reload" + ) as async_reload: + result = await manager.flow.async_init( + "comp", context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert entry.data["host"] == "1.1.1.1" + assert entry.data["additional"] == "data" + + # Setup is already in progress, we should not reload + # if it fails it will go into a retry state and try again + assert len(async_reload.mock_calls) == 0 + await hass.async_block_till_done() + assert entry.state is config_entries.ConfigEntryState.LOADED From a8763d74798ecc95d4a2b323235aabea5463da66 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Tue, 7 Jun 2022 07:57:41 +0200 Subject: [PATCH 275/947] Update pylint to 2.14.1 (#73144) --- homeassistant/components/broadlink/config_flow.py | 8 ++------ requirements_test.txt | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index da8b489a98b..8a32ba02ee8 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -188,9 +188,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(device.mac.hex()) _LOGGER.error( - "Failed to authenticate to the device at %s: %s", - device.host[0], - err_msg, # pylint: disable=used-before-assignment + "Failed to authenticate to the device at %s: %s", device.host[0], err_msg ) return self.async_show_form(step_id="auth", errors=errors) @@ -253,9 +251,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_finish() _LOGGER.error( - "Failed to unlock the device at %s: %s", - device.host[0], - err_msg, # pylint: disable=used-before-assignment + "Failed to unlock the device at %s: %s", device.host[0], err_msg ) else: diff --git a/requirements_test.txt b/requirements_test.txt index afef38074ee..7c03d2e51c2 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.2.1 mock-open==1.4.0 mypy==0.961 pre-commit==2.19.0 -pylint==2.14.0 +pylint==2.14.1 pipdeptree==2.2.1 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 From 6971bb8f5bce2183dccb2e0ee7c3845a14dffd94 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jun 2022 11:15:31 +0200 Subject: [PATCH 276/947] Adjust config-flow type hints in vera (#72409) * Adjust config-flow type hints in vera * Reduce size of PR --- homeassistant/components/vera/config_flow.py | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py index 319dcd031d0..c300f599faa 100644 --- a/homeassistant/components/vera/config_flow.py +++ b/homeassistant/components/vera/config_flow.py @@ -14,6 +14,7 @@ from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import entity_registry as er from .const import CONF_CONTROLLER, CONF_LEGACY_UNIQUE_ID, DOMAIN @@ -37,12 +38,14 @@ def list_to_str(data: list[Any]) -> str: return " ".join([str(i) for i in data]) -def new_options(lights: list[int], exclude: list[int]) -> dict: +def new_options(lights: list[int], exclude: list[int]) -> dict[str, list[int]]: """Create a standard options object.""" return {CONF_LIGHTS: lights, CONF_EXCLUDE: exclude} -def options_schema(options: Mapping[str, Any] = None) -> dict: +def options_schema( + options: Mapping[str, Any] | None = None +) -> dict[vol.Optional, type[str]]: """Return options schema.""" options = options or {} return { @@ -57,7 +60,7 @@ def options_schema(options: Mapping[str, Any] = None) -> dict: } -def options_data(user_input: dict) -> dict: +def options_data(user_input: dict[str, str]) -> dict[str, list[int]]: """Return options dict.""" return new_options( str_to_int_list(user_input.get(CONF_LIGHTS, "")), @@ -72,7 +75,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input: dict = None): + async def async_step_init( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( @@ -95,7 +101,9 @@ class VeraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def async_step_user(self, user_input: dict = None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle user initiated flow.""" if user_input is not None: return await self.async_step_finish( @@ -114,7 +122,7 @@ class VeraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_import(self, config: dict): + async def async_step_import(self, config: dict[str, Any]) -> FlowResult: """Handle a flow initialized by import.""" # If there are entities with the legacy unique_id, then this imported config @@ -139,7 +147,7 @@ class VeraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): } ) - async def async_step_finish(self, config: dict): + async def async_step_finish(self, config: dict[str, Any]) -> FlowResult: """Validate and create config entry.""" base_url = config[CONF_CONTROLLER] = config[CONF_CONTROLLER].rstrip("/") controller = pv.VeraController(base_url) From ab82f71b4315d792d80eb1318ae8899350a8d3c5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 7 Jun 2022 11:30:12 +0200 Subject: [PATCH 277/947] Adjust config-flow type hints in xiaomi_miio (#72503) * Adjust config-flow type hints in xiaomi_miio * Use Mapping * Reduce size of PR --- .../components/xiaomi_miio/config_flow.py | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index a78e01c7fae..e5b5275757c 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -1,6 +1,10 @@ """Config flow to configure Xiaomi Miio.""" +from __future__ import annotations + +from collections.abc import Mapping import logging from re import search +from typing import Any from micloud import MiCloud from micloud.micloudexception import MiCloudAccessDenied @@ -8,7 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf -from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.const import CONF_HOST, CONF_MODEL, CONF_NAME, CONF_TOKEN from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -61,7 +65,9 @@ class OptionsFlowHandler(config_entries.OptionsFlow): """Init object.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Manage the options.""" errors = {} if user_input is not None: @@ -105,25 +111,25 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self.host = None - self.mac = None + self.host: str | None = None + self.mac: str | None = None self.token = None self.model = None self.name = None self.cloud_username = None self.cloud_password = None self.cloud_country = None - self.cloud_devices = {} + self.cloud_devices: dict[str, dict[str, Any]] = {} @staticmethod @callback - def async_get_options_flow(config_entry) -> OptionsFlowHandler: + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an authentication error or missing cloud credentials.""" self.host = user_input[CONF_HOST] self.token = user_input[CONF_TOKEN] @@ -131,13 +137,15 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.model = user_input.get(CONF_MODEL) return await self.async_step_reauth_confirm() - async def async_step_reauth_confirm(self, user_input=None): + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Dialog that informs the user that reauth is required.""" if user_input is not None: return await self.async_step_cloud() return self.async_show_form(step_id="reauth_confirm") - async def async_step_import(self, conf: dict): + async def async_step_import(self, conf: dict[str, Any]) -> FlowResult: """Import a configuration from config.yaml.""" self.host = conf[CONF_HOST] self.token = conf[CONF_TOKEN] @@ -149,7 +157,9 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_connect() - async def async_step_user(self, user_input=None): + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" return await self.async_step_cloud() @@ -203,7 +213,7 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) return self.async_abort(reason="not_xiaomi_miio") - def extract_cloud_info(self, cloud_device_info): + def extract_cloud_info(self, cloud_device_info: dict[str, Any]) -> None: """Extract the cloud info.""" if self.host is None: self.host = cloud_device_info["localip"] @@ -215,7 +225,9 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.name = cloud_device_info["name"] self.token = cloud_device_info["token"] - async def async_step_cloud(self, user_input=None): + async def async_step_cloud( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Configure a xiaomi miio device through the Miio Cloud.""" errors = {} if user_input is not None: @@ -283,9 +295,11 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="cloud", data_schema=DEVICE_CLOUD_CONFIG, errors=errors ) - async def async_step_select(self, user_input=None): + async def async_step_select( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Handle multiple cloud devices found.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: cloud_device = self.cloud_devices[user_input["select_device"]] self.extract_cloud_info(cloud_device) @@ -299,9 +313,11 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="select", data_schema=select_schema, errors=errors ) - async def async_step_manual(self, user_input=None): + async def async_step_manual( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Configure a xiaomi miio device Manually.""" - errors = {} + errors: dict[str, str] = {} if user_input is not None: self.token = user_input[CONF_TOKEN] if user_input.get(CONF_HOST): @@ -316,9 +332,11 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="manual", data_schema=schema, errors=errors) - async def async_step_connect(self, user_input=None): + async def async_step_connect( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: """Connect to a xiaomi miio device.""" - errors = {} + errors: dict[str, str] = {} if self.host is None or self.token is None: return self.async_abort(reason="incomplete_info") From 5f2b4001f31f008ee581c2b1aa046bea3a360915 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 7 Jun 2022 14:41:43 +0200 Subject: [PATCH 278/947] Separate recorder database schema from other classes (#72977) * Separate recorder database schema from other classes * fix logbook imports * migrate new tests * few more * last one * fix merge Co-authored-by: J. Nick Koston --- .../components/logbook/queries/all.py | 6 +- .../components/logbook/queries/common.py | 4 +- .../components/logbook/queries/devices.py | 2 +- .../components/logbook/queries/entities.py | 2 +- .../logbook/queries/entities_and_devices.py | 2 +- homeassistant/components/recorder/core.py | 8 +- .../components/recorder/db_schema.py | 600 ++++++++++++++++++ homeassistant/components/recorder/filters.py | 2 +- homeassistant/components/recorder/history.py | 4 +- .../components/recorder/migration.py | 4 +- homeassistant/components/recorder/models.py | 592 +---------------- homeassistant/components/recorder/purge.py | 2 +- homeassistant/components/recorder/queries.py | 2 +- homeassistant/components/recorder/repack.py | 2 +- .../components/recorder/run_history.py | 3 +- .../components/recorder/statistics.py | 5 +- homeassistant/components/recorder/util.py | 5 +- tests/components/automation/test_recorder.py | 2 +- tests/components/camera/test_recorder.py | 2 +- tests/components/climate/test_recorder.py | 2 +- tests/components/fan/test_recorder.py | 2 +- tests/components/group/test_recorder.py | 2 +- tests/components/humidifier/test_recorder.py | 2 +- .../components/input_boolean/test_recorder.py | 2 +- .../components/input_button/test_recorder.py | 2 +- .../input_datetime/test_recorder.py | 2 +- .../components/input_number/test_recorder.py | 2 +- .../components/input_select/test_recorder.py | 2 +- tests/components/input_text/test_recorder.py | 2 +- tests/components/light/test_recorder.py | 2 +- .../components/media_player/test_recorder.py | 2 +- tests/components/number/test_recorder.py | 2 +- tests/components/recorder/common.py | 6 +- .../{models_schema_0.py => db_schema_0.py} | 0 .../{models_schema_16.py => db_schema_16.py} | 0 .../{models_schema_18.py => db_schema_18.py} | 0 .../{models_schema_22.py => db_schema_22.py} | 0 .../{models_schema_23.py => db_schema_23.py} | 0 ....py => db_schema_23_with_newer_columns.py} | 0 .../{models_schema_28.py => db_schema_28.py} | 0 .../test_filters_with_entityfilter.py | 2 +- tests/components/recorder/test_history.py | 5 +- tests/components/recorder/test_init.py | 4 +- tests/components/recorder/test_migrate.py | 20 +- tests/components/recorder/test_models.py | 6 +- tests/components/recorder/test_purge.py | 2 +- tests/components/recorder/test_run_history.py | 3 +- tests/components/recorder/test_statistics.py | 72 ++- .../recorder/test_statistics_v23_migration.py | 82 +-- tests/components/recorder/test_util.py | 3 +- tests/components/script/test_recorder.py | 2 +- tests/components/select/test_recorder.py | 2 +- tests/components/sensor/test_recorder.py | 8 +- tests/components/siren/test_recorder.py | 2 +- tests/components/sun/test_recorder.py | 2 +- tests/components/update/test_recorder.py | 2 +- tests/components/vacuum/test_recorder.py | 2 +- .../components/water_heater/test_recorder.py | 2 +- tests/components/weather/test_recorder.py | 2 +- 59 files changed, 771 insertions(+), 733 deletions(-) create mode 100644 homeassistant/components/recorder/db_schema.py rename tests/components/recorder/{models_schema_0.py => db_schema_0.py} (100%) rename tests/components/recorder/{models_schema_16.py => db_schema_16.py} (100%) rename tests/components/recorder/{models_schema_18.py => db_schema_18.py} (100%) rename tests/components/recorder/{models_schema_22.py => db_schema_22.py} (100%) rename tests/components/recorder/{models_schema_23.py => db_schema_23.py} (100%) rename tests/components/recorder/{models_schema_23_with_newer_columns.py => db_schema_23_with_newer_columns.py} (100%) rename tests/components/recorder/{models_schema_28.py => db_schema_28.py} (100%) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index d321578f545..da05aa02fff 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -8,7 +8,11 @@ from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.lambdas import StatementLambdaElement -from homeassistant.components.recorder.models import LAST_UPDATED_INDEX, Events, States +from homeassistant.components.recorder.db_schema import ( + LAST_UPDATED_INDEX, + Events, + States, +) from .common import ( apply_states_filters, diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 56925b60e62..5b79f6e0d32 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -10,8 +10,7 @@ from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.expression import literal from sqlalchemy.sql.selectable import Select -from homeassistant.components.recorder.filters import like_domain_matchers -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( EVENTS_CONTEXT_ID_INDEX, OLD_FORMAT_ATTRS_JSON, OLD_STATE, @@ -22,6 +21,7 @@ from homeassistant.components.recorder.models import ( StateAttributes, States, ) +from homeassistant.components.recorder.filters import like_domain_matchers from ..const import ALWAYS_CONTINUOUS_DOMAINS, CONDITIONALLY_CONTINUOUS_DOMAINS diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index 88e9f50a42c..f750c552bc4 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -10,7 +10,7 @@ from sqlalchemy.sql.elements import ClauseList from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( DEVICE_ID_IN_EVENT, EventData, Events, diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 8de4a5eaf64..4ef96c100d7 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Query from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( ENTITY_ID_IN_EVENT, ENTITY_ID_LAST_UPDATED_INDEX, OLD_ENTITY_ID_IN_EVENT, diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 1c4271422b7..591918dd653 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -10,7 +10,7 @@ from sqlalchemy.orm import Query from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import CTE, CompoundSelect -from homeassistant.components.recorder.models import EventData, Events, States +from homeassistant.components.recorder.db_schema import EventData, Events, States from .common import ( apply_events_context_hints, diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 7a096a9c404..d8260976ccf 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -48,17 +48,19 @@ from .const import ( SQLITE_URL_PREFIX, SupportedDialect, ) -from .executor import DBInterruptibleThreadPoolExecutor -from .models import ( +from .db_schema import ( SCHEMA_VERSION, Base, EventData, Events, StateAttributes, States, + StatisticsRuns, +) +from .executor import DBInterruptibleThreadPoolExecutor +from .models import ( StatisticData, StatisticMetaData, - StatisticsRuns, UnsupportedDialect, process_timestamp, ) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py new file mode 100644 index 00000000000..642efe2e969 --- /dev/null +++ b/homeassistant/components/recorder/db_schema.py @@ -0,0 +1,600 @@ +"""Models for SQLAlchemy.""" +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime, timedelta +import json +import logging +from typing import Any, cast + +import ciso8601 +from fnvhash import fnv1a_32 +from sqlalchemy import ( + JSON, + BigInteger, + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Identity, + Index, + Integer, + SmallInteger, + String, + Text, + distinct, + type_coerce, +) +from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite +from sqlalchemy.ext.declarative import declared_attr +from sqlalchemy.orm import aliased, declarative_base, relationship +from sqlalchemy.orm.session import Session + +from homeassistant.const import ( + MAX_LENGTH_EVENT_CONTEXT_ID, + MAX_LENGTH_EVENT_EVENT_TYPE, + MAX_LENGTH_EVENT_ORIGIN, + MAX_LENGTH_STATE_ENTITY_ID, + MAX_LENGTH_STATE_STATE, +) +from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +import homeassistant.util.dt as dt_util + +from .const import ALL_DOMAIN_EXCLUDE_ATTRS, JSON_DUMP +from .models import StatisticData, StatisticMetaData, process_timestamp + +# SQLAlchemy Schema +# pylint: disable=invalid-name +Base = declarative_base() + +SCHEMA_VERSION = 29 + +_LOGGER = logging.getLogger(__name__) + +TABLE_EVENTS = "events" +TABLE_EVENT_DATA = "event_data" +TABLE_STATES = "states" +TABLE_STATE_ATTRIBUTES = "state_attributes" +TABLE_RECORDER_RUNS = "recorder_runs" +TABLE_SCHEMA_CHANGES = "schema_changes" +TABLE_STATISTICS = "statistics" +TABLE_STATISTICS_META = "statistics_meta" +TABLE_STATISTICS_RUNS = "statistics_runs" +TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" + +ALL_TABLES = [ + TABLE_STATES, + TABLE_STATE_ATTRIBUTES, + TABLE_EVENTS, + TABLE_EVENT_DATA, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, + TABLE_STATISTICS, + TABLE_STATISTICS_META, + TABLE_STATISTICS_RUNS, + TABLE_STATISTICS_SHORT_TERM, +] + +TABLES_TO_CHECK = [ + TABLE_STATES, + TABLE_EVENTS, + TABLE_RECORDER_RUNS, + TABLE_SCHEMA_CHANGES, +] + +LAST_UPDATED_INDEX = "ix_states_last_updated" +ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated" +EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" +STATES_CONTEXT_ID_INDEX = "ix_states_context_id" + + +class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): # type: ignore[misc] + """Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex.""" + + def result_processor(self, dialect, coltype): # type: ignore[no-untyped-def] + """Offload the datetime parsing to ciso8601.""" + return lambda value: None if value is None else ciso8601.parse_datetime(value) + + +JSON_VARIENT_CAST = Text().with_variant( + postgresql.JSON(none_as_null=True), "postgresql" +) +JSONB_VARIENT_CAST = Text().with_variant( + postgresql.JSONB(none_as_null=True), "postgresql" +) +DATETIME_TYPE = ( + DateTime(timezone=True) + .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql") + .with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") +) +DOUBLE_TYPE = ( + Float() + .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") + .with_variant(oracle.DOUBLE_PRECISION(), "oracle") + .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") +) + + +class JSONLiteral(JSON): # type: ignore[misc] + """Teach SA how to literalize json.""" + + def literal_processor(self, dialect: str) -> Callable[[Any], str]: + """Processor to convert a value to JSON.""" + + def process(value: Any) -> str: + """Dump json.""" + return json.dumps(value) + + return process + + +EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] +EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} + + +class Events(Base): # type: ignore[misc,valid-type] + """Event history data.""" + + __table_args__ = ( + # Used for fetching events at a specific time + # see logbook + Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENTS + event_id = Column(Integer, Identity(), primary_key=True) + event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) + event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used for new rows + origin_idx = Column(SmallInteger) + time_fired = Column(DATETIME_TYPE, index=True) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) + event_data_rel = relationship("EventData") + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> Events: + """Create an event database object from a native event.""" + return Events( + event_type=event.event_type, + event_data=None, + origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + ) + + def to_native(self, validate_entity_id: bool = True) -> Event | None: + """Convert to a native HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + return Event( + self.event_type, + json.loads(self.event_data) if self.event_data else {}, + EventOrigin(self.origin) + if self.origin + else EVENT_ORIGIN_ORDER[self.origin_idx], + process_timestamp(self.time_fired), + context=context, + ) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting to event: %s", self) + return None + + +class EventData(Base): # type: ignore[misc,valid-type] + """Event data history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_EVENT_DATA + data_id = Column(Integer, Identity(), primary_key=True) + hash = Column(BigInteger, index=True) + # Note that this is not named attributes to avoid confusion with the states table + shared_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> EventData: + """Create object from an event.""" + shared_data = JSON_DUMP(event.data) + return EventData( + shared_data=shared_data, hash=EventData.hash_shared_data(shared_data) + ) + + @staticmethod + def shared_data_from_event(event: Event) -> str: + """Create shared_attrs from an event.""" + return JSON_DUMP(event.data) + + @staticmethod + def hash_shared_data(shared_data: str) -> int: + """Return the hash of json encoded shared data.""" + return cast(int, fnv1a_32(shared_data.encode("utf-8"))) + + def to_native(self) -> dict[str, Any]: + """Convert to an HA state object.""" + try: + return cast(dict[str, Any], json.loads(self.shared_data)) + except ValueError: + _LOGGER.exception("Error converting row to event data: %s", self) + return {} + + +class States(Base): # type: ignore[misc,valid-type] + """State change history.""" + + __table_args__ = ( + # Used for fetching the state of entities at a specific time + # (get_states in history.py) + Index(ENTITY_ID_LAST_UPDATED_INDEX, "entity_id", "last_updated"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATES + state_id = Column(Integer, Identity(), primary_key=True) + entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) + state = Column(String(MAX_LENGTH_STATE_STATE)) + attributes = Column( + Text().with_variant(mysql.LONGTEXT, "mysql") + ) # no longer used for new rows + event_id = Column( # no longer used for new rows + Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True + ) + last_changed = Column(DATETIME_TYPE) + last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) + old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) + attributes_id = Column( + Integer, ForeignKey("state_attributes.attributes_id"), index=True + ) + context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) + context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) + origin_idx = Column(SmallInteger) # 0 is local, 1 is remote + old_state = relationship("States", remote_side=[state_id]) + state_attributes = relationship("StateAttributes") + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> States: + """Create object from a state_changed event.""" + entity_id = event.data["entity_id"] + state: State | None = event.data.get("new_state") + dbstate = States( + entity_id=entity_id, + attributes=None, + context_id=event.context.id, + context_user_id=event.context.user_id, + context_parent_id=event.context.parent_id, + origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), + ) + + # None state means the state was removed from the state machine + if state is None: + dbstate.state = "" + dbstate.last_updated = event.time_fired + dbstate.last_changed = None + return dbstate + + dbstate.state = state.state + dbstate.last_updated = state.last_updated + if state.last_updated == state.last_changed: + dbstate.last_changed = None + else: + dbstate.last_changed = state.last_changed + + return dbstate + + def to_native(self, validate_entity_id: bool = True) -> State | None: + """Convert to an HA state object.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id, + parent_id=self.context_parent_id, + ) + try: + attrs = json.loads(self.attributes) if self.attributes else {} + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state: %s", self) + return None + if self.last_changed is None or self.last_changed == self.last_updated: + last_changed = last_updated = process_timestamp(self.last_updated) + else: + last_updated = process_timestamp(self.last_updated) + last_changed = process_timestamp(self.last_changed) + return State( + self.entity_id, + self.state, + # Join the state_attributes table on attributes_id to get the attributes + # for newer states + attrs, + last_changed, + last_updated, + context=context, + validate_entity_id=validate_entity_id, + ) + + +class StateAttributes(Base): # type: ignore[misc,valid-type] + """State attribute change history.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATE_ATTRIBUTES + attributes_id = Column(Integer, Identity(), primary_key=True) + hash = Column(BigInteger, index=True) + # Note that this is not named attributes to avoid confusion with the states table + shared_attrs = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + @staticmethod + def from_event(event: Event) -> StateAttributes: + """Create object from a state_changed event.""" + state: State | None = event.data.get("new_state") + # None state means the state was removed from the state machine + dbstate = StateAttributes( + shared_attrs="{}" if state is None else JSON_DUMP(state.attributes) + ) + dbstate.hash = StateAttributes.hash_shared_attrs(dbstate.shared_attrs) + return dbstate + + @staticmethod + def shared_attrs_from_event( + event: Event, exclude_attrs_by_domain: dict[str, set[str]] + ) -> str: + """Create shared_attrs from a state_changed event.""" + state: State | None = event.data.get("new_state") + # None state means the state was removed from the state machine + if state is None: + return "{}" + domain = split_entity_id(state.entity_id)[0] + exclude_attrs = ( + exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS + ) + return JSON_DUMP( + {k: v for k, v in state.attributes.items() if k not in exclude_attrs} + ) + + @staticmethod + def hash_shared_attrs(shared_attrs: str) -> int: + """Return the hash of json encoded shared attributes.""" + return cast(int, fnv1a_32(shared_attrs.encode("utf-8"))) + + def to_native(self) -> dict[str, Any]: + """Convert to an HA state object.""" + try: + return cast(dict[str, Any], json.loads(self.shared_attrs)) + except ValueError: + # When json.loads fails + _LOGGER.exception("Error converting row to state attributes: %s", self) + return {} + + +class StatisticsBase: + """Statistics base class.""" + + id = Column(Integer, Identity(), primary_key=True) + created = Column(DATETIME_TYPE, default=dt_util.utcnow) + + @declared_attr # type: ignore[misc] + def metadata_id(self) -> Column: + """Define the metadata_id column for sub classes.""" + return Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) + + start = Column(DATETIME_TYPE, index=True) + mean = Column(DOUBLE_TYPE) + min = Column(DOUBLE_TYPE) + max = Column(DOUBLE_TYPE) + last_reset = Column(DATETIME_TYPE) + state = Column(DOUBLE_TYPE) + sum = Column(DOUBLE_TYPE) + + @classmethod + def from_stats(cls, metadata_id: int, stats: StatisticData) -> StatisticsBase: + """Create object from a statistics.""" + return cls( # type: ignore[call-arg,misc] + metadata_id=metadata_id, + **stats, + ) + + +class Statistics(Base, StatisticsBase): # type: ignore[misc,valid-type] + """Long term statistics.""" + + duration = timedelta(hours=1) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start", unique=True), + ) + __tablename__ = TABLE_STATISTICS + + +class StatisticsShortTerm(Base, StatisticsBase): # type: ignore[misc,valid-type] + """Short term statistics.""" + + duration = timedelta(minutes=5) + + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index( + "ix_statistics_short_term_statistic_id_start", + "metadata_id", + "start", + unique=True, + ), + ) + __tablename__ = TABLE_STATISTICS_SHORT_TERM + + +class StatisticsMeta(Base): # type: ignore[misc,valid-type] + """Statistics meta data.""" + + __table_args__ = ( + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) + __tablename__ = TABLE_STATISTICS_META + id = Column(Integer, Identity(), primary_key=True) + statistic_id = Column(String(255), index=True, unique=True) + source = Column(String(32)) + unit_of_measurement = Column(String(255)) + has_mean = Column(Boolean) + has_sum = Column(Boolean) + name = Column(String(255)) + + @staticmethod + def from_meta(meta: StatisticMetaData) -> StatisticsMeta: + """Create object from meta data.""" + return StatisticsMeta(**meta) + + +class RecorderRuns(Base): # type: ignore[misc,valid-type] + """Representation of recorder run.""" + + __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) + __tablename__ = TABLE_RECORDER_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), default=dt_util.utcnow) + end = Column(DateTime(timezone=True)) + closed_incorrect = Column(Boolean, default=False) + created = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + end = ( + f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None + ) + return ( + f"" + ) + + def entity_ids(self, point_in_time: datetime | None = None) -> list[str]: + """Return the entity ids that existed in this run. + + Specify point_in_time if you want to know which existed at that point + in time inside the run. + """ + session = Session.object_session(self) + + assert session is not None, "RecorderRuns need to be persisted" + + query = session.query(distinct(States.entity_id)).filter( + States.last_updated >= self.start + ) + + if point_in_time is not None: + query = query.filter(States.last_updated < point_in_time) + elif self.end is not None: + query = query.filter(States.last_updated < self.end) + + return [row[0] for row in query] + + def to_native(self, validate_entity_id: bool = True) -> RecorderRuns: + """Return self, native format is this model.""" + return self + + +class SchemaChanges(Base): # type: ignore[misc,valid-type] + """Representation of schema version changes.""" + + __tablename__ = TABLE_SCHEMA_CHANGES + change_id = Column(Integer, Identity(), primary_key=True) + schema_version = Column(Integer) + changed = Column(DateTime(timezone=True), default=dt_util.utcnow) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +class StatisticsRuns(Base): # type: ignore[misc,valid-type] + """Representation of statistics run.""" + + __tablename__ = TABLE_STATISTICS_RUNS + run_id = Column(Integer, Identity(), primary_key=True) + start = Column(DateTime(timezone=True), index=True) + + def __repr__(self) -> str: + """Return string representation of instance for debugging.""" + return ( + f"" + ) + + +EVENT_DATA_JSON = type_coerce( + EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) +OLD_FORMAT_EVENT_DATA_JSON = type_coerce( + Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) +) + +SHARED_ATTRS_JSON = type_coerce( + StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) +OLD_FORMAT_ATTRS_JSON = type_coerce( + States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) +) + +ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] +OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] +DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] +OLD_STATE = aliased(States, name="old_state") diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 0b3e0e68030..02c342441a7 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ from homeassistant.helpers.entityfilter import CONF_ENTITY_GLOBS from homeassistant.helpers.typing import ConfigType -from .models import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States +from .db_schema import ENTITY_ID_IN_EVENT, OLD_ENTITY_ID_IN_EVENT, States DOMAIN = "history" HISTORY_FILTERS = "history_filters" diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 37285f66d1d..e1eca282a3a 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -25,12 +25,10 @@ from homeassistant.components.websocket_api.const import ( from homeassistant.core import HomeAssistant, State, split_entity_id import homeassistant.util.dt as dt_util +from .db_schema import RecorderRuns, StateAttributes, States from .filters import Filters from .models import ( LazyState, - RecorderRuns, - StateAttributes, - States, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index cc5af684566..7e11e62502d 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -22,7 +22,7 @@ from sqlalchemy.sql.expression import true from homeassistant.core import HomeAssistant from .const import SupportedDialect -from .models import ( +from .db_schema import ( SCHEMA_VERSION, TABLE_STATES, Base, @@ -31,8 +31,8 @@ from .models import ( StatisticsMeta, StatisticsRuns, StatisticsShortTerm, - process_timestamp, ) +from .models import process_timestamp from .statistics import ( delete_statistics_duplicates, delete_statistics_meta_duplicates, diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 8db648f15a8..ef1f76df9fc 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -1,36 +1,12 @@ -"""Models for SQLAlchemy.""" +"""Models for Recorder.""" from __future__ import annotations -from collections.abc import Callable -from datetime import datetime, timedelta +from datetime import datetime import json import logging -from typing import Any, TypedDict, cast, overload +from typing import Any, TypedDict, overload -import ciso8601 -from fnvhash import fnv1a_32 -from sqlalchemy import ( - JSON, - BigInteger, - Boolean, - Column, - DateTime, - Float, - ForeignKey, - Identity, - Index, - Integer, - SmallInteger, - String, - Text, - distinct, - type_coerce, -) -from sqlalchemy.dialects import mysql, oracle, postgresql, sqlite from sqlalchemy.engine.row import Row -from sqlalchemy.ext.declarative import declared_attr -from sqlalchemy.orm import aliased, declarative_base, relationship -from sqlalchemy.orm.session import Session from homeassistant.components.websocket_api.const import ( COMPRESSED_STATE_ATTRIBUTES, @@ -38,396 +14,22 @@ from homeassistant.components.websocket_api.const import ( COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE, ) -from homeassistant.const import ( - MAX_LENGTH_EVENT_CONTEXT_ID, - MAX_LENGTH_EVENT_EVENT_TYPE, - MAX_LENGTH_EVENT_ORIGIN, - MAX_LENGTH_STATE_ENTITY_ID, - MAX_LENGTH_STATE_STATE, -) -from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.core import Context, State import homeassistant.util.dt as dt_util -from .const import ALL_DOMAIN_EXCLUDE_ATTRS, JSON_DUMP - -# SQLAlchemy Schema # pylint: disable=invalid-name -Base = declarative_base() - -SCHEMA_VERSION = 29 _LOGGER = logging.getLogger(__name__) DB_TIMEZONE = "+00:00" -TABLE_EVENTS = "events" -TABLE_EVENT_DATA = "event_data" -TABLE_STATES = "states" -TABLE_STATE_ATTRIBUTES = "state_attributes" -TABLE_RECORDER_RUNS = "recorder_runs" -TABLE_SCHEMA_CHANGES = "schema_changes" -TABLE_STATISTICS = "statistics" -TABLE_STATISTICS_META = "statistics_meta" -TABLE_STATISTICS_RUNS = "statistics_runs" -TABLE_STATISTICS_SHORT_TERM = "statistics_short_term" - -ALL_TABLES = [ - TABLE_STATES, - TABLE_STATE_ATTRIBUTES, - TABLE_EVENTS, - TABLE_EVENT_DATA, - TABLE_RECORDER_RUNS, - TABLE_SCHEMA_CHANGES, - TABLE_STATISTICS, - TABLE_STATISTICS_META, - TABLE_STATISTICS_RUNS, - TABLE_STATISTICS_SHORT_TERM, -] - -TABLES_TO_CHECK = [ - TABLE_STATES, - TABLE_EVENTS, - TABLE_RECORDER_RUNS, - TABLE_SCHEMA_CHANGES, -] - -LAST_UPDATED_INDEX = "ix_states_last_updated" -ENTITY_ID_LAST_UPDATED_INDEX = "ix_states_entity_id_last_updated" -EVENTS_CONTEXT_ID_INDEX = "ix_events_context_id" -STATES_CONTEXT_ID_INDEX = "ix_states_context_id" - EMPTY_JSON_OBJECT = "{}" -class FAST_PYSQLITE_DATETIME(sqlite.DATETIME): # type: ignore[misc] - """Use ciso8601 to parse datetimes instead of sqlalchemy built-in regex.""" - - def result_processor(self, dialect, coltype): # type: ignore[no-untyped-def] - """Offload the datetime parsing to ciso8601.""" - return lambda value: None if value is None else ciso8601.parse_datetime(value) - - -JSON_VARIENT_CAST = Text().with_variant( - postgresql.JSON(none_as_null=True), "postgresql" -) -JSONB_VARIENT_CAST = Text().with_variant( - postgresql.JSONB(none_as_null=True), "postgresql" -) -DATETIME_TYPE = ( - DateTime(timezone=True) - .with_variant(mysql.DATETIME(timezone=True, fsp=6), "mysql") - .with_variant(FAST_PYSQLITE_DATETIME(), "sqlite") -) -DOUBLE_TYPE = ( - Float() - .with_variant(mysql.DOUBLE(asdecimal=False), "mysql") - .with_variant(oracle.DOUBLE_PRECISION(), "oracle") - .with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") -) - - -class JSONLiteral(JSON): # type: ignore[misc] - """Teach SA how to literalize json.""" - - def literal_processor(self, dialect: str) -> Callable[[Any], str]: - """Processor to convert a value to JSON.""" - - def process(value: Any) -> str: - """Dump json.""" - return json.dumps(value) - - return process - - -EVENT_ORIGIN_ORDER = [EventOrigin.local, EventOrigin.remote] -EVENT_ORIGIN_TO_IDX = {origin: idx for idx, origin in enumerate(EVENT_ORIGIN_ORDER)} - - class UnsupportedDialect(Exception): """The dialect or its version is not supported.""" -class Events(Base): # type: ignore[misc,valid-type] - """Event history data.""" - - __table_args__ = ( - # Used for fetching events at a specific time - # see logbook - Index("ix_events_event_type_time_fired", "event_type", "time_fired"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_EVENTS - event_id = Column(Integer, Identity(), primary_key=True) - event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) - event_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) - origin = Column(String(MAX_LENGTH_EVENT_ORIGIN)) # no longer used for new rows - origin_idx = Column(SmallInteger) - time_fired = Column(DATETIME_TYPE, index=True) - context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) - context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - data_id = Column(Integer, ForeignKey("event_data.data_id"), index=True) - event_data_rel = relationship("EventData") - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> Events: - """Create an event database object from a native event.""" - return Events( - event_type=event.event_type, - event_data=None, - origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), - time_fired=event.time_fired, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, - ) - - def to_native(self, validate_entity_id: bool = True) -> Event | None: - """Convert to a native HA Event.""" - context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, - ) - try: - return Event( - self.event_type, - json.loads(self.event_data) if self.event_data else {}, - EventOrigin(self.origin) - if self.origin - else EVENT_ORIGIN_ORDER[self.origin_idx], - process_timestamp(self.time_fired), - context=context, - ) - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting to event: %s", self) - return None - - -class EventData(Base): # type: ignore[misc,valid-type] - """Event data history.""" - - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_EVENT_DATA - data_id = Column(Integer, Identity(), primary_key=True) - hash = Column(BigInteger, index=True) - # Note that this is not named attributes to avoid confusion with the states table - shared_data = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> EventData: - """Create object from an event.""" - shared_data = JSON_DUMP(event.data) - return EventData( - shared_data=shared_data, hash=EventData.hash_shared_data(shared_data) - ) - - @staticmethod - def shared_data_from_event(event: Event) -> str: - """Create shared_attrs from an event.""" - return JSON_DUMP(event.data) - - @staticmethod - def hash_shared_data(shared_data: str) -> int: - """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data.encode("utf-8"))) - - def to_native(self) -> dict[str, Any]: - """Convert to an HA state object.""" - try: - return cast(dict[str, Any], json.loads(self.shared_data)) - except ValueError: - _LOGGER.exception("Error converting row to event data: %s", self) - return {} - - -class States(Base): # type: ignore[misc,valid-type] - """State change history.""" - - __table_args__ = ( - # Used for fetching the state of entities at a specific time - # (get_states in history.py) - Index(ENTITY_ID_LAST_UPDATED_INDEX, "entity_id", "last_updated"), - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_STATES - state_id = Column(Integer, Identity(), primary_key=True) - entity_id = Column(String(MAX_LENGTH_STATE_ENTITY_ID)) - state = Column(String(MAX_LENGTH_STATE_STATE)) - attributes = Column( - Text().with_variant(mysql.LONGTEXT, "mysql") - ) # no longer used for new rows - event_id = Column( # no longer used for new rows - Integer, ForeignKey("events.event_id", ondelete="CASCADE"), index=True - ) - last_changed = Column(DATETIME_TYPE) - last_updated = Column(DATETIME_TYPE, default=dt_util.utcnow, index=True) - old_state_id = Column(Integer, ForeignKey("states.state_id"), index=True) - attributes_id = Column( - Integer, ForeignKey("state_attributes.attributes_id"), index=True - ) - context_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) - context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID)) - origin_idx = Column(SmallInteger) # 0 is local, 1 is remote - old_state = relationship("States", remote_side=[state_id]) - state_attributes = relationship("StateAttributes") - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> States: - """Create object from a state_changed event.""" - entity_id = event.data["entity_id"] - state: State | None = event.data.get("new_state") - dbstate = States( - entity_id=entity_id, - attributes=None, - context_id=event.context.id, - context_user_id=event.context.user_id, - context_parent_id=event.context.parent_id, - origin_idx=EVENT_ORIGIN_TO_IDX.get(event.origin), - ) - - # None state means the state was removed from the state machine - if state is None: - dbstate.state = "" - dbstate.last_updated = event.time_fired - dbstate.last_changed = None - return dbstate - - dbstate.state = state.state - dbstate.last_updated = state.last_updated - if state.last_updated == state.last_changed: - dbstate.last_changed = None - else: - dbstate.last_changed = state.last_changed - - return dbstate - - def to_native(self, validate_entity_id: bool = True) -> State | None: - """Convert to an HA state object.""" - context = Context( - id=self.context_id, - user_id=self.context_user_id, - parent_id=self.context_parent_id, - ) - try: - attrs = json.loads(self.attributes) if self.attributes else {} - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting row to state: %s", self) - return None - if self.last_changed is None or self.last_changed == self.last_updated: - last_changed = last_updated = process_timestamp(self.last_updated) - else: - last_updated = process_timestamp(self.last_updated) - last_changed = process_timestamp(self.last_changed) - return State( - self.entity_id, - self.state, - # Join the state_attributes table on attributes_id to get the attributes - # for newer states - attrs, - last_changed, - last_updated, - context=context, - validate_entity_id=validate_entity_id, - ) - - -class StateAttributes(Base): # type: ignore[misc,valid-type] - """State attribute change history.""" - - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_STATE_ATTRIBUTES - attributes_id = Column(Integer, Identity(), primary_key=True) - hash = Column(BigInteger, index=True) - # Note that this is not named attributes to avoid confusion with the states table - shared_attrs = Column(Text().with_variant(mysql.LONGTEXT, "mysql")) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - @staticmethod - def from_event(event: Event) -> StateAttributes: - """Create object from a state_changed event.""" - state: State | None = event.data.get("new_state") - # None state means the state was removed from the state machine - dbstate = StateAttributes( - shared_attrs="{}" if state is None else JSON_DUMP(state.attributes) - ) - dbstate.hash = StateAttributes.hash_shared_attrs(dbstate.shared_attrs) - return dbstate - - @staticmethod - def shared_attrs_from_event( - event: Event, exclude_attrs_by_domain: dict[str, set[str]] - ) -> str: - """Create shared_attrs from a state_changed event.""" - state: State | None = event.data.get("new_state") - # None state means the state was removed from the state machine - if state is None: - return "{}" - domain = split_entity_id(state.entity_id)[0] - exclude_attrs = ( - exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS - ) - return JSON_DUMP( - {k: v for k, v in state.attributes.items() if k not in exclude_attrs} - ) - - @staticmethod - def hash_shared_attrs(shared_attrs: str) -> int: - """Return the hash of json encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs.encode("utf-8"))) - - def to_native(self) -> dict[str, Any]: - """Convert to an HA state object.""" - try: - return cast(dict[str, Any], json.loads(self.shared_attrs)) - except ValueError: - # When json.loads fails - _LOGGER.exception("Error converting row to state attributes: %s", self) - return {} - - class StatisticResult(TypedDict): """Statistic result data class. @@ -455,67 +57,6 @@ class StatisticData(StatisticDataBase, total=False): sum: float -class StatisticsBase: - """Statistics base class.""" - - id = Column(Integer, Identity(), primary_key=True) - created = Column(DATETIME_TYPE, default=dt_util.utcnow) - - @declared_attr # type: ignore[misc] - def metadata_id(self) -> Column: - """Define the metadata_id column for sub classes.""" - return Column( - Integer, - ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), - index=True, - ) - - start = Column(DATETIME_TYPE, index=True) - mean = Column(DOUBLE_TYPE) - min = Column(DOUBLE_TYPE) - max = Column(DOUBLE_TYPE) - last_reset = Column(DATETIME_TYPE) - state = Column(DOUBLE_TYPE) - sum = Column(DOUBLE_TYPE) - - @classmethod - def from_stats(cls, metadata_id: int, stats: StatisticData) -> StatisticsBase: - """Create object from a statistics.""" - return cls( # type: ignore[call-arg,misc] - metadata_id=metadata_id, - **stats, - ) - - -class Statistics(Base, StatisticsBase): # type: ignore[misc,valid-type] - """Long term statistics.""" - - duration = timedelta(hours=1) - - __table_args__ = ( - # Used for fetching statistics for a certain entity at a specific time - Index("ix_statistics_statistic_id_start", "metadata_id", "start", unique=True), - ) - __tablename__ = TABLE_STATISTICS - - -class StatisticsShortTerm(Base, StatisticsBase): # type: ignore[misc,valid-type] - """Short term statistics.""" - - duration = timedelta(minutes=5) - - __table_args__ = ( - # Used for fetching statistics for a certain entity at a specific time - Index( - "ix_statistics_short_term_statistic_id_start", - "metadata_id", - "start", - unique=True, - ), - ) - __tablename__ = TABLE_STATISTICS_SHORT_TERM - - class StatisticMetaData(TypedDict): """Statistic meta data class.""" @@ -527,131 +68,6 @@ class StatisticMetaData(TypedDict): unit_of_measurement: str | None -class StatisticsMeta(Base): # type: ignore[misc,valid-type] - """Statistics meta data.""" - - __table_args__ = ( - {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, - ) - __tablename__ = TABLE_STATISTICS_META - id = Column(Integer, Identity(), primary_key=True) - statistic_id = Column(String(255), index=True, unique=True) - source = Column(String(32)) - unit_of_measurement = Column(String(255)) - has_mean = Column(Boolean) - has_sum = Column(Boolean) - name = Column(String(255)) - - @staticmethod - def from_meta(meta: StatisticMetaData) -> StatisticsMeta: - """Create object from meta data.""" - return StatisticsMeta(**meta) - - -class RecorderRuns(Base): # type: ignore[misc,valid-type] - """Representation of recorder run.""" - - __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) - __tablename__ = TABLE_RECORDER_RUNS - run_id = Column(Integer, Identity(), primary_key=True) - start = Column(DateTime(timezone=True), default=dt_util.utcnow) - end = Column(DateTime(timezone=True)) - closed_incorrect = Column(Boolean, default=False) - created = Column(DateTime(timezone=True), default=dt_util.utcnow) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - end = ( - f"'{self.end.isoformat(sep=' ', timespec='seconds')}'" if self.end else None - ) - return ( - f"" - ) - - def entity_ids(self, point_in_time: datetime | None = None) -> list[str]: - """Return the entity ids that existed in this run. - - Specify point_in_time if you want to know which existed at that point - in time inside the run. - """ - session = Session.object_session(self) - - assert session is not None, "RecorderRuns need to be persisted" - - query = session.query(distinct(States.entity_id)).filter( - States.last_updated >= self.start - ) - - if point_in_time is not None: - query = query.filter(States.last_updated < point_in_time) - elif self.end is not None: - query = query.filter(States.last_updated < self.end) - - return [row[0] for row in query] - - def to_native(self, validate_entity_id: bool = True) -> RecorderRuns: - """Return self, native format is this model.""" - return self - - -class SchemaChanges(Base): # type: ignore[misc,valid-type] - """Representation of schema version changes.""" - - __tablename__ = TABLE_SCHEMA_CHANGES - change_id = Column(Integer, Identity(), primary_key=True) - schema_version = Column(Integer) - changed = Column(DateTime(timezone=True), default=dt_util.utcnow) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - -class StatisticsRuns(Base): # type: ignore[misc,valid-type] - """Representation of statistics run.""" - - __tablename__ = TABLE_STATISTICS_RUNS - run_id = Column(Integer, Identity(), primary_key=True) - start = Column(DateTime(timezone=True), index=True) - - def __repr__(self) -> str: - """Return string representation of instance for debugging.""" - return ( - f"" - ) - - -EVENT_DATA_JSON = type_coerce( - EventData.shared_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) -OLD_FORMAT_EVENT_DATA_JSON = type_coerce( - Events.event_data.cast(JSONB_VARIENT_CAST), JSONLiteral(none_as_null=True) -) - -SHARED_ATTRS_JSON = type_coerce( - StateAttributes.shared_attrs.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) -OLD_FORMAT_ATTRS_JSON = type_coerce( - States.attributes.cast(JSON_VARIENT_CAST), JSON(none_as_null=True) -) - -ENTITY_ID_IN_EVENT: Column = EVENT_DATA_JSON["entity_id"] -OLD_ENTITY_ID_IN_EVENT: Column = OLD_FORMAT_EVENT_DATA_JSON["entity_id"] -DEVICE_ID_IN_EVENT: Column = EVENT_DATA_JSON["device_id"] -OLD_STATE = aliased(States, name="old_state") - - @overload def process_timestamp(ts: None) -> None: ... diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 10136dfb5a6..c470575c5f1 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -14,7 +14,7 @@ from sqlalchemy.sql.expression import distinct from homeassistant.const import EVENT_STATE_CHANGED from .const import MAX_ROWS_TO_PURGE, SupportedDialect -from .models import Events, StateAttributes, States +from .db_schema import Events, StateAttributes, States from .queries import ( attributes_ids_exist_in_states, attributes_ids_exist_in_states_sqlite, diff --git a/homeassistant/components/recorder/queries.py b/homeassistant/components/recorder/queries.py index e27d3d692cc..4b4488d4dad 100644 --- a/homeassistant/components/recorder/queries.py +++ b/homeassistant/components/recorder/queries.py @@ -9,7 +9,7 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from sqlalchemy.sql.selectable import Select from .const import MAX_ROWS_TO_PURGE -from .models import ( +from .db_schema import ( EventData, Events, RecorderRuns, diff --git a/homeassistant/components/recorder/repack.py b/homeassistant/components/recorder/repack.py index 1b1d59df37e..53c922cf481 100644 --- a/homeassistant/components/recorder/repack.py +++ b/homeassistant/components/recorder/repack.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from sqlalchemy import text from .const import SupportedDialect -from .models import ALL_TABLES +from .db_schema import ALL_TABLES if TYPE_CHECKING: from . import Recorder diff --git a/homeassistant/components/recorder/run_history.py b/homeassistant/components/recorder/run_history.py index 783aff89c17..fb87d9a1fa2 100644 --- a/homeassistant/components/recorder/run_history.py +++ b/homeassistant/components/recorder/run_history.py @@ -9,7 +9,8 @@ from sqlalchemy.orm.session import Session import homeassistant.util.dt as dt_util -from .models import RecorderRuns, process_timestamp +from .db_schema import RecorderRuns +from .models import process_timestamp def _find_recorder_run_for_start_time( diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 012b34ec0ef..26221aa199b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -42,14 +42,11 @@ from homeassistant.util.unit_system import UnitSystem import homeassistant.util.volume as volume_util from .const import DATA_INSTANCE, DOMAIN, MAX_ROWS_TO_PURGE, SupportedDialect +from .db_schema import Statistics, StatisticsMeta, StatisticsRuns, StatisticsShortTerm from .models import ( StatisticData, StatisticMetaData, StatisticResult, - Statistics, - StatisticsMeta, - StatisticsRuns, - StatisticsShortTerm, process_timestamp, process_timestamp_to_utc_isoformat, ) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 843f0e4b185..c1fbc831987 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -29,14 +29,13 @@ from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from .const import DATA_INSTANCE, SQLITE_URL_PREFIX, SupportedDialect -from .models import ( +from .db_schema import ( TABLE_RECORDER_RUNS, TABLE_SCHEMA_CHANGES, TABLES_TO_CHECK, RecorderRuns, - UnsupportedDialect, - process_timestamp, ) +from .models import UnsupportedDialect, process_timestamp if TYPE_CHECKING: from . import Recorder diff --git a/tests/components/automation/test_recorder.py b/tests/components/automation/test_recorder.py index bfb02c0daba..4067393b76c 100644 --- a/tests/components/automation/test_recorder.py +++ b/tests/components/automation/test_recorder.py @@ -11,7 +11,7 @@ from homeassistant.components.automation import ( ATTR_MODE, CONF_ID, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/camera/test_recorder.py b/tests/components/camera/test_recorder.py index 0dc161fb0c0..1217997a996 100644 --- a/tests/components/camera/test_recorder.py +++ b/tests/components/camera/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import camera -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ( ATTR_ATTRIBUTION, diff --git a/tests/components/climate/test_recorder.py b/tests/components/climate/test_recorder.py index 427645fb871..7ed604495dc 100644 --- a/tests/components/climate/test_recorder.py +++ b/tests/components/climate/test_recorder.py @@ -15,7 +15,7 @@ from homeassistant.components.climate.const import ( ATTR_SWING_MODES, ATTR_TARGET_TEMP_STEP, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/fan/test_recorder.py b/tests/components/fan/test_recorder.py index aa5bae45f4c..604f5e3a2e9 100644 --- a/tests/components/fan/test_recorder.py +++ b/tests/components/fan/test_recorder.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import fan from homeassistant.components.fan import ATTR_PRESET_MODES -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/group/test_recorder.py b/tests/components/group/test_recorder.py index fb68d9d3d43..7a4a41839ef 100644 --- a/tests/components/group/test_recorder.py +++ b/tests/components/group/test_recorder.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import group from homeassistant.components.group import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_ON from homeassistant.core import State diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index ce694fc221b..28859e6133f 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -9,7 +9,7 @@ from homeassistant.components.humidifier import ( ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/input_boolean/test_recorder.py b/tests/components/input_boolean/test_recorder.py index c01c2532953..e7f68379343 100644 --- a/tests/components/input_boolean/test_recorder.py +++ b/tests/components/input_boolean/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_boolean import DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_button/test_recorder.py b/tests/components/input_button/test_recorder.py index eb5bcc05cf3..e469536549a 100644 --- a/tests/components/input_button/test_recorder.py +++ b/tests/components/input_button/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_button import DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_datetime/test_recorder.py b/tests/components/input_datetime/test_recorder.py index e8da8939ea9..bbdd0446e56 100644 --- a/tests/components/input_datetime/test_recorder.py +++ b/tests/components/input_datetime/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_datetime import CONF_HAS_DATE, CONF_HAS_TIME, DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_number/test_recorder.py b/tests/components/input_number/test_recorder.py index 9db2e2cd9c8..f736d450e7a 100644 --- a/tests/components/input_number/test_recorder.py +++ b/tests/components/input_number/test_recorder.py @@ -10,7 +10,7 @@ from homeassistant.components.input_number import ( ATTR_STEP, DOMAIN, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_select/test_recorder.py b/tests/components/input_select/test_recorder.py index 3a5ae4e385f..2931132bafc 100644 --- a/tests/components/input_select/test_recorder.py +++ b/tests/components/input_select/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components.input_select import ATTR_OPTIONS, DOMAIN -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/input_text/test_recorder.py b/tests/components/input_text/test_recorder.py index f613bbcebe1..928399cd939 100644 --- a/tests/components/input_text/test_recorder.py +++ b/tests/components/input_text/test_recorder.py @@ -11,7 +11,7 @@ from homeassistant.components.input_text import ( DOMAIN, MODE_TEXT, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_EDITABLE from homeassistant.core import HomeAssistant, State diff --git a/tests/components/light/test_recorder.py b/tests/components/light/test_recorder.py index 7e004891bb8..b6d26306317 100644 --- a/tests/components/light/test_recorder.py +++ b/tests/components/light/test_recorder.py @@ -10,7 +10,7 @@ from homeassistant.components.light import ( ATTR_MIN_MIREDS, ATTR_SUPPORTED_COLOR_MODES, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/media_player/test_recorder.py b/tests/components/media_player/test_recorder.py index 7f6b15768f2..1d053a23cee 100644 --- a/tests/components/media_player/test_recorder.py +++ b/tests/components/media_player/test_recorder.py @@ -11,7 +11,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_SOUND_MODE_LIST, ) -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/number/test_recorder.py b/tests/components/number/test_recorder.py index 1f5d39ed5e9..f51d3933b5d 100644 --- a/tests/components/number/test_recorder.py +++ b/tests/components/number/test_recorder.py @@ -5,7 +5,7 @@ from datetime import timedelta from homeassistant.components import number from homeassistant.components.number import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_FRIENDLY_NAME from homeassistant.core import State diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index 39cde4c2e7c..20df89eca5b 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -14,13 +14,13 @@ from homeassistant import core as ha from homeassistant.components import recorder from homeassistant.components.recorder import get_instance, statistics from homeassistant.components.recorder.core import Recorder -from homeassistant.components.recorder.models import RecorderRuns +from homeassistant.components.recorder.db_schema import RecorderRuns from homeassistant.components.recorder.tasks import RecorderTask, StatisticsTask from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, fire_time_changed -from tests.components.recorder import models_schema_0 +from tests.components.recorder import db_schema_0 DEFAULT_PURGE_TASKS = 3 @@ -122,7 +122,7 @@ def create_engine_test(*args, **kwargs): This simulates an existing db with the old schema. """ engine = create_engine(*args, **kwargs) - models_schema_0.Base.metadata.create_all(engine) + db_schema_0.Base.metadata.create_all(engine) return engine diff --git a/tests/components/recorder/models_schema_0.py b/tests/components/recorder/db_schema_0.py similarity index 100% rename from tests/components/recorder/models_schema_0.py rename to tests/components/recorder/db_schema_0.py diff --git a/tests/components/recorder/models_schema_16.py b/tests/components/recorder/db_schema_16.py similarity index 100% rename from tests/components/recorder/models_schema_16.py rename to tests/components/recorder/db_schema_16.py diff --git a/tests/components/recorder/models_schema_18.py b/tests/components/recorder/db_schema_18.py similarity index 100% rename from tests/components/recorder/models_schema_18.py rename to tests/components/recorder/db_schema_18.py diff --git a/tests/components/recorder/models_schema_22.py b/tests/components/recorder/db_schema_22.py similarity index 100% rename from tests/components/recorder/models_schema_22.py rename to tests/components/recorder/db_schema_22.py diff --git a/tests/components/recorder/models_schema_23.py b/tests/components/recorder/db_schema_23.py similarity index 100% rename from tests/components/recorder/models_schema_23.py rename to tests/components/recorder/db_schema_23.py diff --git a/tests/components/recorder/models_schema_23_with_newer_columns.py b/tests/components/recorder/db_schema_23_with_newer_columns.py similarity index 100% rename from tests/components/recorder/models_schema_23_with_newer_columns.py rename to tests/components/recorder/db_schema_23_with_newer_columns.py diff --git a/tests/components/recorder/models_schema_28.py b/tests/components/recorder/db_schema_28.py similarity index 100% rename from tests/components/recorder/models_schema_28.py rename to tests/components/recorder/db_schema_28.py diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index 0758d6fdc95..ed4d4efe066 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -5,12 +5,12 @@ from sqlalchemy import select from sqlalchemy.engine.row import Row from homeassistant.components.recorder import get_instance +from homeassistant.components.recorder.db_schema import EventData, States from homeassistant.components.recorder.filters import ( Filters, extract_include_exclude_filter_conf, sqlalchemy_filter_from_include_exclude_conf, ) -from homeassistant.components.recorder.models import EventData, States from homeassistant.components.recorder.util import session_scope from homeassistant.const import ATTR_ENTITY_ID, STATE_ON from homeassistant.core import HomeAssistant diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index ee02ffbec49..cc1d8e7faa7 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -12,14 +12,13 @@ from sqlalchemy import text from homeassistant.components import recorder from homeassistant.components.recorder import history -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( Events, - LazyState, RecorderRuns, StateAttributes, States, - process_timestamp, ) +from homeassistant.components.recorder.models import LazyState, process_timestamp from homeassistant.components.recorder.util import session_scope import homeassistant.core as ha from homeassistant.core import HomeAssistant, State diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index 87dbce3ba3b..3e25a54e39d 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -25,7 +25,7 @@ from homeassistant.components.recorder import ( get_instance, ) from homeassistant.components.recorder.const import DATA_INSTANCE, KEEPALIVE_TIME -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, EventData, Events, @@ -33,8 +33,8 @@ from homeassistant.components.recorder.models import ( StateAttributes, States, StatisticsRuns, - process_timestamp, ) +from homeassistant.components.recorder.models import process_timestamp from homeassistant.components.recorder.services import ( SERVICE_DISABLE, SERVICE_ENABLE, diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index fcc35938088..38d6a191809 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -20,9 +20,9 @@ from sqlalchemy.pool import StaticPool from homeassistant.bootstrap import async_setup_component from homeassistant.components import persistent_notification as pn, recorder -from homeassistant.components.recorder import migration, models +from homeassistant.components.recorder import db_schema, migration from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( SCHEMA_VERSION, RecorderRuns, States, @@ -66,7 +66,7 @@ async def test_schema_update_calls(hass): update.assert_has_calls( [ call(hass, engine, session_maker, version + 1, 0) - for version in range(0, models.SCHEMA_VERSION) + for version in range(0, db_schema.SCHEMA_VERSION) ] ) @@ -267,14 +267,16 @@ async def test_schema_migrate(hass, start_version): This simulates an existing db with the old schema. """ - module = f"tests.components.recorder.models_schema_{str(start_version)}" + module = f"tests.components.recorder.db_schema_{str(start_version)}" importlib.import_module(module) old_models = sys.modules[module] engine = create_engine(*args, **kwargs) old_models.Base.metadata.create_all(engine) if start_version > 0: with Session(engine) as session: - session.add(recorder.models.SchemaChanges(schema_version=start_version)) + session.add( + recorder.db_schema.SchemaChanges(schema_version=start_version) + ) session.commit() return engine @@ -299,8 +301,8 @@ async def test_schema_migrate(hass, start_version): # the recorder will silently create a new database. with session_scope(hass=hass) as session: res = ( - session.query(models.SchemaChanges) - .order_by(models.SchemaChanges.change_id.desc()) + session.query(db_schema.SchemaChanges) + .order_by(db_schema.SchemaChanges.change_id.desc()) .first() ) migration_version = res.schema_version @@ -325,7 +327,7 @@ async def test_schema_migrate(hass, start_version): await hass.async_block_till_done() await hass.async_add_executor_job(migration_done.wait) await async_wait_recording_done(hass) - assert migration_version == models.SCHEMA_VERSION + assert migration_version == db_schema.SCHEMA_VERSION assert setup_run.called assert recorder.util.async_migration_in_progress(hass) is not True @@ -381,7 +383,7 @@ def test_forgiving_add_column(): def test_forgiving_add_index(): """Test that add index will continue if index exists.""" engine = create_engine("sqlite://", poolclass=StaticPool) - models.Base.metadata.create_all(engine) + db_schema.Base.metadata.create_all(engine) with Session(engine) as session: instance = Mock() instance.get_session = Mock(return_value=session) diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index 9d07c33a17a..81469ab1dab 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -7,14 +7,16 @@ import pytest from sqlalchemy import create_engine from sqlalchemy.orm import scoped_session, sessionmaker -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( Base, EventData, Events, - LazyState, RecorderRuns, StateAttributes, States, +) +from homeassistant.components.recorder.models import ( + LazyState, process_datetime_to_timestamp, process_timestamp, process_timestamp_to_utc_isoformat, diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index f4e998c5388..c6c447c01c9 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -10,7 +10,7 @@ from sqlalchemy.orm.session import Session from homeassistant.components import recorder from homeassistant.components.recorder.const import MAX_ROWS_TO_PURGE, SupportedDialect -from homeassistant.components.recorder.models import ( +from homeassistant.components.recorder.db_schema import ( EventData, Events, RecorderRuns, diff --git a/tests/components/recorder/test_run_history.py b/tests/components/recorder/test_run_history.py index 80797c666ec..ff4a5e5d701 100644 --- a/tests/components/recorder/test_run_history.py +++ b/tests/components/recorder/test_run_history.py @@ -3,7 +3,8 @@ from datetime import timedelta from homeassistant.components import recorder -from homeassistant.components.recorder.models import RecorderRuns, process_timestamp +from homeassistant.components.recorder.db_schema import RecorderRuns +from homeassistant.components.recorder.models import process_timestamp from homeassistant.util import dt as dt_util diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 97e64716f49..48639790d0d 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -13,10 +13,8 @@ from sqlalchemy.orm import Session from homeassistant.components import recorder from homeassistant.components.recorder import history, statistics from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX -from homeassistant.components.recorder.models import ( - StatisticsShortTerm, - process_timestamp_to_utc_isoformat, -) +from homeassistant.components.recorder.db_schema import StatisticsShortTerm +from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.recorder.statistics import ( async_add_external_statistics, delete_statistics_duplicates, @@ -390,7 +388,7 @@ def test_rename_entity_collision(hass_recorder, caplog): } with session_scope(hass=hass) as session: - session.add(recorder.models.StatisticsMeta.from_meta(metadata_1)) + session.add(recorder.db_schema.StatisticsMeta.from_meta(metadata_1)) # Rename entity sensor.test1 to sensor.test99 @callback @@ -941,7 +939,7 @@ def test_duplicate_statistics_handle_integrity_error(hass_recorder, caplog): assert insert_statistics_mock.call_count == 3 with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.Statistics).all() + tmp = session.query(recorder.db_schema.Statistics).all() assert len(tmp) == 2 assert "Blocked attempt to insert duplicated statistic rows" in caplog.text @@ -952,15 +950,19 @@ def _create_engine_28(*args, **kwargs): This simulates an existing db with the old schema. """ - module = "tests.components.recorder.models_schema_28" + module = "tests.components.recorder.db_schema_28" importlib.import_module(module) - old_models = sys.modules[module] + old_db_schema = sys.modules[module] engine = create_engine(*args, **kwargs) - old_models.Base.metadata.create_all(engine) + old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: - session.add(recorder.models.StatisticsRuns(start=statistics.get_start_time())) session.add( - recorder.models.SchemaChanges(schema_version=old_models.SCHEMA_VERSION) + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) ) session.commit() return engine @@ -971,9 +973,9 @@ def test_delete_metadata_duplicates(caplog, tmpdir): test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - module = "tests.components.recorder.models_schema_28" + module = "tests.components.recorder.db_schema_28" importlib.import_module(module) - old_models = sys.modules[module] + old_db_schema = sys.modules[module] external_energy_metadata_1 = { "has_mean": False, @@ -1001,8 +1003,8 @@ def test_delete_metadata_duplicates(caplog, tmpdir): } # Create some duplicated statistics_meta with schema version 28 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch( "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 ): @@ -1013,15 +1015,17 @@ def test_delete_metadata_duplicates(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.StatisticsMeta).all() + tmp = session.query(recorder.db_schema.StatisticsMeta).all() assert len(tmp) == 3 assert tmp[0].id == 1 assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" @@ -1042,7 +1046,7 @@ def test_delete_metadata_duplicates(caplog, tmpdir): assert "Deleted 1 duplicated statistics_meta rows" in caplog.text with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.StatisticsMeta).all() + tmp = session.query(recorder.db_schema.StatisticsMeta).all() assert len(tmp) == 2 assert tmp[0].id == 2 assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" @@ -1058,9 +1062,9 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): test_db_file = tmpdir.mkdir("sqlite").join("test_run_info.db") dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" - module = "tests.components.recorder.models_schema_28" + module = "tests.components.recorder.db_schema_28" importlib.import_module(module) - old_models = sys.modules[module] + old_db_schema = sys.modules[module] external_energy_metadata_1 = { "has_mean": False, @@ -1088,8 +1092,8 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): } # Create some duplicated statistics with schema version 28 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch( "homeassistant.components.recorder.core.create_engine", new=_create_engine_28 ): @@ -1100,20 +1104,26 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) for _ in range(3000): session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta( + external_energy_metadata_1 + ) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -1127,7 +1137,7 @@ def test_delete_metadata_duplicates_many(caplog, tmpdir): assert "Deleted 3002 duplicated statistics_meta rows" in caplog.text with session_scope(hass=hass) as session: - tmp = session.query(recorder.models.StatisticsMeta).all() + tmp = session.query(recorder.db_schema.StatisticsMeta).all() assert len(tmp) == 3 assert tmp[0].id == 3001 assert tmp[0].statistic_id == "test:total_energy_import_tariff_1" diff --git a/tests/components/recorder/test_statistics_v23_migration.py b/tests/components/recorder/test_statistics_v23_migration.py index d487743a87f..50311a987d6 100644 --- a/tests/components/recorder/test_statistics_v23_migration.py +++ b/tests/components/recorder/test_statistics_v23_migration.py @@ -25,7 +25,7 @@ from tests.components.recorder.common import wait_recording_done ORIG_TZ = dt_util.DEFAULT_TIME_ZONE CREATE_ENGINE_TARGET = "homeassistant.components.recorder.core.create_engine" -SCHEMA_MODULE = "tests.components.recorder.models_schema_23_with_newer_columns" +SCHEMA_MODULE = "tests.components.recorder.db_schema_23_with_newer_columns" def _create_engine_test(*args, **kwargs): @@ -34,13 +34,17 @@ def _create_engine_test(*args, **kwargs): This simulates an existing db with the old schema. """ importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] engine = create_engine(*args, **kwargs) - old_models.Base.metadata.create_all(engine) + old_db_schema.Base.metadata.create_all(engine) with Session(engine) as session: - session.add(recorder.models.StatisticsRuns(start=statistics.get_start_time())) session.add( - recorder.models.SchemaChanges(schema_version=old_models.SCHEMA_VERSION) + recorder.db_schema.StatisticsRuns(start=statistics.get_start_time()) + ) + session.add( + recorder.db_schema.SchemaChanges( + schema_version=old_db_schema.SCHEMA_VERSION + ) ) session.commit() return engine @@ -52,7 +56,7 @@ def test_delete_duplicates(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) @@ -171,8 +175,8 @@ def test_delete_duplicates(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -181,19 +185,21 @@ def test_delete_duplicates(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) with session_scope(hass=hass) as session: for stat in external_energy_statistics_1: - session.add(recorder.models.Statistics.from_stats(1, stat)) + session.add(recorder.db_schema.Statistics.from_stats(1, stat)) for stat in external_energy_statistics_2: - session.add(recorder.models.Statistics.from_stats(2, stat)) + session.add(recorder.db_schema.Statistics.from_stats(2, stat)) for stat in external_co2_statistics: - session.add(recorder.models.Statistics.from_stats(3, stat)) + session.add(recorder.db_schema.Statistics.from_stats(3, stat)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -218,7 +224,7 @@ def test_delete_duplicates_many(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) @@ -337,8 +343,8 @@ def test_delete_duplicates_many(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -347,25 +353,27 @@ def test_delete_duplicates_many(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) + ) + session.add( + recorder.db_schema.StatisticsMeta.from_meta(external_co2_metadata) ) - session.add(recorder.models.StatisticsMeta.from_meta(external_co2_metadata)) with session_scope(hass=hass) as session: for stat in external_energy_statistics_1: - session.add(recorder.models.Statistics.from_stats(1, stat)) + session.add(recorder.db_schema.Statistics.from_stats(1, stat)) for _ in range(3000): session.add( - recorder.models.Statistics.from_stats( + recorder.db_schema.Statistics.from_stats( 1, external_energy_statistics_1[-1] ) ) for stat in external_energy_statistics_2: - session.add(recorder.models.Statistics.from_stats(2, stat)) + session.add(recorder.db_schema.Statistics.from_stats(2, stat)) for stat in external_co2_statistics: - session.add(recorder.models.Statistics.from_stats(3, stat)) + session.add(recorder.db_schema.Statistics.from_stats(3, stat)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -391,7 +399,7 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period1 = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) @@ -480,8 +488,8 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -490,16 +498,16 @@ def test_delete_duplicates_non_identical(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_2) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_2) ) with session_scope(hass=hass) as session: for stat in external_energy_statistics_1: - session.add(recorder.models.Statistics.from_stats(1, stat)) + session.add(recorder.db_schema.Statistics.from_stats(1, stat)) for stat in external_energy_statistics_2: - session.add(recorder.models.Statistics.from_stats(2, stat)) + session.add(recorder.db_schema.Statistics.from_stats(2, stat)) hass.stop() dt_util.DEFAULT_TIME_ZONE = ORIG_TZ @@ -560,7 +568,7 @@ def test_delete_duplicates_short_term(caplog, tmpdir): dburl = f"{SQLITE_URL_PREFIX}//{test_db_file}" importlib.import_module(SCHEMA_MODULE) - old_models = sys.modules[SCHEMA_MODULE] + old_db_schema = sys.modules[SCHEMA_MODULE] period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) @@ -580,8 +588,8 @@ def test_delete_duplicates_short_term(caplog, tmpdir): } # Create some duplicated statistics with schema version 23 - with patch.object(recorder, "models", old_models), patch.object( - recorder.migration, "SCHEMA_VERSION", old_models.SCHEMA_VERSION + with patch.object(recorder, "db_schema", old_db_schema), patch.object( + recorder.migration, "SCHEMA_VERSION", old_db_schema.SCHEMA_VERSION ), patch(CREATE_ENGINE_TARGET, new=_create_engine_test): hass = get_test_home_assistant() setup_component(hass, "recorder", {"recorder": {"db_url": dburl}}) @@ -590,14 +598,14 @@ def test_delete_duplicates_short_term(caplog, tmpdir): with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsMeta.from_meta(external_energy_metadata_1) + recorder.db_schema.StatisticsMeta.from_meta(external_energy_metadata_1) ) with session_scope(hass=hass) as session: session.add( - recorder.models.StatisticsShortTerm.from_stats(1, statistic_row) + recorder.db_schema.StatisticsShortTerm.from_stats(1, statistic_row) ) session.add( - recorder.models.StatisticsShortTerm.from_stats(1, statistic_row) + recorder.db_schema.StatisticsShortTerm.from_stats(1, statistic_row) ) hass.stop() diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 343c57045cf..8624719f951 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -14,7 +14,8 @@ from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder from homeassistant.components.recorder import history, util from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX -from homeassistant.components.recorder.models import RecorderRuns, UnsupportedDialect +from homeassistant.components.recorder.db_schema import RecorderRuns +from homeassistant.components.recorder.models import UnsupportedDialect from homeassistant.components.recorder.util import ( end_incomplete_runs, is_second_sunday, diff --git a/tests/components/script/test_recorder.py b/tests/components/script/test_recorder.py index 0dc7bd54746..a023212b82b 100644 --- a/tests/components/script/test_recorder.py +++ b/tests/components/script/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations import pytest from homeassistant.components import script -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.script import ( ATTR_CUR, diff --git a/tests/components/select/test_recorder.py b/tests/components/select/test_recorder.py index f48679a43f1..083caef3444 100644 --- a/tests/components/select/test_recorder.py +++ b/tests/components/select/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import select -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.select import ATTR_OPTIONS from homeassistant.const import ATTR_FRIENDLY_NAME diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 66ed0032201..c62d1309c7a 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -11,10 +11,8 @@ from pytest import approx from homeassistant import loader from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE -from homeassistant.components.recorder.models import ( - StatisticsMeta, - process_timestamp_to_utc_isoformat, -) +from homeassistant.components.recorder.db_schema import StatisticsMeta +from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat from homeassistant.components.recorder.statistics import ( get_metadata, list_statistic_ids, @@ -2287,7 +2285,7 @@ def test_compile_statistics_hourly_daily_monthly_summary(hass_recorder, caplog): year=2021, month=9, day=1, hour=5, minute=0, second=0, microsecond=0 ) with patch( - "homeassistant.components.recorder.models.dt_util.utcnow", return_value=zero + "homeassistant.components.recorder.db_schema.dt_util.utcnow", return_value=zero ): hass = hass_recorder() # Remove this after dropping the use of the hass_recorder fixture diff --git a/tests/components/siren/test_recorder.py b/tests/components/siren/test_recorder.py index 46e066e4873..aaf1679478a 100644 --- a/tests/components/siren/test_recorder.py +++ b/tests/components/siren/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import siren -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.siren import ATTR_AVAILABLE_TONES from homeassistant.const import ATTR_FRIENDLY_NAME diff --git a/tests/components/sun/test_recorder.py b/tests/components/sun/test_recorder.py index 0b0d0ad48cf..547bf44ec5f 100644 --- a/tests/components/sun/test_recorder.py +++ b/tests/components/sun/test_recorder.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.sun import ( DOMAIN, diff --git a/tests/components/update/test_recorder.py b/tests/components/update/test_recorder.py index d340a8dfa3f..d1263a720af 100644 --- a/tests/components/update/test_recorder.py +++ b/tests/components/update/test_recorder.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.update.const import ( ATTR_IN_PROGRESS, diff --git a/tests/components/vacuum/test_recorder.py b/tests/components/vacuum/test_recorder.py index 6267091b984..040cc9105aa 100644 --- a/tests/components/vacuum/test_recorder.py +++ b/tests/components/vacuum/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import vacuum -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.vacuum import ATTR_FAN_SPEED_LIST from homeassistant.const import ATTR_FRIENDLY_NAME diff --git a/tests/components/water_heater/test_recorder.py b/tests/components/water_heater/test_recorder.py index 4a70fc12c8f..b6670152e3f 100644 --- a/tests/components/water_heater/test_recorder.py +++ b/tests/components/water_heater/test_recorder.py @@ -4,7 +4,7 @@ from __future__ import annotations from datetime import timedelta from homeassistant.components import water_heater -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.water_heater import ( ATTR_MAX_TEMP, diff --git a/tests/components/weather/test_recorder.py b/tests/components/weather/test_recorder.py index 9f2e5289013..ef1998f734c 100644 --- a/tests/components/weather/test_recorder.py +++ b/tests/components/weather/test_recorder.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -from homeassistant.components.recorder.models import StateAttributes, States +from homeassistant.components.recorder.db_schema import StateAttributes, States from homeassistant.components.recorder.util import session_scope from homeassistant.components.weather import ATTR_FORECAST, DOMAIN from homeassistant.core import HomeAssistant, State From 68d67a3e4960f88ed1f1d5e2cb398cac59fcdaf4 Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 7 Jun 2022 22:34:12 +0800 Subject: [PATCH 279/947] Add yolink valve controller support (#73111) --- homeassistant/components/yolink/const.py | 1 + homeassistant/components/yolink/sensor.py | 20 ++++++++++++----- homeassistant/components/yolink/switch.py | 27 +++++++++++++++++------ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index 44f4f3104f7..b154fe9178d 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -21,3 +21,4 @@ ATTR_DEVICE_VIBRATION_SENSOR = "VibrationSensor" ATTR_DEVICE_OUTLET = "Outlet" ATTR_DEVICE_SIREN = "Siren" ATTR_DEVICE_LOCK = "Lock" +ATTR_DEVICE_MANIPULATOR = "Manipulator" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index e0d746219fb..26dee7a493d 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -22,6 +22,7 @@ from .const import ( ATTR_COORDINATORS, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LOCK, + ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, @@ -53,17 +54,28 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, + ATTR_DEVICE_MANIPULATOR, ] BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, - ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, + ATTR_DEVICE_MANIPULATOR, ] +def cvt_battery(val: int | None) -> int | None: + """Convert battery to percentage.""" + if val is None: + return None + if val > 0: + return percentage.ordered_list_item_to_percentage([1, 2, 3, 4], val) + return 0 + + SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( YoLinkSensorEntityDescription( key="battery", @@ -71,11 +83,7 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, name="Battery", state_class=SensorStateClass.MEASUREMENT, - value=lambda value: percentage.ordered_list_item_to_percentage( - [1, 2, 3, 4], value - ) - if value is not None - else None, + value=cvt_battery, exists_fn=lambda device: device.device_type in BATTERY_POWER_SENSOR, ), YoLinkSensorEntityDescription( diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 723043100b3..6733191b943 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -16,7 +16,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_COORDINATORS, ATTR_DEVICE_OUTLET, DOMAIN +from .const import ( + ATTR_COORDINATORS, + ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_OUTLET, + DOMAIN, +) from .coordinator import YoLinkCoordinator from .entity import YoLinkEntity @@ -27,19 +32,27 @@ class YoLinkSwitchEntityDescription(SwitchEntityDescription): exists_fn: Callable[[YoLinkDevice], bool] = lambda _: True value: Callable[[Any], bool | None] = lambda _: None + state_key: str = "state" DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( YoLinkSwitchEntityDescription( - key="state", + key="outlet_state", device_class=SwitchDeviceClass.OUTLET, name="State", value=lambda value: value == "open" if value is not None else None, exists_fn=lambda device: device.device_type in [ATTR_DEVICE_OUTLET], ), + YoLinkSwitchEntityDescription( + key="manipulator_state", + device_class=SwitchDeviceClass.SWITCH, + name="State", + value=lambda value: value == "open" if value is not None else None, + exists_fn=lambda device: device.device_type in [ATTR_DEVICE_MANIPULATOR], + ), ) -DEVICE_TYPE = [ATTR_DEVICE_OUTLET] +DEVICE_TYPE = [ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_OUTLET] async def async_setup_entry( @@ -47,7 +60,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up YoLink Sensor from a config entry.""" + """Set up YoLink switch from a config entry.""" device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] switch_device_coordinators = [ device_coordinator @@ -77,7 +90,7 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): coordinator: YoLinkCoordinator, description: YoLinkSwitchEntityDescription, ) -> None: - """Init YoLink Outlet.""" + """Init YoLink switch.""" super().__init__(config_entry, coordinator) self.entity_description = description self._attr_unique_id = ( @@ -91,12 +104,12 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity): def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" self._attr_is_on = self.entity_description.value( - state.get(self.entity_description.key) + state.get(self.entity_description.state_key) ) self.async_write_ha_state() async def call_state_change(self, state: str) -> None: - """Call setState api to change outlet state.""" + """Call setState api to change switch state.""" await self.call_device_api("setState", {"state": state}) self._attr_is_on = self.entity_description.value(state) self.async_write_ha_state() From c6b835dd91620484f8a4b4c6593f83f7da3850b7 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 7 Jun 2022 17:02:12 +0200 Subject: [PATCH 280/947] Add missing `state_class` to min_max sensors (#73169) Add missing state_class --- homeassistant/components/min_max/sensor.py | 11 ++++++++++- tests/components/min_max/test_sensor.py | 6 ++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 5c117357729..99aec4e9e7b 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -6,7 +6,11 @@ import statistics import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + PLATFORM_SCHEMA, + SensorEntity, + SensorStateClass, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -232,6 +236,11 @@ class MinMaxSensor(SensorEntity): """Return the icon to use in the frontend, if any.""" return ICON + @property + def state_class(self) -> SensorStateClass: + """Return the state class.""" + return SensorStateClass.MEASUREMENT + @callback def _async_min_max_sensor_state_listener(self, event, update_state=True): """Handle the sensor state changes.""" diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index e143b26e47f..72728ac20b6 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -4,6 +4,7 @@ from unittest.mock import patch from homeassistant import config as hass_config from homeassistant.components.min_max.const import DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, @@ -76,6 +77,7 @@ async def test_min_sensor(hass): assert str(float(MIN_VALUE)) == state.state assert entity_ids[2] == state.attributes.get("min_entity_id") + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_max_sensor(hass): @@ -102,6 +104,7 @@ async def test_max_sensor(hass): assert str(float(MAX_VALUE)) == state.state assert entity_ids[1] == state.attributes.get("max_entity_id") + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_mean_sensor(hass): @@ -127,6 +130,7 @@ async def test_mean_sensor(hass): state = hass.states.get("sensor.test_mean") assert str(float(MEAN)) == state.state + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_mean_1_digit_sensor(hass): @@ -204,6 +208,7 @@ async def test_median_sensor(hass): state = hass.states.get("sensor.test_median") assert str(float(MEDIAN)) == state.state + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_not_enough_sensor_value(hass): @@ -327,6 +332,7 @@ async def test_last_sensor(hass): state = hass.states.get("sensor.test_last") assert str(float(value)) == state.state assert entity_id == state.attributes.get("last_entity_id") + assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT async def test_reload(hass): From 73f2bca37782e54a19c19e6ca86ddc9eb98791e1 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Wed, 8 Jun 2022 02:10:53 +1000 Subject: [PATCH 281/947] Make Stream.stop() async (#73107) * Make Stream.start() async * Stop streams concurrently on shutdown Co-authored-by: Martin Hjelmare --- homeassistant/components/camera/__init__.py | 4 +- homeassistant/components/nest/camera_sdm.py | 2 +- homeassistant/components/stream/__init__.py | 67 +++++++++++++-------- homeassistant/components/stream/core.py | 14 +++-- homeassistant/components/stream/hls.py | 4 +- tests/components/camera/test_init.py | 3 +- tests/components/nest/test_camera_sdm.py | 6 +- tests/components/stream/test_hls.py | 27 +++++---- tests/components/stream/test_ll_hls.py | 6 +- tests/components/stream/test_recorder.py | 10 +-- tests/components/stream/test_worker.py | 16 ++--- 11 files changed, 92 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 627da2d1872..2ed8b58232d 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -386,7 +386,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: continue stream.keepalive = True stream.add_provider("hls") - stream.start() + await stream.start() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, preload_stream) @@ -996,7 +996,7 @@ async def _async_stream_endpoint_url( stream.keepalive = camera_prefs.preload_stream stream.add_provider(fmt) - stream.start() + await stream.start() return stream.endpoint_url(fmt) diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 6e14100e881..61f8ead4ea3 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -175,7 +175,7 @@ class NestCamera(Camera): # Next attempt to catch a url will get a new one self._stream = None if self.stream: - self.stream.stop() + await self.stream.stop() self.stream = None return # Update the stream worker with the latest valid url diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 895bdaf3201..c33188fd71c 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -16,6 +16,7 @@ to always keep workers active. """ from __future__ import annotations +import asyncio from collections.abc import Callable, Mapping import logging import re @@ -206,13 +207,16 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Setup Recorder async_setup_recorder(hass) - @callback - def shutdown(event: Event) -> None: + async def shutdown(event: Event) -> None: """Stop all stream workers.""" for stream in hass.data[DOMAIN][ATTR_STREAMS]: stream.keepalive = False - stream.stop() - _LOGGER.info("Stopped stream workers") + if awaitables := [ + asyncio.create_task(stream.stop()) + for stream in hass.data[DOMAIN][ATTR_STREAMS] + ]: + await asyncio.wait(awaitables) + _LOGGER.debug("Stopped stream workers") hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) @@ -236,6 +240,7 @@ class Stream: self._stream_label = stream_label self.keepalive = False self.access_token: str | None = None + self._start_stop_lock = asyncio.Lock() self._thread: threading.Thread | None = None self._thread_quit = threading.Event() self._outputs: dict[str, StreamOutput] = {} @@ -271,12 +276,11 @@ class Stream: """Add provider output stream.""" if not (provider := self._outputs.get(fmt)): - @callback - def idle_callback() -> None: + async def idle_callback() -> None: if ( not self.keepalive or fmt == RECORDER_PROVIDER ) and fmt in self._outputs: - self.remove_provider(self._outputs[fmt]) + await self.remove_provider(self._outputs[fmt]) self.check_idle() provider = PROVIDERS[fmt]( @@ -286,14 +290,14 @@ class Stream: return provider - def remove_provider(self, provider: StreamOutput) -> None: + async def remove_provider(self, provider: StreamOutput) -> None: """Remove provider output stream.""" if provider.name in self._outputs: self._outputs[provider.name].cleanup() del self._outputs[provider.name] if not self._outputs: - self.stop() + await self.stop() def check_idle(self) -> None: """Reset access token if all providers are idle.""" @@ -316,9 +320,14 @@ class Stream: if self._update_callback: self._update_callback() - def start(self) -> None: - """Start a stream.""" - if self._thread is None or not self._thread.is_alive(): + async def start(self) -> None: + """Start a stream. + + Uses an asyncio.Lock to avoid conflicts with _stop(). + """ + async with self._start_stop_lock: + if self._thread and self._thread.is_alive(): + return if self._thread is not None: # The thread must have crashed/exited. Join to clean up the # previous thread. @@ -329,7 +338,7 @@ class Stream: target=self._run_worker, ) self._thread.start() - self._logger.info( + self._logger.debug( "Started stream: %s", redact_credentials(str(self.source)) ) @@ -394,33 +403,39 @@ class Stream: redact_credentials(str(self.source)), ) - @callback - def worker_finished() -> None: + async def worker_finished() -> None: # The worker is no checking availability of the stream and can no longer track # availability so mark it as available, otherwise the frontend may not be able to # interact with the stream. if not self.available: self._async_update_state(True) + # We can call remove_provider() sequentially as the wrapped _stop() function + # which blocks internally is only called when the last provider is removed. for provider in self.outputs().values(): - self.remove_provider(provider) + await self.remove_provider(provider) - self.hass.loop.call_soon_threadsafe(worker_finished) + self.hass.create_task(worker_finished()) - def stop(self) -> None: + async def stop(self) -> None: """Remove outputs and access token.""" self._outputs = {} self.access_token = None if not self.keepalive: - self._stop() + await self._stop() - def _stop(self) -> None: - """Stop worker thread.""" - if self._thread is not None: + async def _stop(self) -> None: + """Stop worker thread. + + Uses an asyncio.Lock to avoid conflicts with start(). + """ + async with self._start_stop_lock: + if self._thread is None: + return self._thread_quit.set() - self._thread.join() + await self.hass.async_add_executor_job(self._thread.join) self._thread = None - self._logger.info( + self._logger.debug( "Stopped stream: %s", redact_credentials(str(self.source)) ) @@ -448,7 +463,7 @@ class Stream: ) recorder.video_path = video_path - self.start() + await self.start() self._logger.debug("Started a stream recording of %s seconds", duration) # Take advantage of lookback @@ -473,7 +488,7 @@ class Stream: """ self.add_provider(HLS_PROVIDER) - self.start() + await self.start() return await self._keyframe_converter.async_get_image( width=width, height=height ) diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 8c0b867752e..da18a5a6a08 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -3,9 +3,9 @@ from __future__ import annotations import asyncio from collections import deque -from collections.abc import Iterable +from collections.abc import Callable, Coroutine, Iterable import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from aiohttp import web import async_timeout @@ -192,7 +192,10 @@ class IdleTimer: """ def __init__( - self, hass: HomeAssistant, timeout: int, idle_callback: CALLBACK_TYPE + self, + hass: HomeAssistant, + timeout: int, + idle_callback: Callable[[], Coroutine[Any, Any, None]], ) -> None: """Initialize IdleTimer.""" self._hass = hass @@ -219,11 +222,12 @@ class IdleTimer: if self._unsub is not None: self._unsub() + @callback def fire(self, _now: datetime.datetime) -> None: """Invoke the idle timeout callback, called when the alarm fires.""" self.idle = True self._unsub = None - self._callback() + self._hass.async_create_task(self._callback()) class StreamOutput: @@ -349,7 +353,7 @@ class StreamView(HomeAssistantView): raise web.HTTPNotFound() # Start worker if not already started - stream.start() + await stream.start() return await self.handle(request, stream, sequence, part_num) diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 23584b59fb9..8e78093d07a 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -117,7 +117,7 @@ class HlsMasterPlaylistView(StreamView): ) -> web.Response: """Return m3u8 playlist.""" track = stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() # Make sure at least two segments are ready (last one may not be complete) if not track.sequences and not await track.recv(): return web.HTTPNotFound() @@ -232,7 +232,7 @@ class HlsPlaylistView(StreamView): track: HlsStreamOutput = cast( HlsStreamOutput, stream.add_provider(HLS_PROVIDER) ) - stream.start() + await stream.start() hls_msn: str | int | None = request.query.get("_HLS_msn") hls_part: str | int | None = request.query.get("_HLS_part") diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 1b30facf1de..ba13bbd6c52 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -3,7 +3,7 @@ import asyncio import base64 from http import HTTPStatus import io -from unittest.mock import Mock, PropertyMock, mock_open, patch +from unittest.mock import AsyncMock, Mock, PropertyMock, mock_open, patch import pytest @@ -410,6 +410,7 @@ async def test_preload_stream(hass, mock_stream): "homeassistant.components.demo.camera.DemoCamera.stream_source", return_value="http://example.com", ): + mock_create_stream.return_value.start = AsyncMock() assert await async_setup_component( hass, "camera", {DOMAIN: {"platform": "demo"}} ) diff --git a/tests/components/nest/test_camera_sdm.py b/tests/components/nest/test_camera_sdm.py index 42b236fda7c..5c4194f46f6 100644 --- a/tests/components/nest/test_camera_sdm.py +++ b/tests/components/nest/test_camera_sdm.py @@ -158,6 +158,7 @@ async def mock_create_stream(hass) -> Mock: ) mock_stream.return_value.async_get_image = AsyncMock() mock_stream.return_value.async_get_image.return_value = IMAGE_BYTES_FROM_STREAM + mock_stream.return_value.start = AsyncMock() yield mock_stream @@ -370,6 +371,7 @@ async def test_refresh_expired_stream_token( # Request a stream for the camera entity to exercise nest cam + camera interaction # and shutdown on url expiration with patch("homeassistant.components.camera.create_stream") as create_stream: + create_stream.return_value.start = AsyncMock() hls_url = await camera.async_request_stream(hass, "camera.my_camera", fmt="hls") assert hls_url.startswith("/api/hls/") # Includes access token assert create_stream.called @@ -536,7 +538,8 @@ async def test_refresh_expired_stream_failure( # Request an HLS stream with patch("homeassistant.components.camera.create_stream") as create_stream: - + create_stream.return_value.start = AsyncMock() + create_stream.return_value.stop = AsyncMock() hls_url = await camera.async_request_stream(hass, "camera.my_camera", fmt="hls") assert hls_url.startswith("/api/hls/") # Includes access token assert create_stream.called @@ -555,6 +558,7 @@ async def test_refresh_expired_stream_failure( # Requesting an HLS stream will create an entirely new stream with patch("homeassistant.components.camera.create_stream") as create_stream: + create_stream.return_value.start = AsyncMock() # The HLS stream endpoint was invalidated, with a new auth token hls_url2 = await camera.async_request_stream( hass, "camera.my_camera", fmt="hls" diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 8e01c55de84..7343b96ef9a 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -144,7 +144,7 @@ async def test_hls_stream( # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() hls_client = await hls_stream(stream) @@ -171,7 +171,7 @@ async def test_hls_stream( stream_worker_sync.resume() # Stop stream, if it hasn't quit already - stream.stop() + await stream.stop() # Ensure playlist not accessible after stream ends fail_response = await hls_client.get() @@ -205,7 +205,7 @@ async def test_stream_timeout( # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() url = stream.endpoint_url(HLS_PROVIDER) http_client = await hass_client() @@ -218,6 +218,7 @@ async def test_stream_timeout( # Wait a minute future = dt_util.utcnow() + timedelta(minutes=1) async_fire_time_changed(hass, future) + await hass.async_block_till_done() # Fetch again to reset timer playlist_response = await http_client.get(parsed_url.path) @@ -249,10 +250,10 @@ async def test_stream_timeout_after_stop( # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() stream_worker_sync.resume() - stream.stop() + await stream.stop() # Wait 5 minutes and fire callback. Stream should already have been # stopped so this is a no-op. @@ -297,14 +298,14 @@ async def test_stream_retries(hass, setup_component, should_retry): mock_time.time.side_effect = time_side_effect # Request stream. Enable retries which are disabled by default in tests. should_retry.return_value = True - stream.start() + await stream.start() stream._thread.join() stream._thread = None assert av_open.call_count == 2 await hass.async_block_till_done() # Stop stream, if it hasn't quit already - stream.stop() + await stream.stop() # Stream marked initially available, then marked as failed, then marked available # before the final failure that exits the stream. @@ -351,7 +352,7 @@ async def test_hls_playlist_view(hass, setup_component, hls_stream, stream_worke ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_hls_max_segments(hass, setup_component, hls_stream, stream_worker_sync): @@ -400,7 +401,7 @@ async def test_hls_max_segments(hass, setup_component, hls_stream, stream_worker assert segment_response.status == HTTPStatus.OK stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_hls_playlist_view_discontinuity( @@ -438,7 +439,7 @@ async def test_hls_playlist_view_discontinuity( ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_hls_max_segments_discontinuity( @@ -481,7 +482,7 @@ async def test_hls_max_segments_discontinuity( ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_remove_incomplete_segment_on_exit( @@ -490,7 +491,7 @@ async def test_remove_incomplete_segment_on_exit( """Test that the incomplete segment gets removed when the worker thread quits.""" stream = create_stream(hass, STREAM_SOURCE, {}) stream_worker_sync.pause() - stream.start() + await stream.start() hls = stream.add_provider(HLS_PROVIDER) segment = Segment(sequence=0, stream_id=0, duration=SEGMENT_DURATION) @@ -511,4 +512,4 @@ async def test_remove_incomplete_segment_on_exit( await hass.async_block_till_done() assert segments[-1].complete assert len(segments) == 2 - stream.stop() + await stream.stop() diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 9a0d94136b9..4aaec93d646 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -144,7 +144,7 @@ async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync): # Request stream stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() hls_client = await hls_stream(stream) @@ -243,7 +243,7 @@ async def test_ll_hls_stream(hass, hls_stream, stream_worker_sync): stream_worker_sync.resume() # Stop stream, if it hasn't quit already - stream.stop() + await stream.stop() # Ensure playlist not accessible after stream ends fail_response = await hls_client.get() @@ -316,7 +316,7 @@ async def test_ll_hls_playlist_view(hass, hls_stream, stream_worker_sync): ) stream_worker_sync.resume() - stream.stop() + await stream.stop() async def test_ll_hls_msn(hass, hls_stream, stream_worker_sync, hls_sync): diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 50aa4df3f1c..9433cbd449d 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -46,7 +46,7 @@ async def test_record_stream(hass, hass_client, record_worker_sync, h264_video): # thread completes and is shutdown completely to avoid thread leaks. await record_worker_sync.join() - stream.stop() + await stream.stop() async def test_record_lookback( @@ -59,14 +59,14 @@ async def test_record_lookback( # Start an HLS feed to enable lookback stream.add_provider(HLS_PROVIDER) - stream.start() + await stream.start() with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path", lookback=4) # This test does not need recorder cleanup since it is not fully exercised - stream.stop() + await stream.stop() async def test_recorder_timeout(hass, hass_client, stream_worker_sync, h264_video): @@ -97,7 +97,7 @@ async def test_recorder_timeout(hass, hass_client, stream_worker_sync, h264_vide assert mock_timeout.called stream_worker_sync.resume() - stream.stop() + await stream.stop() await hass.async_block_till_done() await hass.async_block_till_done() @@ -229,7 +229,7 @@ async def test_record_stream_audio( assert len(result.streams.audio) == expected_audio_streams result.close() - stream.stop() + await stream.stop() await hass.async_block_till_done() # Verify that the save worker was invoked, then block until its diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 2a44dd64455..a70f2be81b8 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -651,12 +651,12 @@ async def test_stream_stopped_while_decoding(hass): return py_av.open(stream_source, args, kwargs) with patch("av.open", new=blocking_open): - stream.start() + await stream.start() assert worker_open.wait(TIMEOUT) # Note: There is a race here where the worker could start as soon # as the wake event is sent, completing all decode work. worker_wake.set() - stream.stop() + await stream.stop() # Stream is still considered available when the worker was still active and asked to stop assert stream.available @@ -688,7 +688,7 @@ async def test_update_stream_source(hass): return py_av.open(stream_source, args, kwargs) with patch("av.open", new=blocking_open): - stream.start() + await stream.start() assert worker_open.wait(TIMEOUT) assert last_stream_source == STREAM_SOURCE assert stream.available @@ -704,7 +704,7 @@ async def test_update_stream_source(hass): assert stream.available # Cleanup - stream.stop() + await stream.stop() async def test_worker_log(hass, caplog): @@ -796,7 +796,7 @@ async def test_durations(hass, record_worker_sync): await record_worker_sync.join() - stream.stop() + await stream.stop() async def test_has_keyframe(hass, record_worker_sync, h264_video): @@ -836,7 +836,7 @@ async def test_has_keyframe(hass, record_worker_sync, h264_video): await record_worker_sync.join() - stream.stop() + await stream.stop() async def test_h265_video_is_hvc1(hass, record_worker_sync): @@ -871,7 +871,7 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): await record_worker_sync.join() - stream.stop() + await stream.stop() assert stream.get_diagnostics() == { "container_format": "mov,mp4,m4a,3gp,3g2,mj2", @@ -905,4 +905,4 @@ async def test_get_image(hass, record_worker_sync): assert await stream.async_get_image() == EMPTY_8_6_JPEG - stream.stop() + await stream.stop() From a5dc7c5f2850a0e7cdf718393c077f45bb69dd74 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 7 Jun 2022 12:49:40 -0400 Subject: [PATCH 282/947] Add logbook describe event support to ZHA (#73077) --- .../components/zha/core/channels/__init__.py | 2 +- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/device.py | 12 +- .../components/zha/device_trigger.py | 3 +- homeassistant/components/zha/logbook.py | 81 +++++++ tests/components/zha/test_channels.py | 2 +- tests/components/zha/test_cover.py | 3 +- tests/components/zha/test_logbook.py | 208 ++++++++++++++++++ 8 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/zha/logbook.py create mode 100644 tests/components/zha/test_logbook.py diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a0df976486f..33143821f9c 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -163,7 +163,7 @@ class Channels: def zha_send_event(self, event_data: dict[str, str | int]) -> None: """Relay events to hass.""" self.zha_device.hass.bus.async_fire( - "zha_event", + const.ZHA_EVENT, { const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee), const.ATTR_UNIQUE_ID: self.unique_id, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 1e249ebd52b..c2d9e926453 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -374,6 +374,7 @@ ZHA_CHANNEL_MSG_CFG_RPT = "zha_channel_configure_reporting" ZHA_CHANNEL_MSG_DATA = "zha_channel_msg_data" ZHA_CHANNEL_CFG_DONE = "zha_channel_cfg_done" ZHA_CHANNEL_READS_PER_REQ = 5 +ZHA_EVENT = "zha_event" ZHA_GW_MSG = "zha_gateway_message" ZHA_GW_MSG_DEVICE_FULL_INIT = "device_fully_initialized" ZHA_GW_MSG_DEVICE_INFO = "device_info" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index e5b3403ba54..6e72c17ef42 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable from datetime import timedelta from enum import Enum +from functools import cached_property import logging import random import time @@ -280,7 +281,16 @@ class ZHADevice(LogMixin): """Return the gateway for this device.""" return self._zha_gateway - @property + @cached_property + def device_automation_commands(self) -> dict[str, list[tuple[str, str]]]: + """Return the a lookup of commands to etype/sub_type.""" + commands: dict[str, list[tuple[str, str]]] = {} + for etype_subtype, trigger in self.device_automation_triggers.items(): + if command := trigger.get(ATTR_COMMAND): + commands.setdefault(command, []).append(etype_subtype) + return commands + + @cached_property def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: """Return the device automation triggers for this device.""" triggers = { diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 670b1cc1477..44682aaa559 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -1,4 +1,5 @@ """Provides device automations for ZHA devices that emit events.""" + import voluptuous as vol from homeassistant.components.automation import ( @@ -16,12 +17,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType from . import DOMAIN +from .core.const import ZHA_EVENT from .core.helpers import async_get_zha_device CONF_SUBTYPE = "subtype" DEVICE = "device" DEVICE_IEEE = "device_ieee" -ZHA_EVENT = "zha_event" TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): str, vol.Required(CONF_SUBTYPE): str} diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py new file mode 100644 index 00000000000..e2d238ddbe8 --- /dev/null +++ b/homeassistant/components/zha/logbook.py @@ -0,0 +1,81 @@ +"""Describe ZHA logbook events.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +from homeassistant.components.logbook.const import ( + LOGBOOK_ENTRY_MESSAGE, + LOGBOOK_ENTRY_NAME, +) +from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID +from homeassistant.core import Event, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr + +from .core.const import DOMAIN as ZHA_DOMAIN, ZHA_EVENT +from .core.helpers import async_get_zha_device + +if TYPE_CHECKING: + from .core.device import ZHADevice + + +@callback +def async_describe_events( + hass: HomeAssistant, + async_describe_event: Callable[[str, str, Callable[[Event], dict[str, str]]], None], +) -> None: + """Describe logbook events.""" + device_registry = dr.async_get(hass) + + @callback + def async_describe_zha_event(event: Event) -> dict[str, str]: + """Describe zha logbook event.""" + device: dr.DeviceEntry | None = None + device_name: str = "Unknown device" + zha_device: ZHADevice | None = None + event_data: dict = event.data + event_type: str | None = None + event_subtype: str | None = None + + try: + device = device_registry.devices[event.data[ATTR_DEVICE_ID]] + if device: + device_name = device.name_by_user or device.name or "Unknown device" + zha_device = async_get_zha_device(hass, event.data[ATTR_DEVICE_ID]) + except (KeyError, AttributeError): + pass + + if ( + zha_device + and (command := event_data.get(ATTR_COMMAND)) + and (command_to_etype_subtype := zha_device.device_automation_commands) + and (etype_subtypes := command_to_etype_subtype.get(command)) + ): + all_triggers = zha_device.device_automation_triggers + for etype_subtype in etype_subtypes: + trigger = all_triggers[etype_subtype] + if not all( + event_data.get(key) == value for key, value in trigger.items() + ): + continue + event_type, event_subtype = etype_subtype + break + + if event_type is None: + event_type = event_data[ATTR_COMMAND] + + if event_subtype is not None and event_subtype != event_type: + event_type = f"{event_type} - {event_subtype}" + + event_type = event_type.replace("_", " ").title() + + message = f"{event_type} event was fired" + if event_data["params"]: + message = f"{message} with parameters: {event_data['params']}" + + return { + LOGBOOK_ENTRY_NAME: device_name, + LOGBOOK_ENTRY_MESSAGE: message, + } + + async_describe_event(ZHA_DOMAIN, ZHA_EVENT, async_describe_zha_event) diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 79b8dbc6a71..e55e10bd7ae 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -510,7 +510,7 @@ async def test_poll_control_cluster_command(hass, poll_control_device): checkin_mock = AsyncMock() poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"] cluster = poll_control_ch.cluster - events = async_capture_events(hass, "zha_event") + events = async_capture_events(hass, zha_const.ZHA_EVENT) with mock.patch.object(poll_control_ch, "check_in_response", checkin_mock): tsn = 22 diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 3c00b5d3109..d5b07e16685 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -17,6 +17,7 @@ from homeassistant.components.cover import ( SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, ) +from homeassistant.components.zha.core.const import ZHA_EVENT from homeassistant.const import ( ATTR_COMMAND, STATE_CLOSED, @@ -410,7 +411,7 @@ async def test_cover_remote(hass, zha_device_joined_restored, zigpy_cover_remote cluster = zigpy_cover_remote.endpoints[1].out_clusters[ closures.WindowCovering.cluster_id ] - zha_events = async_capture_events(hass, "zha_event") + zha_events = async_capture_events(hass, ZHA_EVENT) # up command hdr = make_zcl_header(0, global_command=False) diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py new file mode 100644 index 00000000000..00e1cc28ea6 --- /dev/null +++ b/tests/components/zha/test_logbook.py @@ -0,0 +1,208 @@ +"""ZHA logbook describe events tests.""" + +import pytest +import zigpy.profiles.zha +import zigpy.zcl.clusters.general as general + +from homeassistant.components.zha.core.const import ZHA_EVENT +from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID +from homeassistant.helpers import device_registry as dr +from homeassistant.setup import async_setup_component + +from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE + +from tests.components.logbook.common import MockRow, mock_humanify + +ON = 1 +OFF = 0 +SHAKEN = "device_shaken" +COMMAND = "command" +COMMAND_SHAKE = "shake" +COMMAND_HOLD = "hold" +COMMAND_SINGLE = "single" +COMMAND_DOUBLE = "double" +DOUBLE_PRESS = "remote_button_double_press" +SHORT_PRESS = "remote_button_short_press" +LONG_PRESS = "remote_button_long_press" +LONG_RELEASE = "remote_button_long_release" +UP = "up" +DOWN = "down" + + +@pytest.fixture +async def mock_devices(hass, zigpy_device_mock, zha_device_joined): + """IAS device fixture.""" + + zigpy_device = zigpy_device_mock( + { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } + } + ) + + zha_device = await zha_device_joined(zigpy_device) + zha_device.update_available(True) + await hass.async_block_till_done() + return zigpy_device, zha_device + + +async def test_zha_logbook_event_device_with_triggers(hass, mock_devices): + """Test zha logbook events with device and triggers.""" + + zigpy_device, zha_device = mock_devices + + zigpy_device.device_automation_triggers = { + (SHAKEN, SHAKEN): {COMMAND: COMMAND_SHAKE}, + (UP, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE, "endpoint_id": 1}, + (DOWN, DOUBLE_PRESS): {COMMAND: COMMAND_DOUBLE, "endpoint_id": 2}, + (SHORT_PRESS, SHORT_PRESS): {COMMAND: COMMAND_SINGLE}, + (LONG_PRESS, LONG_PRESS): {COMMAND: COMMAND_HOLD}, + (LONG_RELEASE, LONG_RELEASE): {COMMAND: COMMAND_HOLD}, + } + + ieee_address = str(zha_device.ieee) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_SHAKE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_DOUBLE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_DOUBLE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 2, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "FakeManufacturer FakeModel" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Device Shaken event was fired with parameters: {'test': 'test'}" + ) + + assert events[1]["name"] == "FakeManufacturer FakeModel" + assert events[1]["domain"] == "zha" + assert ( + events[1]["message"] + == "Up - Remote Button Double Press event was fired with parameters: {'test': 'test'}" + ) + + +async def test_zha_logbook_event_device_no_triggers(hass, mock_devices): + """Test zha logbook events with device and without triggers.""" + + zigpy_device, zha_device = mock_devices + ieee_address = str(zha_device.ieee) + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}) + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + COMMAND: COMMAND_SHAKE, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "FakeManufacturer FakeModel" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Shake event was fired with parameters: {'test': 'test'}" + ) + + +async def test_zha_logbook_event_device_no_device(hass, mock_devices): + """Test zha logbook events without device and without triggers.""" + + hass.config.components.add("recorder") + assert await async_setup_component(hass, "logbook", {}) + + events = mock_humanify( + hass, + [ + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: "non-existing-device", + COMMAND: COMMAND_SHAKE, + "device_ieee": "90:fd:9f:ff:fe:fe:d8:a1", + CONF_UNIQUE_ID: "90:fd:9f:ff:fe:fe:d8:a1:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), + ], + ) + + assert events[0]["name"] == "Unknown device" + assert events[0]["domain"] == "zha" + assert ( + events[0]["message"] + == "Shake event was fired with parameters: {'test': 'test'}" + ) From 0b5c0f82496f9a74098b51c44a60af7668c3334d Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 7 Jun 2022 18:56:11 +0200 Subject: [PATCH 283/947] Bump `nam` backend library (#72771) * Update config flow * Fix discovery with auth * Call check_credentials() on init * Update tests * Bump library version * Cleaning * Return dataclass instead of tuple * Fix pylint error --- homeassistant/components/nam/__init__.py | 7 +- homeassistant/components/nam/config_flow.py | 61 +++++++++---- homeassistant/components/nam/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/nam/test_config_flow.py | 98 ++++++++++----------- tests/components/nam/test_init.py | 2 +- 7 files changed, 99 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/nam/__init__.py b/homeassistant/components/nam/__init__.py index 6b0f9db3757..021b46e2f38 100644 --- a/homeassistant/components/nam/__init__.py +++ b/homeassistant/components/nam/__init__.py @@ -52,11 +52,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: options = ConnectionOptions(host=host, username=username, password=password) try: nam = await NettigoAirMonitor.create(websession, options) - except AuthFailed as err: - raise ConfigEntryAuthFailed from err except (ApiError, ClientError, ClientConnectorError, asyncio.TimeoutError) as err: raise ConfigEntryNotReady from err + try: + await nam.async_check_credentials() + except AuthFailed as err: + raise ConfigEntryAuthFailed from err + coordinator = NAMDataUpdateCoordinator(hass, nam, entry.unique_id) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index df41eb7c5f1..451148c22fe 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Mapping +from dataclasses import dataclass import logging from typing import Any @@ -27,6 +28,15 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN + +@dataclass +class NamConfig: + """NAM device configuration class.""" + + mac_address: str + auth_enabled: bool + + _LOGGER = logging.getLogger(__name__) AUTH_SCHEMA = vol.Schema( @@ -34,15 +44,31 @@ AUTH_SCHEMA = vol.Schema( ) -async def async_get_mac(hass: HomeAssistant, host: str, data: dict[str, Any]) -> str: - """Get device MAC address.""" +async def async_get_config(hass: HomeAssistant, host: str) -> NamConfig: + """Get device MAC address and auth_enabled property.""" websession = async_get_clientsession(hass) - options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + options = ConnectionOptions(host) nam = await NettigoAirMonitor.create(websession, options) async with async_timeout.timeout(10): - return await nam.async_get_mac_address() + mac = await nam.async_get_mac_address() + + return NamConfig(mac, nam.auth_enabled) + + +async def async_check_credentials( + hass: HomeAssistant, host: str, data: dict[str, Any] +) -> None: + """Check if credentials are valid.""" + websession = async_get_clientsession(hass) + + options = ConnectionOptions(host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)) + + nam = await NettigoAirMonitor.create(websession, options) + + async with async_timeout.timeout(10): + await nam.async_check_credentials() class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -54,6 +80,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Initialize flow.""" self.host: str self.entry: config_entries.ConfigEntry + self._config: NamConfig async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -65,9 +92,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.host = user_input[CONF_HOST] try: - mac = await async_get_mac(self.hass, self.host, {}) - except AuthFailed: - return await self.async_step_credentials() + config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" except CannotGetMac: @@ -76,9 +101,12 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id(format_mac(config.mac_address)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) + if config.auth_enabled is True: + return await self.async_step_credentials() + return self.async_create_entry( title=self.host, data=user_input, @@ -98,19 +126,15 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: - mac = await async_get_mac(self.hass, self.host, user_input) + await async_check_credentials(self.hass, self.host, user_input) except AuthFailed: errors["base"] = "invalid_auth" except (ApiError, ClientConnectorError, asyncio.TimeoutError): errors["base"] = "cannot_connect" - except CannotGetMac: - return self.async_abort(reason="device_unsupported") except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(format_mac(mac)) - self._abort_if_unique_id_configured({CONF_HOST: self.host}) return self.async_create_entry( title=self.host, @@ -132,15 +156,13 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: self.host}) try: - mac = await async_get_mac(self.hass, self.host, {}) - except AuthFailed: - return await self.async_step_credentials() + self._config = await async_get_config(self.hass, self.host) except (ApiError, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="cannot_connect") except CannotGetMac: return self.async_abort(reason="device_unsupported") - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id(format_mac(self._config.mac_address)) self._abort_if_unique_id_configured({CONF_HOST: self.host}) return await self.async_step_confirm_discovery() @@ -157,6 +179,9 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_HOST: self.host}, ) + if self._config.auth_enabled is True: + return await self.async_step_credentials() + self._set_confirm_only() return self.async_show_form( @@ -181,7 +206,7 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if user_input is not None: try: - await async_get_mac(self.hass, self.host, user_input) + await async_check_credentials(self.hass, self.host, user_input) except (ApiError, AuthFailed, ClientConnectorError, asyncio.TimeoutError): return self.async_abort(reason="reauth_unsuccessful") else: diff --git a/homeassistant/components/nam/manifest.json b/homeassistant/components/nam/manifest.json index a842af46f84..88048b59162 100644 --- a/homeassistant/components/nam/manifest.json +++ b/homeassistant/components/nam/manifest.json @@ -3,7 +3,7 @@ "name": "Nettigo Air Monitor", "documentation": "https://www.home-assistant.io/integrations/nam", "codeowners": ["@bieniu"], - "requirements": ["nettigo-air-monitor==1.2.4"], + "requirements": ["nettigo-air-monitor==1.3.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 70a29067a7b..8a804d3ae90 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1077,7 +1077,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.2.4 +nettigo-air-monitor==1.3.0 # homeassistant.components.neurio_energy neurio==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 850a379eaf6..48b20ee778f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -745,7 +745,7 @@ netdisco==3.0.0 netmap==0.7.0.2 # homeassistant.components.nam -nettigo-air-monitor==1.2.4 +nettigo-air-monitor==1.3.0 # homeassistant.components.nexia nexia==1.0.1 diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 9479e29cdea..67274cf1c78 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -23,6 +23,8 @@ DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( ) VALID_CONFIG = {"host": "10.10.2.3"} VALID_AUTH = {"username": "fake_username", "password": "fake_password"} +DEVICE_CONFIG = {"www_basicauth_enabled": False} +DEVICE_CONFIG_AUTH = {"www_basicauth_enabled": True} async def test_form_create_entry_without_auth(hass): @@ -34,7 +36,10 @@ async def test_form_create_entry_without_auth(hass): assert result["step_id"] == SOURCE_USER assert result["errors"] == {} - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( @@ -62,24 +67,22 @@ async def test_form_create_entry_with_auth(hass): assert result["errors"] == {} with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", - side_effect=AuthFailed("Auth Error"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - VALID_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "credentials" - - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( "homeassistant.components.nam.async_setup_entry", return_value=True ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + VALID_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "credentials" + result = await hass.config_entries.flow.async_configure( result["flow_id"], VALID_AUTH, @@ -104,7 +107,10 @@ async def test_reauth_successful(hass): ) entry.add_to_hass(hass) - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -137,7 +143,7 @@ async def test_reauth_unsuccessful(hass): entry.add_to_hass(hass) with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=ApiError("API Error"), ): result = await hass.config_entries.flow.async_init( @@ -171,8 +177,11 @@ async def test_form_with_auth_errors(hass, error): """Test we handle errors when auth is required.""" exc, base_error = error with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Auth Error"), + ), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -221,26 +230,13 @@ async def test_form_errors(hass, error): async def test_form_abort(hass): - """Test we handle abort after error.""" - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - side_effect=CannotGetMac("Cannot get MAC address from device"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data=VALID_CONFIG, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "device_unsupported" - - -async def test_form_with_auth_abort(hass): """Test we handle abort after error.""" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", - side_effect=AuthFailed("Auth Error"), + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + side_effect=CannotGetMac("Cannot get MAC address from device"), ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -248,18 +244,6 @@ async def test_form_with_auth_abort(hass): data=VALID_CONFIG, ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "credentials" - - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( - "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", - side_effect=CannotGetMac("Cannot get MAC address from device"), - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - VALID_AUTH, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "device_unsupported" @@ -275,7 +259,10 @@ async def test_form_already_configured(hass): DOMAIN, context={"source": SOURCE_USER} ) - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -293,7 +280,10 @@ async def test_form_already_configured(hass): async def test_zeroconf(hass): """Test we get the form.""" - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ): @@ -332,8 +322,11 @@ async def test_zeroconf(hass): async def test_zeroconf_with_auth(hass): """Test that the zeroconf step with auth works.""" with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Auth Error"), + ), patch( + "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", + return_value="aa:bb:cc:dd:ee:ff", ): result = await hass.config_entries.flow.async_init( DOMAIN, @@ -351,7 +344,10 @@ async def test_zeroconf_with_auth(hass): assert result["errors"] == {} assert context["title_placeholders"]["host"] == "10.10.2.3" - with patch("homeassistant.components.nam.NettigoAirMonitor.initialize"), patch( + with patch( + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", + return_value=DEVICE_CONFIG_AUTH, + ), patch( "homeassistant.components.nam.NettigoAirMonitor.async_get_mac_address", return_value="aa:bb:cc:dd:ee:ff", ), patch( diff --git a/tests/components/nam/test_init.py b/tests/components/nam/test_init.py index 3223a394f68..9eac901d693 100644 --- a/tests/components/nam/test_init.py +++ b/tests/components/nam/test_init.py @@ -51,7 +51,7 @@ async def test_config_auth_failed(hass): ) with patch( - "homeassistant.components.nam.NettigoAirMonitor.initialize", + "homeassistant.components.nam.NettigoAirMonitor.async_check_credentials", side_effect=AuthFailed("Authorization has failed"), ): entry.add_to_hass(hass) From 981c34f88d9aa7b9c68db47d1c1b3a8f9af7d1e1 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 7 Jun 2022 19:15:25 +0200 Subject: [PATCH 284/947] Use class attribute instead of property in min_max integration (#73175) --- homeassistant/components/min_max/sensor.py | 28 +++++----------------- 1 file changed, 6 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 99aec4e9e7b..c5a51cdda7a 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -161,6 +161,10 @@ def calc_median(sensor_values, round_digits): class MinMaxSensor(SensorEntity): """Representation of a min/max sensor.""" + _attr_icon = ICON + _attr_should_poll = False + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__(self, entity_ids, name, sensor_type, round_digits, unique_id): """Initialize the min/max sensor.""" self._attr_unique_id = unique_id @@ -169,9 +173,9 @@ class MinMaxSensor(SensorEntity): self._round_digits = round_digits if name: - self._name = name + self._attr_name = name else: - self._name = f"{sensor_type} sensor".capitalize() + self._attr_name = f"{sensor_type} sensor".capitalize() self._sensor_attr = SENSOR_TYPE_TO_ATTR[self._sensor_type] self._unit_of_measurement = None self._unit_of_measurement_mismatch = False @@ -196,11 +200,6 @@ class MinMaxSensor(SensorEntity): self._calc_values() - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def native_value(self): """Return the state of the sensor.""" @@ -215,11 +214,6 @@ class MinMaxSensor(SensorEntity): return "ERR" return self._unit_of_measurement - @property - def should_poll(self): - """No polling needed.""" - return False - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" @@ -231,16 +225,6 @@ class MinMaxSensor(SensorEntity): return {ATTR_LAST_ENTITY_ID: self.last_entity_id} return None - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return ICON - - @property - def state_class(self) -> SensorStateClass: - """Return the state class.""" - return SensorStateClass.MEASUREMENT - @callback def _async_min_max_sensor_state_listener(self, event, update_state=True): """Handle the sensor state changes.""" From f10cae105241af452e306d0afcc1d4693dcaf42f Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 7 Jun 2022 19:57:29 +0200 Subject: [PATCH 285/947] Add missing `state_class` to xiaomi_aqara sensors (#73167) Add missing state_class --- homeassistant/components/xiaomi_aqara/sensor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 9c295c3fe0a..3e0d49d628f 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -7,6 +7,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -30,36 +31,43 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { key="temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), "humidity": SensorEntityDescription( key="humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), "illumination": SensorEntityDescription( key="illumination", native_unit_of_measurement="lm", device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), "lux": SensorEntityDescription( key="lux", native_unit_of_measurement=LIGHT_LUX, device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, ), "pressure": SensorEntityDescription( key="pressure", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), "bed_activity": SensorEntityDescription( key="bed_activity", native_unit_of_measurement="μm", device_class=None, + state_class=SensorStateClass.MEASUREMENT, ), "load_power": SensorEntityDescription( key="load_power", native_unit_of_measurement=POWER_WATT, device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, ), "final_tilt_angle": SensorEntityDescription( key="final_tilt_angle", @@ -69,6 +77,7 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { ), "Battery": SensorEntityDescription( key="Battery", + state_class=SensorStateClass.MEASUREMENT, ), } From 1bc986794087f0b7a114a509b35819570a4c76b3 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 7 Jun 2022 14:19:39 -0400 Subject: [PATCH 286/947] Bump version of pyunifiprotect to 3.9.0 (#73168) Co-authored-by: J. Nick Koston --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifiprotect/test_binary_sensor.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 898abd73a6f..52f69abea00 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.6.0", "unifi-discovery==1.1.3"], + "requirements": ["pyunifiprotect==3.9.0", "unifi-discovery==1.1.3"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 8a804d3ae90..6b71729bb53 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,7 +1993,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.6.0 +pyunifiprotect==3.9.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48b20ee778f..a48dce2c79f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1322,7 +1322,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.6.0 +pyunifiprotect==3.9.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 60a3d5a8126..88b42d36994 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -168,7 +168,7 @@ async def sensor_fixture( sensor_obj.motion_detected_at = now - timedelta(hours=1) sensor_obj.open_status_changed_at = now - timedelta(hours=1) sensor_obj.alarm_triggered_at = now - timedelta(hours=1) - sensor_obj.tampering_detected_at = now - timedelta(hours=1) + sensor_obj.tampering_detected_at = None mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] @@ -204,7 +204,7 @@ async def sensor_none_fixture( sensor_obj.mount_type = MountType.LEAK sensor_obj.battery_status.is_low = False sensor_obj.alarm_settings.is_enabled = False - sensor_obj.tampering_detected_at = now - timedelta(hours=1) + sensor_obj.tampering_detected_at = None mock_entry.api.bootstrap.reset_objects() mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] From 94c037605a50dc211b5f90cc79226af814b308d8 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Tue, 7 Jun 2022 16:35:59 -0400 Subject: [PATCH 287/947] Address late comment on Goalzero refactor (#73180) --- homeassistant/components/goalzero/entity.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/goalzero/entity.py b/homeassistant/components/goalzero/entity.py index fa0d55b0d5f..8c696ce1377 100644 --- a/homeassistant/components/goalzero/entity.py +++ b/homeassistant/components/goalzero/entity.py @@ -23,7 +23,6 @@ class GoalZeroEntity(CoordinatorEntity[GoalZeroDataUpdateCoordinator]): ) -> None: """Initialize a Goal Zero Yeti entity.""" super().__init__(coordinator) - self.coordinator = coordinator self.entity_description = description self._attr_name = ( f"{coordinator.config_entry.data[CONF_NAME]} {description.name}" From d587e4769ad0bb3b7106fcf7a1213614ddd20b8f Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Tue, 7 Jun 2022 14:39:15 -0700 Subject: [PATCH 288/947] Bump pywemo to 0.9.1 (#73186) --- homeassistant/components/wemo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index 40bb8161d90..b324ba060ea 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.8.1"], + "requirements": ["pywemo==0.9.1"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/requirements_all.txt b/requirements_all.txt index 6b71729bb53..b95e0b7280c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2023,7 +2023,7 @@ pyvolumio==0.1.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.8.1 +pywemo==0.9.1 # homeassistant.components.wilight pywilight==0.0.70 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a48dce2c79f..5a9399b0e3e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1343,7 +1343,7 @@ pyvolumio==0.1.5 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.8.1 +pywemo==0.9.1 # homeassistant.components.wilight pywilight==0.0.70 From eca67680164037cadbaf524ed02a1c7b35fe3938 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 8 Jun 2022 01:01:44 +0200 Subject: [PATCH 289/947] Use default None for voltage property of FritzDevice in Fritz!Smarthome (#73141) use default None for device.voltage --- homeassistant/components/fritzbox/sensor.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index e590f14ce89..2ae7f9dccc8 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -96,7 +96,9 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=lambda device: device.voltage / 1000 if device.voltage else 0.0, + native_value=lambda device: device.voltage / 1000 + if getattr(device, "voltage", None) + else 0.0, ), FritzSensorEntityDescription( key="electric_current", @@ -106,7 +108,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] native_value=lambda device: device.power / device.voltage - if device.power and device.voltage + if device.power and getattr(device, "voltage", None) else 0.0, ), FritzSensorEntityDescription( From 8c34067f17f0506d56d61e095be5d99523468e83 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 8 Jun 2022 01:11:38 +0200 Subject: [PATCH 290/947] Fix creating unique IDs for WiFi switches in Fritz!Tools (#73183) --- homeassistant/components/fritz/switch.py | 11 +- tests/components/fritz/conftest.py | 12 +- tests/components/fritz/const.py | 3 +- tests/components/fritz/test_button.py | 2 +- tests/components/fritz/test_config_flow.py | 2 +- tests/components/fritz/test_diagnostics.py | 2 +- tests/components/fritz/test_init.py | 2 +- tests/components/fritz/test_sensor.py | 2 +- tests/components/fritz/test_switch.py | 189 +++++++++++++++++++++ tests/components/fritz/test_update.py | 2 +- 10 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 tests/components/fritz/test_switch.py diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index cac6e735a81..a45cd347463 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -169,7 +169,16 @@ def wifi_entities_list( } for i, network in networks.copy().items(): networks[i]["switch_name"] = network["ssid"] - if len([j for j, n in networks.items() if n["ssid"] == network["ssid"]]) > 1: + if ( + len( + [ + j + for j, n in networks.items() + if slugify(n["ssid"]) == slugify(network["ssid"]) + ] + ) + > 1 + ): networks[i]["switch_name"] += f" ({WIFI_STANDARD[i]})" _LOGGER.debug("WiFi networks list: %s", networks) diff --git a/tests/components/fritz/conftest.py b/tests/components/fritz/conftest.py index b073335f20a..edbde883f56 100644 --- a/tests/components/fritz/conftest.py +++ b/tests/components/fritz/conftest.py @@ -1,4 +1,4 @@ -"""Common stuff for AVM Fritz!Box tests.""" +"""Common stuff for Fritz!Tools tests.""" import logging from unittest.mock import MagicMock, patch @@ -73,13 +73,19 @@ class FritzHostMock(FritzHosts): return MOCK_MESH_DATA +@pytest.fixture(name="fc_data") +def fc_data_mock(): + """Fixture for default fc_data.""" + return MOCK_FB_SERVICES + + @pytest.fixture() -def fc_class_mock(): +def fc_class_mock(fc_data): """Fixture that sets up a mocked FritzConnection class.""" with patch( "homeassistant.components.fritz.common.FritzConnection", autospec=True ) as result: - result.return_value = FritzConnectionMock(MOCK_FB_SERVICES) + result.return_value = FritzConnectionMock(fc_data) yield result diff --git a/tests/components/fritz/const.py b/tests/components/fritz/const.py index f8f6f8370d7..32f2211d16b 100644 --- a/tests/components/fritz/const.py +++ b/tests/components/fritz/const.py @@ -1,4 +1,4 @@ -"""Common stuff for AVM Fritz!Box tests.""" +"""Common stuff for Fritz!Tools tests.""" from homeassistant.components import ssdp from homeassistant.components.fritz.const import DOMAIN from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_UDN @@ -194,6 +194,7 @@ MOCK_FB_SERVICES: dict[str, dict] = { }, } + MOCK_MESH_DATA = { "schema_version": "1.9", "nodes": [ diff --git a/tests/components/fritz/test_button.py b/tests/components/fritz/test_button.py index a6ff579958a..a2bd6132731 100644 --- a/tests/components/fritz/test_button.py +++ b/tests/components/fritz/test_button.py @@ -1,4 +1,4 @@ -"""Tests for Shelly button platform.""" +"""Tests for Fritz!Tools button platform.""" from unittest.mock import patch import pytest diff --git a/tests/components/fritz/test_config_flow.py b/tests/components/fritz/test_config_flow.py index 6d014782842..619f06f5493 100644 --- a/tests/components/fritz/test_config_flow.py +++ b/tests/components/fritz/test_config_flow.py @@ -1,4 +1,4 @@ -"""Tests for AVM Fritz!Box config flow.""" +"""Tests for Fritz!Tools config flow.""" import dataclasses from unittest.mock import patch diff --git a/tests/components/fritz/test_diagnostics.py b/tests/components/fritz/test_diagnostics.py index a4b4942c375..2c0b42d6bdf 100644 --- a/tests/components/fritz/test_diagnostics.py +++ b/tests/components/fritz/test_diagnostics.py @@ -1,4 +1,4 @@ -"""Tests for the AVM Fritz!Box integration.""" +"""Tests for Fritz!Tools diagnostics platform.""" from __future__ import annotations from aiohttp import ClientSession diff --git a/tests/components/fritz/test_init.py b/tests/components/fritz/test_init.py index fd67321d235..9f7a17de900 100644 --- a/tests/components/fritz/test_init.py +++ b/tests/components/fritz/test_init.py @@ -1,4 +1,4 @@ -"""Tests for AVM Fritz!Box.""" +"""Tests for Fritz!Tools.""" from unittest.mock import patch from fritzconnection.core.exceptions import FritzSecurityError diff --git a/tests/components/fritz/test_sensor.py b/tests/components/fritz/test_sensor.py index 31e142a3e47..73a7c1068ae 100644 --- a/tests/components/fritz/test_sensor.py +++ b/tests/components/fritz/test_sensor.py @@ -1,4 +1,4 @@ -"""Tests for Shelly button platform.""" +"""Tests for Fritz!Tools sensor platform.""" from __future__ import annotations from datetime import timedelta diff --git a/tests/components/fritz/test_switch.py b/tests/components/fritz/test_switch.py new file mode 100644 index 00000000000..1e744bb62e6 --- /dev/null +++ b/tests/components/fritz/test_switch.py @@ -0,0 +1,189 @@ +"""Tests for Fritz!Tools switch platform.""" +from __future__ import annotations + +import pytest + +from homeassistant.components.fritz.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import MOCK_FB_SERVICES, MOCK_USER_DATA + +from tests.common import MockConfigEntry + +MOCK_WLANCONFIGS_SAME_SSID: dict[str, dict] = { + "WLANConfiguration1": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewMaxBitRate": "Auto", + "NewChannel": 13, + "NewSSID": "WiFi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewMACAddressControlEnabled": False, + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:12", + "NewBasicEncryptionModes": "None", + "NewBasicAuthenticationMode": "None", + "NewMaxCharsSSID": 32, + "NewMinCharsSSID": 1, + "NewAllowedCharsSSID": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "NewMinCharsPSK": 64, + "NewMaxCharsPSK": 64, + "NewAllowedCharsPSK": "0123456789ABCDEFabcdef", + } + }, + "WLANConfiguration2": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewMaxBitRate": "Auto", + "NewChannel": 52, + "NewSSID": "WiFi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewMACAddressControlEnabled": False, + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:13", + "NewBasicEncryptionModes": "None", + "NewBasicAuthenticationMode": "None", + "NewMaxCharsSSID": 32, + "NewMinCharsSSID": 1, + "NewAllowedCharsSSID": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "NewMinCharsPSK": 64, + "NewMaxCharsPSK": 64, + "NewAllowedCharsPSK": "0123456789ABCDEFabcdef", + } + }, +} +MOCK_WLANCONFIGS_DIFF_SSID: dict[str, dict] = { + "WLANConfiguration1": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewMaxBitRate": "Auto", + "NewChannel": 13, + "NewSSID": "WiFi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewMACAddressControlEnabled": False, + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:12", + "NewBasicEncryptionModes": "None", + "NewBasicAuthenticationMode": "None", + "NewMaxCharsSSID": 32, + "NewMinCharsSSID": 1, + "NewAllowedCharsSSID": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "NewMinCharsPSK": 64, + "NewMaxCharsPSK": 64, + "NewAllowedCharsPSK": "0123456789ABCDEFabcdef", + } + }, + "WLANConfiguration2": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewMaxBitRate": "Auto", + "NewChannel": 52, + "NewSSID": "WiFi2", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewMACAddressControlEnabled": False, + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:13", + "NewBasicEncryptionModes": "None", + "NewBasicAuthenticationMode": "None", + "NewMaxCharsSSID": 32, + "NewMinCharsSSID": 1, + "NewAllowedCharsSSID": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "NewMinCharsPSK": 64, + "NewMaxCharsPSK": 64, + "NewAllowedCharsPSK": "0123456789ABCDEFabcdef", + } + }, +} +MOCK_WLANCONFIGS_DIFF2_SSID: dict[str, dict] = { + "WLANConfiguration1": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewMaxBitRate": "Auto", + "NewChannel": 13, + "NewSSID": "WiFi", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewMACAddressControlEnabled": False, + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:12", + "NewBasicEncryptionModes": "None", + "NewBasicAuthenticationMode": "None", + "NewMaxCharsSSID": 32, + "NewMinCharsSSID": 1, + "NewAllowedCharsSSID": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "NewMinCharsPSK": 64, + "NewMaxCharsPSK": 64, + "NewAllowedCharsPSK": "0123456789ABCDEFabcdef", + } + }, + "WLANConfiguration2": { + "GetInfo": { + "NewEnable": True, + "NewStatus": "Up", + "NewMaxBitRate": "Auto", + "NewChannel": 52, + "NewSSID": "WiFi+", + "NewBeaconType": "11iandWPA3", + "NewX_AVM-DE_PossibleBeaconTypes": "None,11i,11iandWPA3", + "NewMACAddressControlEnabled": False, + "NewStandard": "ax", + "NewBSSID": "1C:ED:6F:12:34:13", + "NewBasicEncryptionModes": "None", + "NewBasicAuthenticationMode": "None", + "NewMaxCharsSSID": 32, + "NewMinCharsSSID": 1, + "NewAllowedCharsSSID": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~", + "NewMinCharsPSK": 64, + "NewMaxCharsPSK": 64, + "NewAllowedCharsPSK": "0123456789ABCDEFabcdef", + } + }, +} + + +@pytest.mark.parametrize( + "fc_data, expected_wifi_names", + [ + ( + {**MOCK_FB_SERVICES, **MOCK_WLANCONFIGS_SAME_SSID}, + ["WiFi (2.4Ghz)", "WiFi (5Ghz)"], + ), + ({**MOCK_FB_SERVICES, **MOCK_WLANCONFIGS_DIFF_SSID}, ["WiFi", "WiFi2"]), + ( + {**MOCK_FB_SERVICES, **MOCK_WLANCONFIGS_DIFF2_SSID}, + ["WiFi (2.4Ghz)", "WiFi+ (5Ghz)"], + ), + ], +) +async def test_switch_setup( + hass: HomeAssistant, + expected_wifi_names: list[str], + fc_class_mock, + fh_class_mock, +): + """Test setup of Fritz!Tools switches.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) + + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + switches = hass.states.async_all(Platform.SWITCH) + assert len(switches) == 3 + assert switches[0].name == f"Mock Title Wi-Fi {expected_wifi_names[0]}" + assert switches[1].name == f"Mock Title Wi-Fi {expected_wifi_names[1]}" + assert switches[2].name == "printer Internet Access" diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index 2261ef3ef9b..32bdf361847 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -1,4 +1,4 @@ -"""The tests for the Fritzbox update entity.""" +"""Tests for Fritz!Tools update platform.""" from unittest.mock import patch From db0f089a2e3532f1e061e47926eccd520a28f7f5 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 7 Jun 2022 17:14:40 -0600 Subject: [PATCH 291/947] Fix bugs with RainMachine zone run time sensors (#73179) --- .../components/rainmachine/sensor.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index cc37189aa49..1144ceea159 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta +from regenmaschine.controller import Controller + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -13,8 +15,9 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import TEMP_CELSIUS, VOLUME_CUBIC_METERS from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity import EntityCategory, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow from . import RainMachineEntity @@ -205,16 +208,33 @@ class ZoneTimeRemainingSensor(RainMachineEntity, SensorEntity): entity_description: RainMachineSensorDescriptionUid + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + controller: Controller, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, coordinator, controller, description) + + self._running_or_queued: bool = False + @callback def update_from_latest_data(self) -> None: """Update the state.""" data = self.coordinator.data[self.entity_description.uid] now = utcnow() - if RUN_STATE_MAP.get(data["state"]) != RunStates.RUNNING: - # If the zone isn't actively running, return immediately: + if RUN_STATE_MAP.get(data["state"]) == RunStates.NOT_RUNNING: + if self._running_or_queued: + # If we go from running to not running, update the state to be right + # now (i.e., the time the zone stopped running): + self._attr_native_value = now + self._running_or_queued = False return + self._running_or_queued = True new_timestamp = now + timedelta(seconds=data["remaining"]) if self._attr_native_value: From 7ae8bd5137c4dc76bfd9c4a069280044b09425ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jun 2022 13:15:50 -1000 Subject: [PATCH 292/947] Remove sqlalchemy lambda_stmt usage from history, logbook, and statistics (#73191) --- .../components/logbook/queries/__init__.py | 26 ++-- .../components/logbook/queries/all.py | 20 ++- .../components/logbook/queries/devices.py | 42 +++--- .../components/logbook/queries/entities.py | 47 +++---- .../logbook/queries/entities_and_devices.py | 59 ++++---- homeassistant/components/recorder/history.py | 130 ++++++++---------- .../components/recorder/statistics.py | 99 ++++++------- homeassistant/components/recorder/util.py | 10 +- tests/components/recorder/test_util.py | 23 ++-- 9 files changed, 203 insertions(+), 253 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 3c027823612..a59ebc94b87 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -2,8 +2,9 @@ from __future__ import annotations from datetime import datetime as dt +import json -from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Select from homeassistant.components.recorder.filters import Filters @@ -21,7 +22,7 @@ def statement_for_request( device_ids: list[str] | None = None, filters: Filters | None = None, context_id: str | None = None, -) -> StatementLambdaElement: +) -> Select: """Generate the logbook statement for a logbook request.""" # No entities: logbook sends everything for the timeframe @@ -38,41 +39,36 @@ def statement_for_request( context_id, ) - # sqlalchemy caches object quoting, the - # json quotable ones must be a different - # object from the non-json ones to prevent - # sqlalchemy from quoting them incorrectly - # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quotable_entity_ids = list(entity_ids) - json_quotable_device_ids = list(device_ids) + json_quoted_entity_ids = [json.dumps(entity_id) for entity_id in entity_ids] + json_quoted_device_ids = [json.dumps(device_id) for device_id in device_ids] return entities_devices_stmt( start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, - json_quotable_device_ids, + json_quoted_entity_ids, + json_quoted_device_ids, ) # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quotable_entity_ids = list(entity_ids) + json_quoted_entity_ids = [json.dumps(entity_id) for entity_id in entity_ids] return entities_stmt( start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, + json_quoted_entity_ids, ) # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quotable_device_ids = list(device_ids) + json_quoted_device_ids = [json.dumps(device_id) for device_id in device_ids] return devices_stmt( start_day, end_day, event_types, - json_quotable_device_ids, + json_quoted_device_ids, ) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index da05aa02fff..e0a651c7972 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -3,10 +3,9 @@ from __future__ import annotations from datetime import datetime as dt -from sqlalchemy import lambda_stmt from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList -from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Select from homeassistant.components.recorder.db_schema import ( LAST_UPDATED_INDEX, @@ -29,32 +28,29 @@ def all_stmt( states_entity_filter: ClauseList | None = None, events_entity_filter: ClauseList | None = None, context_id: str | None = None, -) -> StatementLambdaElement: +) -> Select: """Generate a logbook query for all entities.""" - stmt = lambda_stmt( - lambda: select_events_without_states(start_day, end_day, event_types) - ) + stmt = select_events_without_states(start_day, end_day, event_types) if context_id is not None: # Once all the old `state_changed` events # are gone from the database remove the # _legacy_select_events_context_id() - stmt += lambda s: s.where(Events.context_id == context_id).union_all( + stmt = stmt.where(Events.context_id == context_id).union_all( _states_query_for_context_id(start_day, end_day, context_id), legacy_select_events_context_id(start_day, end_day, context_id), ) else: if events_entity_filter is not None: - stmt += lambda s: s.where(events_entity_filter) + stmt = stmt.where(events_entity_filter) if states_entity_filter is not None: - stmt += lambda s: s.union_all( + stmt = stmt.union_all( _states_query_for_all(start_day, end_day).where(states_entity_filter) ) else: - stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) + stmt = stmt.union_all(_states_query_for_all(start_day, end_day)) - stmt += lambda s: s.order_by(Events.time_fired) - return stmt + return stmt.order_by(Events.time_fired) def _states_query_for_all(start_day: dt, end_day: dt) -> Query: diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index f750c552bc4..cbe766fb02c 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -4,11 +4,10 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt -from sqlalchemy import lambda_stmt, select +from sqlalchemy import select from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList -from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import CTE, CompoundSelect +from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select from homeassistant.components.recorder.db_schema import ( DEVICE_ID_IN_EVENT, @@ -31,11 +30,11 @@ def _select_device_id_context_ids_sub_query( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quotable_device_ids: list[str], + json_quoted_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple devices.""" inner = select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quotable_device_ids) + apply_event_device_id_matchers(json_quoted_device_ids) ) return select(inner.c.context_id).group_by(inner.c.context_id) @@ -45,14 +44,14 @@ def _apply_devices_context_union( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quotable_device_ids: list[str], + json_quoted_device_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the device context ids and a query to find linked row.""" devices_cte: CTE = _select_device_id_context_ids_sub_query( start_day, end_day, event_types, - json_quotable_device_ids, + json_quoted_device_ids, ).cte() return query.union_all( apply_events_context_hints( @@ -72,25 +71,22 @@ def devices_stmt( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quotable_device_ids: list[str], -) -> StatementLambdaElement: + json_quoted_device_ids: list[str], +) -> Select: """Generate a logbook query for multiple devices.""" - stmt = lambda_stmt( - lambda: _apply_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quotable_device_ids) - ), - start_day, - end_day, - event_types, - json_quotable_device_ids, - ).order_by(Events.time_fired) - ) - return stmt + return _apply_devices_context_union( + select_events_without_states(start_day, end_day, event_types).where( + apply_event_device_id_matchers(json_quoted_device_ids) + ), + start_day, + end_day, + event_types, + json_quoted_device_ids, + ).order_by(Events.time_fired) def apply_event_device_id_matchers( - json_quotable_device_ids: Iterable[str], + json_quoted_device_ids: Iterable[str], ) -> ClauseList: """Create matchers for the device_ids in the event_data.""" - return DEVICE_ID_IN_EVENT.in_(json_quotable_device_ids) + return DEVICE_ID_IN_EVENT.in_(json_quoted_device_ids) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 4ef96c100d7..4d250fbb0f1 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -5,10 +5,9 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import lambda_stmt, select, union_all +from sqlalchemy import select, union_all from sqlalchemy.orm import Query -from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import CTE, CompoundSelect +from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select from homeassistant.components.recorder.db_schema import ( ENTITY_ID_IN_EVENT, @@ -36,12 +35,12 @@ def _select_entities_context_ids_sub_query( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], + json_quoted_entity_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities.""" union = union_all( select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quotable_entity_ids) + apply_event_entity_id_matchers(json_quoted_entity_ids) ), apply_entities_hints(select(States.context_id)) .filter((States.last_updated > start_day) & (States.last_updated < end_day)) @@ -56,7 +55,7 @@ def _apply_entities_context_union( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], + json_quoted_entity_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the entity and device context ids and a query to find linked row.""" entities_cte: CTE = _select_entities_context_ids_sub_query( @@ -64,7 +63,7 @@ def _apply_entities_context_union( end_day, event_types, entity_ids, - json_quotable_entity_ids, + json_quoted_entity_ids, ).cte() # We used to optimize this to exclude rows we already in the union with # a States.entity_id.not_in(entity_ids) but that made the @@ -91,21 +90,19 @@ def entities_stmt( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], -) -> StatementLambdaElement: + json_quoted_entity_ids: list[str], +) -> Select: """Generate a logbook query for multiple entities.""" - return lambda_stmt( - lambda: _apply_entities_context_union( - select_events_without_states(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quotable_entity_ids) - ), - start_day, - end_day, - event_types, - entity_ids, - json_quotable_entity_ids, - ).order_by(Events.time_fired) - ) + return _apply_entities_context_union( + select_events_without_states(start_day, end_day, event_types).where( + apply_event_entity_id_matchers(json_quoted_entity_ids) + ), + start_day, + end_day, + event_types, + entity_ids, + json_quoted_entity_ids, + ).order_by(Events.time_fired) def states_query_for_entity_ids( @@ -118,12 +115,12 @@ def states_query_for_entity_ids( def apply_event_entity_id_matchers( - json_quotable_entity_ids: Iterable[str], + json_quoted_entity_ids: Iterable[str], ) -> sqlalchemy.or_: """Create matchers for the entity_id in the event_data.""" - return ENTITY_ID_IN_EVENT.in_( - json_quotable_entity_ids - ) | OLD_ENTITY_ID_IN_EVENT.in_(json_quotable_entity_ids) + return ENTITY_ID_IN_EVENT.in_(json_quoted_entity_ids) | OLD_ENTITY_ID_IN_EVENT.in_( + json_quoted_entity_ids + ) def apply_entities_hints(query: Query) -> Query: diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 591918dd653..8b8051e2966 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -5,10 +5,9 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import lambda_stmt, select, union_all +from sqlalchemy import select, union_all from sqlalchemy.orm import Query -from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import CTE, CompoundSelect +from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select from homeassistant.components.recorder.db_schema import EventData, Events, States @@ -33,14 +32,14 @@ def _select_entities_device_id_context_ids_sub_query( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], - json_quotable_device_ids: list[str], + json_quoted_entity_ids: list[str], + json_quoted_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities and multiple devices.""" union = union_all( select_events_context_id_subquery(start_day, end_day, event_types).where( _apply_event_entity_id_device_id_matchers( - json_quotable_entity_ids, json_quotable_device_ids + json_quoted_entity_ids, json_quoted_device_ids ) ), apply_entities_hints(select(States.context_id)) @@ -56,16 +55,16 @@ def _apply_entities_devices_context_union( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], - json_quotable_device_ids: list[str], + json_quoted_entity_ids: list[str], + json_quoted_device_ids: list[str], ) -> CompoundSelect: devices_entities_cte: CTE = _select_entities_device_id_context_ids_sub_query( start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, - json_quotable_device_ids, + json_quoted_entity_ids, + json_quoted_device_ids, ).cte() # We used to optimize this to exclude rows we already in the union with # a States.entity_id.not_in(entity_ids) but that made the @@ -92,32 +91,30 @@ def entities_devices_stmt( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], - json_quotable_device_ids: list[str], -) -> StatementLambdaElement: + json_quoted_entity_ids: list[str], + json_quoted_device_ids: list[str], +) -> Select: """Generate a logbook query for multiple entities.""" - stmt = lambda_stmt( - lambda: _apply_entities_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( - _apply_event_entity_id_device_id_matchers( - json_quotable_entity_ids, json_quotable_device_ids - ) - ), - start_day, - end_day, - event_types, - entity_ids, - json_quotable_entity_ids, - json_quotable_device_ids, - ).order_by(Events.time_fired) - ) + stmt = _apply_entities_devices_context_union( + select_events_without_states(start_day, end_day, event_types).where( + _apply_event_entity_id_device_id_matchers( + json_quoted_entity_ids, json_quoted_device_ids + ) + ), + start_day, + end_day, + event_types, + entity_ids, + json_quoted_entity_ids, + json_quoted_device_ids, + ).order_by(Events.time_fired) return stmt def _apply_event_entity_id_device_id_matchers( - json_quotable_entity_ids: Iterable[str], json_quotable_device_ids: Iterable[str] + json_quoted_entity_ids: Iterable[str], json_quoted_device_ids: Iterable[str] ) -> sqlalchemy.or_: """Create matchers for the device_id and entity_id in the event_data.""" return apply_event_entity_id_matchers( - json_quotable_entity_ids - ) | apply_event_device_id_matchers(json_quotable_device_ids) + json_quoted_entity_ids + ) | apply_event_device_id_matchers(json_quoted_device_ids) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index e1eca282a3a..1238b63f3c9 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -9,13 +9,11 @@ import logging import time from typing import Any, cast -from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select +from sqlalchemy import Column, Text, and_, func, or_, select from sqlalchemy.engine.row import Row -from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal -from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import Subquery +from sqlalchemy.sql.selectable import Select, Subquery from homeassistant.components import recorder from homeassistant.components.websocket_api.const import ( @@ -34,7 +32,7 @@ from .models import ( process_timestamp_to_utc_isoformat, row_to_compressed_state, ) -from .util import execute_stmt_lambda_element, session_scope +from .util import execute_stmt, session_scope # mypy: allow-untyped-defs, no-check-untyped-defs @@ -114,22 +112,18 @@ def _schema_version(hass: HomeAssistant) -> int: return recorder.get_instance(hass).schema_version -def lambda_stmt_and_join_attributes( +def stmt_and_join_attributes( schema_version: int, no_attributes: bool, include_last_changed: bool = True -) -> tuple[StatementLambdaElement, bool]: - """Return the lambda_stmt and if StateAttributes should be joined. - - Because these are lambda_stmt the values inside the lambdas need - to be explicitly written out to avoid caching the wrong values. - """ +) -> tuple[Select, bool]: + """Return the stmt and if StateAttributes should be joined.""" # If no_attributes was requested we do the query # without the attributes fields and do not join the # state_attributes table if no_attributes: if include_last_changed: - return lambda_stmt(lambda: select(*QUERY_STATE_NO_ATTR)), False + return select(*QUERY_STATE_NO_ATTR), False return ( - lambda_stmt(lambda: select(*QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), + select(*QUERY_STATE_NO_ATTR_NO_LAST_CHANGED), False, ) # If we in the process of migrating schema we do @@ -138,19 +132,19 @@ def lambda_stmt_and_join_attributes( if schema_version < 25: if include_last_changed: return ( - lambda_stmt(lambda: select(*QUERY_STATES_PRE_SCHEMA_25)), + select(*QUERY_STATES_PRE_SCHEMA_25), False, ) return ( - lambda_stmt(lambda: select(*QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED)), + select(*QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED), False, ) # Finally if no migration is in progress and no_attributes # was not requested, we query both attributes columns and # join state_attributes if include_last_changed: - return lambda_stmt(lambda: select(*QUERY_STATES)), True - return lambda_stmt(lambda: select(*QUERY_STATES_NO_LAST_CHANGED)), True + return select(*QUERY_STATES), True + return select(*QUERY_STATES_NO_LAST_CHANGED), True def get_significant_states( @@ -182,7 +176,7 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Query) -> Query: +def _ignore_domains_filter(query: Select) -> Select: """Add a filter to ignore domains we do not fetch history for.""" return query.filter( and_( @@ -202,9 +196,9 @@ def _significant_states_stmt( filters: Filters | None, significant_changes_only: bool, no_attributes: bool, -) -> StatementLambdaElement: +) -> Select: """Query the database for significant state changes.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( @@ -213,11 +207,11 @@ def _significant_states_stmt( and significant_changes_only and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS ): - stmt += lambda q: q.filter( + stmt = stmt.filter( (States.last_changed == States.last_updated) | States.last_changed.is_(None) ) elif significant_changes_only: - stmt += lambda q: q.filter( + stmt = stmt.filter( or_( *[ States.entity_id.like(entity_domain) @@ -231,25 +225,22 @@ def _significant_states_stmt( ) if entity_ids: - stmt += lambda q: q.filter(States.entity_id.in_(entity_ids)) + stmt = stmt.filter(States.entity_id.in_(entity_ids)) else: - stmt += _ignore_domains_filter + stmt = _ignore_domains_filter(stmt) if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt = stmt.add_criteria( - lambda q: q.filter(entity_filter), track_on=[filters] - ) + stmt = stmt.filter(entity_filter) - stmt += lambda q: q.filter(States.last_updated > start_time) + stmt = stmt.filter(States.last_updated > start_time) if end_time: - stmt += lambda q: q.filter(States.last_updated < end_time) + stmt = stmt.filter(States.last_updated < end_time) if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - stmt += lambda q: q.order_by(States.entity_id, States.last_updated) - return stmt + return stmt.order_by(States.entity_id, States.last_updated) def get_significant_states_with_session( @@ -286,9 +277,7 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, ) - states = execute_stmt_lambda_element( - session, stmt, None if entity_ids else start_time, end_time - ) + states = execute_stmt(session, stmt, None if entity_ids else start_time, end_time) return _sorted_states_to_dict( hass, session, @@ -340,28 +329,28 @@ def _state_changed_during_period_stmt( no_attributes: bool, descending: bool, limit: int | None, -) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( +) -> Select: + stmt, join_attributes = stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=False ) - stmt += lambda q: q.filter( + stmt = stmt.filter( ((States.last_changed == States.last_updated) | States.last_changed.is_(None)) & (States.last_updated > start_time) ) if end_time: - stmt += lambda q: q.filter(States.last_updated < end_time) + stmt = stmt.filter(States.last_updated < end_time) if entity_id: - stmt += lambda q: q.filter(States.entity_id == entity_id) + stmt = stmt.filter(States.entity_id == entity_id) if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) if descending: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()) + stmt = stmt.order_by(States.entity_id, States.last_updated.desc()) else: - stmt += lambda q: q.order_by(States.entity_id, States.last_updated) + stmt = stmt.order_by(States.entity_id, States.last_updated) if limit: - stmt += lambda q: q.limit(limit) + stmt = stmt.limit(limit) return stmt @@ -389,7 +378,7 @@ def state_changes_during_period( descending, limit, ) - states = execute_stmt_lambda_element( + states = execute_stmt( session, stmt, None if entity_id else start_time, end_time ) return cast( @@ -407,23 +396,22 @@ def state_changes_during_period( def _get_last_state_changes_stmt( schema_version: int, number_of_states: int, entity_id: str | None -) -> StatementLambdaElement: - stmt, join_attributes = lambda_stmt_and_join_attributes( +) -> Select: + stmt, join_attributes = stmt_and_join_attributes( schema_version, False, include_last_changed=False ) - stmt += lambda q: q.filter( + stmt = stmt.filter( (States.last_changed == States.last_updated) | States.last_changed.is_(None) ) if entity_id: - stmt += lambda q: q.filter(States.entity_id == entity_id) + stmt = stmt.filter(States.entity_id == entity_id) if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()).limit( + return stmt.order_by(States.entity_id, States.last_updated.desc()).limit( number_of_states ) - return stmt def get_last_state_changes( @@ -438,7 +426,7 @@ def get_last_state_changes( stmt = _get_last_state_changes_stmt( _schema_version(hass), number_of_states, entity_id ) - states = list(execute_stmt_lambda_element(session, stmt)) + states = list(execute_stmt(session, stmt)) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -458,14 +446,14 @@ def _get_states_for_entites_stmt( utc_point_in_time: datetime, entity_ids: list[str], no_attributes: bool, -) -> StatementLambdaElement: +) -> Select: """Baked query to get states for specific entities.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - stmt += lambda q: q.where( + stmt = stmt.where( States.state_id == ( select(func.max(States.state_id).label("max_state_id")) @@ -479,7 +467,7 @@ def _get_states_for_entites_stmt( ).c.max_state_id ) if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) return stmt @@ -510,9 +498,9 @@ def _get_states_for_all_stmt( utc_point_in_time: datetime, filters: Filters | None, no_attributes: bool, -) -> StatementLambdaElement: +) -> Select: """Baked query to get states for all entities.""" - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We did not get an include-list of entities, query all states in the inner @@ -522,7 +510,7 @@ def _get_states_for_all_stmt( most_recent_states_by_date = _generate_most_recent_states_by_date( run_start, utc_point_in_time ) - stmt += lambda q: q.where( + stmt = stmt.where( States.state_id == ( select(func.max(States.state_id).label("max_state_id")) @@ -538,12 +526,12 @@ def _get_states_for_all_stmt( .subquery() ).c.max_state_id, ) - stmt += _ignore_domains_filter + stmt = _ignore_domains_filter(stmt) if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) + stmt = stmt.filter(entity_filter) if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) return stmt @@ -561,7 +549,7 @@ def _get_rows_with_session( """Return the states at a specific point in time.""" schema_version = _schema_version(hass) if entity_ids and len(entity_ids) == 1: - return execute_stmt_lambda_element( + return execute_stmt( session, _get_single_entity_states_stmt( schema_version, utc_point_in_time, entity_ids[0], no_attributes @@ -586,7 +574,7 @@ def _get_rows_with_session( schema_version, run.start, utc_point_in_time, filters, no_attributes ) - return execute_stmt_lambda_element(session, stmt) + return execute_stmt(session, stmt) def _get_single_entity_states_stmt( @@ -594,14 +582,14 @@ def _get_single_entity_states_stmt( utc_point_in_time: datetime, entity_id: str, no_attributes: bool = False, -) -> StatementLambdaElement: +) -> Select: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = lambda_stmt_and_join_attributes( + stmt, join_attributes = stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) - stmt += ( - lambda q: q.filter( + stmt = ( + stmt.filter( States.last_updated < utc_point_in_time, States.entity_id == entity_id, ) @@ -609,7 +597,7 @@ def _get_single_entity_states_stmt( .limit(1) ) if join_attributes: - stmt += lambda q: q.outerjoin( + stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) return stmt diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 26221aa199b..8d314830ec4 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -14,13 +14,12 @@ import re from statistics import mean from typing import TYPE_CHECKING, Any, Literal, overload -from sqlalchemy import bindparam, func, lambda_stmt, select +from sqlalchemy import bindparam, func, select from sqlalchemy.engine.row import Row from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal_column, true -from sqlalchemy.sql.lambdas import StatementLambdaElement -from sqlalchemy.sql.selectable import Subquery +from sqlalchemy.sql.selectable import Select, Subquery import voluptuous as vol from homeassistant.const import ( @@ -50,12 +49,7 @@ from .models import ( process_timestamp, process_timestamp_to_utc_isoformat, ) -from .util import ( - execute, - execute_stmt_lambda_element, - retryable_database_job, - session_scope, -) +from .util import execute, execute_stmt, retryable_database_job, session_scope if TYPE_CHECKING: from . import Recorder @@ -480,10 +474,10 @@ def delete_statistics_meta_duplicates(session: Session) -> None: def _compile_hourly_statistics_summary_mean_stmt( start_time: datetime, end_time: datetime -) -> StatementLambdaElement: +) -> Select: """Generate the summary mean statement for hourly statistics.""" - return lambda_stmt( - lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN) + return ( + select(*QUERY_STATISTICS_SUMMARY_MEAN) .filter(StatisticsShortTerm.start >= start_time) .filter(StatisticsShortTerm.start < end_time) .group_by(StatisticsShortTerm.metadata_id) @@ -506,7 +500,7 @@ def compile_hourly_statistics( # Compute last hour's average, min, max summary: dict[str, StatisticData] = {} stmt = _compile_hourly_statistics_summary_mean_stmt(start_time, end_time) - stats = execute_stmt_lambda_element(session, stmt) + stats = execute_stmt(session, stmt) if stats: for stat in stats: @@ -688,17 +682,17 @@ def _generate_get_metadata_stmt( statistic_ids: list[str] | tuple[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, -) -> StatementLambdaElement: +) -> Select: """Generate a statement to fetch metadata.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) + stmt = select(*QUERY_STATISTIC_META) if statistic_ids is not None: - stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) + stmt = stmt.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: - stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) + stmt = stmt.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": - stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) + stmt = stmt.where(StatisticsMeta.has_mean == true()) elif statistic_type == "sum": - stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) + stmt = stmt.where(StatisticsMeta.has_sum == true()) return stmt @@ -720,7 +714,7 @@ def get_metadata_with_session( # Fetch metatadata from the database stmt = _generate_get_metadata_stmt(statistic_ids, statistic_type, statistic_source) - result = execute_stmt_lambda_element(session, stmt) + result = execute_stmt(session, stmt) if not result: return {} @@ -982,44 +976,30 @@ def _statistics_during_period_stmt( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, -) -> StatementLambdaElement: - """Prepare a database query for statistics during a given period. - - This prepares a lambda_stmt query, so we don't insert the parameters yet. - """ - stmt = lambda_stmt( - lambda: select(*QUERY_STATISTICS).filter(Statistics.start >= start_time) - ) +) -> Select: + """Prepare a database query for statistics during a given period.""" + stmt = select(*QUERY_STATISTICS).filter(Statistics.start >= start_time) if end_time is not None: - stmt += lambda q: q.filter(Statistics.start < end_time) + stmt = stmt.filter(Statistics.start < end_time) if metadata_ids: - stmt += lambda q: q.filter(Statistics.metadata_id.in_(metadata_ids)) - stmt += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) - return stmt + stmt = stmt.filter(Statistics.metadata_id.in_(metadata_ids)) + return stmt.order_by(Statistics.metadata_id, Statistics.start) def _statistics_during_period_stmt_short_term( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, -) -> StatementLambdaElement: - """Prepare a database query for short term statistics during a given period. - - This prepares a lambda_stmt query, so we don't insert the parameters yet. - """ - stmt = lambda_stmt( - lambda: select(*QUERY_STATISTICS_SHORT_TERM).filter( - StatisticsShortTerm.start >= start_time - ) +) -> Select: + """Prepare a database query for short term statistics during a given period.""" + stmt = select(*QUERY_STATISTICS_SHORT_TERM).filter( + StatisticsShortTerm.start >= start_time ) if end_time is not None: - stmt += lambda q: q.filter(StatisticsShortTerm.start < end_time) + stmt = stmt.filter(StatisticsShortTerm.start < end_time) if metadata_ids: - stmt += lambda q: q.filter(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - stmt += lambda q: q.order_by( - StatisticsShortTerm.metadata_id, StatisticsShortTerm.start - ) - return stmt + stmt = stmt.filter(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + return stmt.order_by(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start) def statistics_during_period( @@ -1054,7 +1034,7 @@ def statistics_during_period( else: table = Statistics stmt = _statistics_during_period_stmt(start_time, end_time, metadata_ids) - stats = execute_stmt_lambda_element(session, stmt) + stats = execute_stmt(session, stmt) if not stats: return {} @@ -1085,10 +1065,10 @@ def statistics_during_period( def _get_last_statistics_stmt( metadata_id: int, number_of_stats: int, -) -> StatementLambdaElement: +) -> Select: """Generate a statement for number_of_stats statistics for a given statistic_id.""" - return lambda_stmt( - lambda: select(*QUERY_STATISTICS) + return ( + select(*QUERY_STATISTICS) .filter_by(metadata_id=metadata_id) .order_by(Statistics.metadata_id, Statistics.start.desc()) .limit(number_of_stats) @@ -1098,10 +1078,10 @@ def _get_last_statistics_stmt( def _get_last_statistics_short_term_stmt( metadata_id: int, number_of_stats: int, -) -> StatementLambdaElement: +) -> Select: """Generate a statement for number_of_stats short term statistics for a given statistic_id.""" - return lambda_stmt( - lambda: select(*QUERY_STATISTICS_SHORT_TERM) + return ( + select(*QUERY_STATISTICS_SHORT_TERM) .filter_by(metadata_id=metadata_id) .order_by(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc()) .limit(number_of_stats) @@ -1127,7 +1107,7 @@ def _get_last_statistics( stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) else: stmt = _get_last_statistics_short_term_stmt(metadata_id, number_of_stats) - stats = execute_stmt_lambda_element(session, stmt) + stats = execute_stmt(session, stmt) if not stats: return {} @@ -1177,11 +1157,11 @@ def _generate_most_recent_statistic_row(metadata_ids: list[int]) -> Subquery: def _latest_short_term_statistics_stmt( metadata_ids: list[int], -) -> StatementLambdaElement: +) -> Select: """Create the statement for finding the latest short term stat rows.""" - stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) + stmt = select(*QUERY_STATISTICS_SHORT_TERM) most_recent_statistic_row = _generate_most_recent_statistic_row(metadata_ids) - stmt += lambda s: s.join( + return stmt.join( most_recent_statistic_row, ( StatisticsShortTerm.metadata_id # pylint: disable=comparison-with-callable @@ -1189,7 +1169,6 @@ def _latest_short_term_statistics_stmt( ) & (StatisticsShortTerm.start == most_recent_statistic_row.c.start_max), ) - return stmt def get_latest_short_term_statistics( @@ -1212,7 +1191,7 @@ def get_latest_short_term_statistics( if statistic_id in metadata ] stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = execute_stmt_lambda_element(session, stmt) + stats = execute_stmt(session, stmt) if not stats: return {} diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index c1fbc831987..7e183f7f64f 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -22,7 +22,6 @@ from sqlalchemy.engine.row import Row from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session -from sqlalchemy.sql.lambdas import StatementLambdaElement from typing_extensions import Concatenate, ParamSpec from homeassistant.core import HomeAssistant @@ -166,9 +165,9 @@ def execute( assert False # unreachable # pragma: no cover -def execute_stmt_lambda_element( +def execute_stmt( session: Session, - stmt: StatementLambdaElement, + query: Query, start_time: datetime | None = None, end_time: datetime | None = None, yield_per: int | None = DEFAULT_YIELD_STATES_ROWS, @@ -184,11 +183,12 @@ def execute_stmt_lambda_element( specific entities) since they are usually faster with .all(). """ - executed = session.execute(stmt) use_all = not start_time or ((end_time or dt_util.utcnow()) - start_time).days <= 1 for tryno in range(0, RETRIES): try: - return executed.all() if use_all else executed.yield_per(yield_per) # type: ignore[no-any-return] + if use_all: + return session.execute(query).all() # type: ignore[no-any-return] + return session.execute(query).yield_per(yield_per) # type: ignore[no-any-return] except SQLAlchemyError as err: _LOGGER.error("Error executing query: %s", err) if tryno == RETRIES - 1: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 8624719f951..97cf4a58b5c 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -9,7 +9,6 @@ from sqlalchemy import text from sqlalchemy.engine.result import ChunkedIteratorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.sql.elements import TextClause -from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder from homeassistant.components.recorder import history, util @@ -713,8 +712,8 @@ def test_build_mysqldb_conv(): @patch("homeassistant.components.recorder.util.QUERY_RETRY_WAIT", 0) -def test_execute_stmt_lambda_element(hass_recorder): - """Test executing with execute_stmt_lambda_element.""" +def test_execute_stmt(hass_recorder): + """Test executing with execute_stmt.""" hass = hass_recorder() instance = recorder.get_instance(hass) hass.states.set("sensor.on", "on") @@ -725,13 +724,15 @@ def test_execute_stmt_lambda_element(hass_recorder): one_week_from_now = now + timedelta(days=7) class MockExecutor: + + _calls = 0 + def __init__(self, stmt): - assert isinstance(stmt, StatementLambdaElement) - self.calls = 0 + """Init the mock.""" def all(self): - self.calls += 1 - if self.calls == 2: + MockExecutor._calls += 1 + if MockExecutor._calls == 2: return ["mock_row"] raise SQLAlchemyError @@ -740,24 +741,24 @@ def test_execute_stmt_lambda_element(hass_recorder): stmt = history._get_single_entity_states_stmt( instance.schema_version, dt_util.utcnow(), "sensor.on", False ) - rows = util.execute_stmt_lambda_element(session, stmt) + rows = util.execute_stmt(session, stmt) assert isinstance(rows, list) assert rows[0].state == new_state.state assert rows[0].entity_id == new_state.entity_id # Time window >= 2 days, we get a ChunkedIteratorResult - rows = util.execute_stmt_lambda_element(session, stmt, now, one_week_from_now) + rows = util.execute_stmt(session, stmt, now, one_week_from_now) assert isinstance(rows, ChunkedIteratorResult) row = next(rows) assert row.state == new_state.state assert row.entity_id == new_state.entity_id # Time window < 2 days, we get a list - rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) + rows = util.execute_stmt(session, stmt, now, tomorrow) assert isinstance(rows, list) assert rows[0].state == new_state.state assert rows[0].entity_id == new_state.entity_id with patch.object(session, "execute", MockExecutor): - rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) + rows = util.execute_stmt(session, stmt, now, tomorrow) assert rows == ["mock_row"] From 1331c75ec2dacd094fe0223f1457b69499239a35 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 8 Jun 2022 00:22:19 +0000 Subject: [PATCH 293/947] [ci skip] Translation update --- .../radiotherm/translations/bg.json | 22 ++++++ .../radiotherm/translations/et.json | 31 ++++++++ .../radiotherm/translations/ja.json | 31 ++++++++ .../radiotherm/translations/no.json | 31 ++++++++ .../components/scrape/translations/bg.json | 1 + .../components/scrape/translations/no.json | 73 +++++++++++++++++++ .../sensibo/translations/sensor.ca.json | 8 ++ .../sensibo/translations/sensor.de.json | 8 ++ .../sensibo/translations/sensor.el.json | 8 ++ .../sensibo/translations/sensor.et.json | 8 ++ .../sensibo/translations/sensor.fr.json | 8 ++ .../sensibo/translations/sensor.id.json | 8 ++ .../sensibo/translations/sensor.it.json | 8 ++ .../sensibo/translations/sensor.ja.json | 8 ++ .../sensibo/translations/sensor.nl.json | 8 ++ .../sensibo/translations/sensor.no.json | 8 ++ .../sensibo/translations/sensor.pl.json | 8 ++ .../sensibo/translations/sensor.pt-BR.json | 8 ++ .../sensibo/translations/sensor.zh-Hant.json | 8 ++ .../components/skybell/translations/no.json | 21 ++++++ .../switch_as_x/translations/cs.json | 2 +- 21 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/radiotherm/translations/bg.json create mode 100644 homeassistant/components/radiotherm/translations/et.json create mode 100644 homeassistant/components/radiotherm/translations/ja.json create mode 100644 homeassistant/components/radiotherm/translations/no.json create mode 100644 homeassistant/components/scrape/translations/no.json create mode 100644 homeassistant/components/sensibo/translations/sensor.ca.json create mode 100644 homeassistant/components/sensibo/translations/sensor.de.json create mode 100644 homeassistant/components/sensibo/translations/sensor.el.json create mode 100644 homeassistant/components/sensibo/translations/sensor.et.json create mode 100644 homeassistant/components/sensibo/translations/sensor.fr.json create mode 100644 homeassistant/components/sensibo/translations/sensor.id.json create mode 100644 homeassistant/components/sensibo/translations/sensor.it.json create mode 100644 homeassistant/components/sensibo/translations/sensor.ja.json create mode 100644 homeassistant/components/sensibo/translations/sensor.nl.json create mode 100644 homeassistant/components/sensibo/translations/sensor.no.json create mode 100644 homeassistant/components/sensibo/translations/sensor.pl.json create mode 100644 homeassistant/components/sensibo/translations/sensor.pt-BR.json create mode 100644 homeassistant/components/sensibo/translations/sensor.zh-Hant.json create mode 100644 homeassistant/components/skybell/translations/no.json diff --git a/homeassistant/components/radiotherm/translations/bg.json b/homeassistant/components/radiotherm/translations/bg.json new file mode 100644 index 00000000000..e588b6014ae --- /dev/null +++ b/homeassistant/components/radiotherm/translations/bg.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/et.json b/homeassistant/components/radiotherm/translations/et.json new file mode 100644 index 00000000000..f8a6a7303ab --- /dev/null +++ b/homeassistant/components/radiotherm/translations/et.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus", + "unknown": "Ootamatu t\u00f5rge" + }, + "flow_title": "{name} {model} ( {host} )", + "step": { + "confirm": { + "description": "Kas seadistada {name}{model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Temperatuuri reguleerimisel m\u00e4\u00e4ra p\u00fcsiv hoidmine." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/ja.json b/homeassistant/components/radiotherm/translations/ja.json new file mode 100644 index 00000000000..b792d0a1c9b --- /dev/null +++ b/homeassistant/components/radiotherm/translations/ja.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", + "unknown": "\u4e88\u671f\u3057\u306a\u3044\u30a8\u30e9\u30fc" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "{name} {model} ({host}) \u306e\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3092\u884c\u3044\u307e\u3059\u304b\uff1f" + }, + "user": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "\u6e29\u5ea6\u3092\u8abf\u6574\u3059\u308b\u3068\u304d\u306f\u3001\u6c38\u7d9a\u7684\u306a\u30db\u30fc\u30eb\u30c9(Permanent hold)\u3092\u8a2d\u5b9a\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/no.json b/homeassistant/components/radiotherm/translations/no.json new file mode 100644 index 00000000000..fc05e672cbe --- /dev/null +++ b/homeassistant/components/radiotherm/translations/no.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "unknown": "Uventet feil" + }, + "flow_title": "{name} {model} ( {host} )", + "step": { + "confirm": { + "description": "Vil du konfigurere {name} {model} ( {host} )?" + }, + "user": { + "data": { + "host": "Vert" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Still inn et permanent hold n\u00e5r du justerer temperaturen." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/bg.json b/homeassistant/components/scrape/translations/bg.json index 164dd477cb5..f22c3fd3b26 100644 --- a/homeassistant/components/scrape/translations/bg.json +++ b/homeassistant/components/scrape/translations/bg.json @@ -24,6 +24,7 @@ "attribute": "\u0410\u0442\u0440\u0438\u0431\u0443\u0442", "authentication": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "index": "\u0418\u043d\u0434\u0435\u043a\u0441", + "name": "\u0418\u043c\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "unit_of_measurement": "\u0415\u0434\u0438\u043d\u0438\u0446\u0430 \u0437\u0430 \u0438\u0437\u043c\u0435\u0440\u0432\u0430\u043d\u0435" } diff --git a/homeassistant/components/scrape/translations/no.json b/homeassistant/components/scrape/translations/no.json new file mode 100644 index 00000000000..6738c8a630a --- /dev/null +++ b/homeassistant/components/scrape/translations/no.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "step": { + "user": { + "data": { + "attribute": "Attributt", + "authentication": "Godkjenning", + "device_class": "Enhetsklasse", + "headers": "Overskrifter", + "index": "Indeks", + "name": "Navn", + "password": "Passord", + "resource": "Ressurs", + "select": "Velg", + "state_class": "Statsklasse", + "unit_of_measurement": "M\u00e5leenhet", + "username": "Brukernavn", + "value_template": "Verdimal", + "verify_ssl": "Verifisere SSL-sertifikat" + }, + "data_description": { + "attribute": "F\u00e5 verdien av et attributt p\u00e5 den valgte taggen", + "authentication": "Type HTTP-godkjenning. Enten grunnleggende eller ufullstendig", + "device_class": "Typen/klassen til sensoren for \u00e5 angi ikonet i frontend", + "headers": "Overskrifter som skal brukes for nettforesp\u00f8rselen", + "index": "Definerer hvilke av elementene som returneres av CSS-velgeren som skal brukes", + "resource": "URL-en til nettstedet som inneholder verdien", + "select": "Definerer hvilken tag som skal s\u00f8kes etter. Sjekk Beautifulsoup CSS-velgere for detaljer", + "state_class": "Sensorens state_class", + "value_template": "Definerer en mal for \u00e5 f\u00e5 tilstanden til sensoren", + "verify_ssl": "Aktiverer/deaktiverer verifisering av SSL/TLS-sertifikat, for eksempel hvis det er selvsignert" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "Attributt", + "authentication": "Godkjenning", + "device_class": "Enhetsklasse", + "headers": "Overskrifter", + "index": "Indeks", + "name": "Navn", + "password": "Passord", + "resource": "Ressurs", + "select": "Velg", + "state_class": "Statsklasse", + "unit_of_measurement": "M\u00e5leenhet", + "username": "Brukernavn", + "value_template": "Verdimal", + "verify_ssl": "Verifisere SSL-sertifikat" + }, + "data_description": { + "attribute": "F\u00e5 verdien av et attributt p\u00e5 den valgte taggen", + "authentication": "Type HTTP-godkjenning. Enten grunnleggende eller ufullstendig", + "device_class": "Typen/klassen til sensoren for \u00e5 angi ikonet i frontend", + "headers": "Overskrifter som skal brukes for nettforesp\u00f8rselen", + "index": "Definerer hvilke av elementene som returneres av CSS-velgeren som skal brukes", + "resource": "URL-en til nettstedet som inneholder verdien", + "select": "Definerer hvilken tag som skal s\u00f8kes etter. Sjekk Beautifulsoup CSS-velgere for detaljer", + "state_class": "Sensorens state_class", + "value_template": "Definerer en mal for \u00e5 f\u00e5 tilstanden til sensoren", + "verify_ssl": "Aktiverer/deaktiverer verifisering av SSL/TLS-sertifikat, for eksempel hvis det er selvsignert" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.ca.json b/homeassistant/components/sensibo/translations/sensor.ca.json new file mode 100644 index 00000000000..1b251c70f7d --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.ca.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensible" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.de.json b/homeassistant/components/sensibo/translations/sensor.de.json new file mode 100644 index 00000000000..ab456f555af --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.de.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Empfindlich" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.el.json b/homeassistant/components/sensibo/translations/sensor.el.json new file mode 100644 index 00000000000..b4e595db882 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.el.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u039a\u03b1\u03bd\u03bf\u03bd\u03b9\u03ba\u03cc", + "s": "\u0395\u03c5\u03b1\u03af\u03c3\u03b8\u03b7\u03c4\u03bf" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.et.json b/homeassistant/components/sensibo/translations/sensor.et.json new file mode 100644 index 00000000000..44bdfe9183a --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.et.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Tavaline", + "s": "Tundlik" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.fr.json b/homeassistant/components/sensibo/translations/sensor.fr.json new file mode 100644 index 00000000000..1b251c70f7d --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.fr.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensible" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.id.json b/homeassistant/components/sensibo/translations/sensor.id.json new file mode 100644 index 00000000000..54a0554ce41 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.id.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensitif" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.it.json b/homeassistant/components/sensibo/translations/sensor.it.json new file mode 100644 index 00000000000..85550808ee9 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.it.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normale", + "s": "Sensibile" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.ja.json b/homeassistant/components/sensibo/translations/sensor.ja.json new file mode 100644 index 00000000000..2921289358f --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.ja.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u30ce\u30fc\u30de\u30eb", + "s": "\u30bb\u30f3\u30b7\u30c6\u30a3\u30d6" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.nl.json b/homeassistant/components/sensibo/translations/sensor.nl.json new file mode 100644 index 00000000000..bd7b06dc940 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.nl.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normaal", + "s": "Gevoelig" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.no.json b/homeassistant/components/sensibo/translations/sensor.no.json new file mode 100644 index 00000000000..e3de20b4636 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.no.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Vanlig", + "s": "F\u00f8lsom" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.pl.json b/homeassistant/components/sensibo/translations/sensor.pl.json new file mode 100644 index 00000000000..1b8ed8a8ed7 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.pl.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "normalna", + "s": "wysoka" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.pt-BR.json b/homeassistant/components/sensibo/translations/sensor.pt-BR.json new file mode 100644 index 00000000000..91d092a1760 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.pt-BR.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sens\u00edvel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.zh-Hant.json b/homeassistant/components/sensibo/translations/sensor.zh-Hant.json new file mode 100644 index 00000000000..5144fdcc699 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.zh-Hant.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u6b63\u5e38", + "s": "\u654f\u611f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/no.json b/homeassistant/components/skybell/translations/no.json new file mode 100644 index 00000000000..8701b272f12 --- /dev/null +++ b/homeassistant/components/skybell/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "Passord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch_as_x/translations/cs.json b/homeassistant/components/switch_as_x/translations/cs.json index c521a79e1d9..b7dc53680d9 100644 --- a/homeassistant/components/switch_as_x/translations/cs.json +++ b/homeassistant/components/switch_as_x/translations/cs.json @@ -8,5 +8,5 @@ } } }, - "title": "Zm\u011bna typy vyp\u00edna\u010de" + "title": "Zm\u011bna typu vyp\u00edna\u010de" } \ No newline at end of file From 329595bf73af5c4bacaf0581ca0ad58f689149f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 7 Jun 2022 14:26:06 -1000 Subject: [PATCH 294/947] Make radiotherm hold mode a switch (#73104) --- .coveragerc | 3 + .../components/radiotherm/__init__.py | 48 +++++++--- .../components/radiotherm/climate.py | 92 +++---------------- .../components/radiotherm/config_flow.py | 38 +------- homeassistant/components/radiotherm/const.py | 2 - .../components/radiotherm/coordinator.py | 15 +-- homeassistant/components/radiotherm/entity.py | 44 +++++++++ homeassistant/components/radiotherm/switch.py | 65 +++++++++++++ homeassistant/components/radiotherm/util.py | 24 +++++ .../components/radiotherm/test_config_flow.py | 45 +-------- 10 files changed, 195 insertions(+), 181 deletions(-) create mode 100644 homeassistant/components/radiotherm/entity.py create mode 100644 homeassistant/components/radiotherm/switch.py create mode 100644 homeassistant/components/radiotherm/util.py diff --git a/.coveragerc b/.coveragerc index 5fa747267a5..3db90953f74 100644 --- a/.coveragerc +++ b/.coveragerc @@ -963,9 +963,12 @@ omit = homeassistant/components/radio_browser/__init__.py homeassistant/components/radio_browser/media_source.py homeassistant/components/radiotherm/__init__.py + homeassistant/components/radiotherm/entity.py homeassistant/components/radiotherm/climate.py homeassistant/components/radiotherm/coordinator.py homeassistant/components/radiotherm/data.py + homeassistant/components/radiotherm/switch.py + homeassistant/components/radiotherm/util.py homeassistant/components/rainbird/* homeassistant/components/raincloud/* homeassistant/components/rainmachine/__init__.py diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 9e389af6719..091d2bb8005 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -1,7 +1,9 @@ """The radiotherm component.""" from __future__ import annotations +from collections.abc import Coroutine from socket import timeout +from typing import Any, TypeVar from radiotherm.validate import RadiothermTstatError @@ -10,30 +12,46 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .const import CONF_HOLD_TEMP, DOMAIN +from .const import DOMAIN from .coordinator import RadioThermUpdateCoordinator from .data import async_get_init_data +from .util import async_set_time -PLATFORMS: list[Platform] = [Platform.CLIMATE] +PLATFORMS: list[Platform] = [Platform.CLIMATE, Platform.SWITCH] + +_T = TypeVar("_T") + + +async def _async_call_or_raise_not_ready( + coro: Coroutine[Any, Any, _T], host: str +) -> _T: + """Call a coro or raise ConfigEntryNotReady.""" + try: + return await coro + except RadiothermTstatError as ex: + msg = f"{host} was busy (invalid value returned): {ex}" + raise ConfigEntryNotReady(msg) from ex + except timeout as ex: + msg = f"{host} timed out waiting for a response: {ex}" + raise ConfigEntryNotReady(msg) from ex async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Radio Thermostat from a config entry.""" host = entry.data[CONF_HOST] - try: - init_data = await async_get_init_data(hass, host) - except RadiothermTstatError as ex: - raise ConfigEntryNotReady( - f"{host} was busy (invalid value returned): {ex}" - ) from ex - except timeout as ex: - raise ConfigEntryNotReady( - f"{host} timed out waiting for a response: {ex}" - ) from ex - - hold_temp = entry.options[CONF_HOLD_TEMP] - coordinator = RadioThermUpdateCoordinator(hass, init_data, hold_temp) + init_coro = async_get_init_data(hass, host) + init_data = await _async_call_or_raise_not_ready(init_coro, host) + coordinator = RadioThermUpdateCoordinator(hass, init_data) await coordinator.async_config_entry_first_refresh() + + # Only set the time if the thermostat is + # not in hold mode since setting the time + # clears the hold for some strange design + # choice + if not coordinator.data.tstat["hold"]: + time_coro = async_set_time(hass, init_data.tstat) + await _async_call_or_raise_not_ready(time_coro, host) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(_async_update_listener)) diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index 63ee93c9c84..3f8e87e74a4 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,7 +1,6 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" from __future__ import annotations -from functools import partial import logging from typing import Any @@ -27,19 +26,13 @@ from homeassistant.const import ( TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt as dt_util from . import DOMAIN -from .const import CONF_HOLD_TEMP from .coordinator import RadioThermUpdateCoordinator -from .data import RadioThermUpdate +from .entity import RadioThermostatEntity _LOGGER = logging.getLogger(__name__) @@ -92,10 +85,11 @@ PRESET_MODE_TO_CODE = { CODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_CODE.items()} -CODE_TO_HOLD_STATE = {0: False, 1: True} PARALLEL_UPDATES = 1 +CONF_HOLD_TEMP = "hold_temp" + def round_temp(temperature): """Round a temperature to the resolution of the thermostat. @@ -150,18 +144,17 @@ async def async_setup_platform( _LOGGER.error("No Radiotherm Thermostats detected") return - hold_temp: bool = config[CONF_HOLD_TEMP] for host in hosts: hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data={CONF_HOST: host, CONF_HOLD_TEMP: hold_temp}, + data={CONF_HOST: host}, ) ) -class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEntity): +class RadioThermostat(RadioThermostatEntity, ClimateEntity): """Representation of a Radio Thermostat.""" _attr_hvac_modes = OPERATION_LIST @@ -171,49 +164,22 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: """Initialize the thermostat.""" super().__init__(coordinator) - self.device = coordinator.init_data.tstat - self._attr_name = coordinator.init_data.name - self._hold_temp = coordinator.hold_temp - self._hold_set = False - self._attr_unique_id = coordinator.init_data.mac - self._attr_device_info = DeviceInfo( - name=coordinator.init_data.name, - model=coordinator.init_data.model, - manufacturer="Radio Thermostats", - sw_version=coordinator.init_data.fw_version, - connections={(dr.CONNECTION_NETWORK_MAC, coordinator.init_data.mac)}, - ) - self._is_model_ct80 = isinstance(self.device, radiotherm.thermostat.CT80) + self._attr_name = self.init_data.name + self._attr_unique_id = self.init_data.mac + self._attr_fan_modes = CT30_FAN_OPERATION_LIST self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) - self._process_data() - if not self._is_model_ct80: - self._attr_fan_modes = CT30_FAN_OPERATION_LIST + if not isinstance(self.device, radiotherm.thermostat.CT80): return self._attr_fan_modes = CT80_FAN_OPERATION_LIST self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._attr_preset_modes = PRESET_MODES - @property - def data(self) -> RadioThermUpdate: - """Returnt the last update.""" - return self.coordinator.data - - async def async_added_to_hass(self) -> None: - """Register callbacks.""" - # Set the time on the device. This shouldn't be in the - # constructor because it's a network call. We can't put it in - # update() because calling it will clear any temporary mode or - # temperature in the thermostat. So add it as a future job - # for the event loop to run. - self.hass.async_add_job(self.set_time) - await super().async_added_to_hass() - async def async_set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" if (code := FAN_MODE_TO_CODE.get(fan_mode)) is None: - raise HomeAssistantError(f"{fan_mode} is not a valid fan mode") + raise ValueError(f"{fan_mode} is not a valid fan mode") await self.hass.async_add_executor_job(self._set_fan_mode, code) self._attr_fan_mode = fan_mode self.async_write_ha_state() @@ -223,16 +189,11 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt """Turn fan on/off.""" self.device.fmode = code - @callback - def _handle_coordinator_update(self) -> None: - self._process_data() - return super()._handle_coordinator_update() - @callback def _process_data(self) -> None: """Update and validate the data from the thermostat.""" data = self.data.tstat - if self._is_model_ct80: + if isinstance(self.device, radiotherm.thermostat.CT80): self._attr_current_humidity = self.data.humidity self._attr_preset_mode = CODE_TO_PRESET_MODE[data["program_mode"]] # Map thermostat values into various STATE_ flags. @@ -242,7 +203,6 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt ATTR_FAN_ACTION: CODE_TO_FAN_STATE[data["fstate"]] } self._attr_hvac_mode = CODE_TO_TEMP_MODE[data["tmode"]] - self._hold_set = CODE_TO_HOLD_STATE[data["hold"]] if self.hvac_mode == HVACMode.OFF: self._attr_hvac_action = None else: @@ -264,15 +224,12 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return - hold_changed = kwargs.get("hold_changed", False) - await self.hass.async_add_executor_job( - partial(self._set_temperature, temperature, hold_changed) - ) + await self.hass.async_add_executor_job(self._set_temperature, temperature) self._attr_target_temperature = temperature self.async_write_ha_state() await self.coordinator.async_request_refresh() - def _set_temperature(self, temperature: int, hold_changed: bool) -> None: + def _set_temperature(self, temperature: int) -> None: """Set new target temperature.""" temperature = round_temp(temperature) if self.hvac_mode == HVACMode.COOL: @@ -285,26 +242,6 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt elif self.hvac_action == HVACAction.HEATING: self.device.t_heat = temperature - # Only change the hold if requested or if hold mode was turned - # on and we haven't set it yet. - if hold_changed or not self._hold_set: - if self._hold_temp: - self.device.hold = 1 - self._hold_set = True - else: - self.device.hold = 0 - - def set_time(self) -> None: - """Set device time.""" - # Calling this clears any local temperature override and - # reverts to the scheduled temperature. - now = dt_util.now() - self.device.time = { - "day": now.weekday(), - "hour": now.hour, - "minute": now.minute, - } - async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set operation mode (auto, cool, heat, off).""" await self.hass.async_add_executor_job(self._set_hvac_mode, hvac_mode) @@ -325,7 +262,7 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt async def async_set_preset_mode(self, preset_mode: str) -> None: """Set Preset mode (Home, Alternate, Away, Holiday).""" if preset_mode not in PRESET_MODES: - raise HomeAssistantError("{preset_mode} is not a valid preset_mode") + raise ValueError(f"{preset_mode} is not a valid preset_mode") await self.hass.async_add_executor_job(self._set_preset_mode, preset_mode) self._attr_preset_mode = preset_mode self.async_write_ha_state() @@ -333,4 +270,5 @@ class RadioThermostat(CoordinatorEntity[RadioThermUpdateCoordinator], ClimateEnt def _set_preset_mode(self, preset_mode: str) -> None: """Set Preset mode (Home, Alternate, Away, Holiday).""" + assert isinstance(self.device, radiotherm.thermostat.CT80) self.device.program_mode = PRESET_MODE_TO_CODE[preset_mode] diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index 45fee0f7fd9..030a3e6c022 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -11,11 +11,11 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.const import CONF_HOST -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError -from .const import CONF_HOLD_TEMP, DOMAIN +from .const import DOMAIN from .data import RadioThermInitData, async_get_init_data _LOGGER = logging.getLogger(__name__) @@ -68,7 +68,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=init_data.name, data={CONF_HOST: ip_address}, - options={CONF_HOLD_TEMP: False}, ) self._set_confirm_only() @@ -100,7 +99,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=init_data.name, data={CONF_HOST: import_info[CONF_HOST]}, - options={CONF_HOLD_TEMP: import_info[CONF_HOLD_TEMP]}, ) async def async_step_user( @@ -125,7 +123,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=init_data.name, data=user_input, - options={CONF_HOLD_TEMP: False}, ) return self.async_show_form( @@ -133,34 +130,3 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=vol.Schema({vol.Required(CONF_HOST): str}), errors=errors, ) - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for radiotherm.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init(self, user_input=None): - """Handle options flow.""" - if user_input is not None: - return self.async_create_entry(title="", data=user_input) - - return self.async_show_form( - step_id="init", - data_schema=vol.Schema( - { - vol.Optional( - CONF_HOLD_TEMP, - default=self.config_entry.options[CONF_HOLD_TEMP], - ): bool - } - ), - ) diff --git a/homeassistant/components/radiotherm/const.py b/homeassistant/components/radiotherm/const.py index 398747c6571..db097d40665 100644 --- a/homeassistant/components/radiotherm/const.py +++ b/homeassistant/components/radiotherm/const.py @@ -2,6 +2,4 @@ DOMAIN = "radiotherm" -CONF_HOLD_TEMP = "hold_temp" - TIMEOUT = 25 diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index 264a4a8d1fd..4afa2c0662b 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -20,12 +20,9 @@ UPDATE_INTERVAL = timedelta(seconds=15) class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): """DataUpdateCoordinator to gather data for radio thermostats.""" - def __init__( - self, hass: HomeAssistant, init_data: RadioThermInitData, hold_temp: bool - ) -> None: + def __init__(self, hass: HomeAssistant, init_data: RadioThermInitData) -> None: """Initialize DataUpdateCoordinator.""" self.init_data = init_data - self.hold_temp = hold_temp self._description = f"{init_data.name} ({init_data.host})" super().__init__( hass, @@ -39,10 +36,8 @@ class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): try: return await async_get_data(self.hass, self.init_data.tstat) except RadiothermTstatError as ex: - raise UpdateFailed( - f"{self._description} was busy (invalid value returned): {ex}" - ) from ex + msg = f"{self._description} was busy (invalid value returned): {ex}" + raise UpdateFailed(msg) from ex except timeout as ex: - raise UpdateFailed( - f"{self._description}) timed out waiting for a response: {ex}" - ) from ex + msg = f"{self._description}) timed out waiting for a response: {ex}" + raise UpdateFailed(msg) from ex diff --git a/homeassistant/components/radiotherm/entity.py b/homeassistant/components/radiotherm/entity.py new file mode 100644 index 00000000000..203d17a5dc2 --- /dev/null +++ b/homeassistant/components/radiotherm/entity.py @@ -0,0 +1,44 @@ +"""The radiotherm integration base entity.""" + +from abc import abstractmethod + +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import RadioThermUpdateCoordinator +from .data import RadioThermUpdate + + +class RadioThermostatEntity(CoordinatorEntity[RadioThermUpdateCoordinator]): + """Base class for radiotherm entities.""" + + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self.init_data = coordinator.init_data + self.device = coordinator.init_data.tstat + self._attr_device_info = DeviceInfo( + name=self.init_data.name, + model=self.init_data.model, + manufacturer="Radio Thermostats", + sw_version=self.init_data.fw_version, + connections={(dr.CONNECTION_NETWORK_MAC, self.init_data.mac)}, + ) + self._process_data() + + @property + def data(self) -> RadioThermUpdate: + """Returnt the last update.""" + return self.coordinator.data + + @callback + @abstractmethod + def _process_data(self) -> None: + """Update and validate the data from the thermostat.""" + + @callback + def _handle_coordinator_update(self) -> None: + self._process_data() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/radiotherm/switch.py b/homeassistant/components/radiotherm/switch.py new file mode 100644 index 00000000000..2cf0602a3fa --- /dev/null +++ b/homeassistant/components/radiotherm/switch.py @@ -0,0 +1,65 @@ +"""Support for radiotherm switches.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.switch import SwitchEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import RadioThermUpdateCoordinator +from .entity import RadioThermostatEntity + +PARALLEL_UPDATES = 1 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up switches for a radiotherm device.""" + coordinator: RadioThermUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([RadioThermHoldSwitch(coordinator)]) + + +class RadioThermHoldSwitch(RadioThermostatEntity, SwitchEntity): + """Provides radiotherm hold switch support.""" + + def __init__(self, coordinator: RadioThermUpdateCoordinator) -> None: + """Initialize the hold mode switch.""" + super().__init__(coordinator) + self._attr_name = f"{coordinator.init_data.name} Hold" + self._attr_unique_id = f"{coordinator.init_data.mac}_hold" + + @property + def icon(self) -> str: + """Return the icon for the switch.""" + return "mdi:timer-off" if self.is_on else "mdi:timer" + + @callback + def _process_data(self) -> None: + """Update and validate the data from the thermostat.""" + data = self.data.tstat + self._attr_is_on = bool(data["hold"]) + + def _set_hold(self, hold: bool) -> None: + """Set hold mode.""" + self.device.hold = int(hold) + + async def _async_set_hold(self, hold: bool) -> None: + """Set hold mode.""" + await self.hass.async_add_executor_job(self._set_hold, hold) + self._attr_is_on = hold + self.async_write_ha_state() + await self.coordinator.async_request_refresh() + + async def async_turn_on(self, **kwargs: Any) -> None: + """Enable permanent hold.""" + await self._async_set_hold(True) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Disable permanent hold.""" + await self._async_set_hold(False) diff --git a/homeassistant/components/radiotherm/util.py b/homeassistant/components/radiotherm/util.py new file mode 100644 index 00000000000..85b927d7935 --- /dev/null +++ b/homeassistant/components/radiotherm/util.py @@ -0,0 +1,24 @@ +"""Utils for radiotherm.""" +from __future__ import annotations + +from radiotherm.thermostat import CommonThermostat + +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + + +async def async_set_time(hass: HomeAssistant, device: CommonThermostat) -> None: + """Sync time to the thermostat.""" + await hass.async_add_executor_job(_set_time, device) + + +def _set_time(device: CommonThermostat) -> None: + """Set device time.""" + # Calling this clears any local temperature override and + # reverts to the scheduled temperature. + now = dt_util.now() + device.time = { + "day": now.weekday(), + "hour": now.hour, + "minute": now.minute, + } diff --git a/tests/components/radiotherm/test_config_flow.py b/tests/components/radiotherm/test_config_flow.py index bc729a27e1c..56a361404f8 100644 --- a/tests/components/radiotherm/test_config_flow.py +++ b/tests/components/radiotherm/test_config_flow.py @@ -7,7 +7,7 @@ from radiotherm.validate import RadiothermTstatError from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.components.radiotherm.const import CONF_HOLD_TEMP, DOMAIN +from homeassistant.components.radiotherm.const import DOMAIN from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry @@ -110,17 +110,12 @@ async def test_import(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.2.3.4", CONF_HOLD_TEMP: True}, + data={CONF_HOST: "1.2.3.4"}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "My Name" - assert result["data"] == { - CONF_HOST: "1.2.3.4", - } - assert result["options"] == { - CONF_HOLD_TEMP: True, - } + assert result["data"] == {CONF_HOST: "1.2.3.4"} assert len(mock_setup_entry.mock_calls) == 1 @@ -133,7 +128,7 @@ async def test_import_cannot_connect(hass): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: "1.2.3.4", CONF_HOLD_TEMP: True}, + data={CONF_HOST: "1.2.3.4"}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT @@ -268,35 +263,3 @@ async def test_user_unique_id_already_exists(hass): assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result2["reason"] == "already_configured" - - -async def test_options_flow(hass): - """Test config flow options.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: "1.2.3.4"}, - unique_id="aa:bb:cc:dd:ee:ff", - options={CONF_HOLD_TEMP: False}, - ) - - entry.add_to_hass(hass) - with patch( - "homeassistant.components.radiotherm.data.radiotherm.get_thermostat", - return_value=_mock_radiotherm(), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(entry.entry_id) - await hass.async_block_till_done() - assert await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={CONF_HOLD_TEMP: True} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert entry.options == {CONF_HOLD_TEMP: True} From f91aa33c5f7bc13ed031a95f946f70e11af1e2f3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 8 Jun 2022 07:02:44 +0200 Subject: [PATCH 295/947] Add FlowResultType enum to data entry flow (#72955) --- homeassistant/auth/__init__.py | 2 +- .../components/almond/config_flow.py | 2 +- homeassistant/components/auth/login_flow.py | 10 +- .../components/auth/mfa_setup_flow.py | 4 +- .../components/config/config_entries.py | 2 +- homeassistant/components/mqtt/discovery.py | 4 +- homeassistant/config_entries.py | 4 +- homeassistant/data_entry_flow.py | 94 +++++++++++-------- homeassistant/helpers/data_entry_flow.py | 2 +- .../helpers/schema_config_entry_flow.py | 2 +- pylint/plugins/hass_imports.py | 6 ++ 11 files changed, 79 insertions(+), 53 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index b3f57656dd7..23fdb775a8a 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -103,7 +103,7 @@ class AuthManagerFlowManager(data_entry_flow.FlowManager): """Return a user as result of login flow.""" flow = cast(LoginFlow, flow) - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result # we got final result diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index dfbdae219ca..11c883f4e0a 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -64,7 +64,7 @@ class AlmondFlowHandler( """Handle authorize step.""" result = await super().async_step_auth(user_input) - if result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP: + if result["type"] == data_entry_flow.FlowResultType.EXTERNAL_STEP: self.host = str(URL(result["url"]).with_path("me")) return result diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py index cd6a405d42c..b24da92afdd 100644 --- a/homeassistant/components/auth/login_flow.py +++ b/homeassistant/components/auth/login_flow.py @@ -52,7 +52,7 @@ flow for details. Progress the flow. Most flows will be 1 page, but could optionally add extra login challenges, like TFA. Once the flow has finished, the returned step will -have type RESULT_TYPE_CREATE_ENTRY and "result" key will contain an authorization code. +have type FlowResultType.CREATE_ENTRY and "result" key will contain an authorization code. The authorization code associated with an authorized user by default, it will associate with an credential if "type" set to "link_user" in "/auth/login_flow" @@ -123,13 +123,13 @@ class AuthProvidersView(HomeAssistantView): def _prepare_result_json(result): """Convert result to JSON.""" - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: data = result.copy() data.pop("result") data.pop("data") return data - if result["type"] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.FlowResultType.FORM: return result data = result.copy() @@ -154,11 +154,11 @@ class LoginFlowBaseView(HomeAssistantView): async def _async_flow_result_to_response(self, request, client_id, result): """Convert the flow result to a response.""" - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: # @log_invalid_auth does not work here since it returns HTTP 200. # We need to manually log failed login attempts. if ( - result["type"] == data_entry_flow.RESULT_TYPE_FORM + result["type"] == data_entry_flow.FlowResultType.FORM and (errors := result.get("errors")) and errors.get("base") in ( diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py index aa45cc1b028..e288fe33df7 100644 --- a/homeassistant/components/auth/mfa_setup_flow.py +++ b/homeassistant/components/auth/mfa_setup_flow.py @@ -129,11 +129,11 @@ def websocket_depose_mfa( def _prepare_result_json(result): """Convert result to JSON.""" - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: data = result.copy() return data - if result["type"] != data_entry_flow.RESULT_TYPE_FORM: + if result["type"] != data_entry_flow.FlowResultType.FORM: return result data = result.copy() diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 0a093ee4574..b1756b58c3e 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -143,7 +143,7 @@ class ConfigManagerEntryResourceReloadView(HomeAssistantView): def _prepare_config_flow_result_json(result, prepare_result_json): """Convert result to JSON.""" - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return prepare_result_json(result) data = result.copy() diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 8685c790fd2..3bbf8ef61ad 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -11,7 +11,7 @@ import time from homeassistant.const import CONF_DEVICE, CONF_PLATFORM from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import RESULT_TYPE_ABORT +from homeassistant.data_entry_flow import FlowResultType import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -305,7 +305,7 @@ async def async_start( # noqa: C901 ) if ( result - and result["type"] == RESULT_TYPE_ABORT + and result["type"] == FlowResultType.ABORT and result["reason"] in ("already_configured", "single_instance_allowed") ): diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index bb24b24a7fb..14900153ae4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -682,7 +682,7 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager): if not self._async_has_other_discovery_flows(flow.flow_id): persistent_notification.async_dismiss(self.hass, DISCOVERY_NOTIFICATION_ID) - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result # Check if config entry exists with unique ID. Unload it. @@ -1534,7 +1534,7 @@ class OptionsFlowManager(data_entry_flow.FlowManager): """ flow = cast(OptionsFlow, flow) - if result["type"] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] != data_entry_flow.FlowResultType.CREATE_ENTRY: return result entry = self.hass.config_entries.async_get_entry(flow.handler) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 714cea07044..abc8061c0d5 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -10,10 +10,27 @@ from typing import Any, TypedDict import voluptuous as vol +from .backports.enum import StrEnum from .core import HomeAssistant, callback from .exceptions import HomeAssistantError +from .helpers.frame import report from .util import uuid as uuid_util + +class FlowResultType(StrEnum): + """Result type for a data entry flow.""" + + FORM = "form" + CREATE_ENTRY = "create_entry" + ABORT = "abort" + EXTERNAL_STEP = "external" + EXTERNAL_STEP_DONE = "external_done" + SHOW_PROGRESS = "progress" + SHOW_PROGRESS_DONE = "progress_done" + MENU = "menu" + + +# RESULT_TYPE_* is deprecated, to be removed in 2022.9 RESULT_TYPE_FORM = "form" RESULT_TYPE_CREATE_ENTRY = "create_entry" RESULT_TYPE_ABORT = "abort" @@ -64,7 +81,7 @@ class FlowResult(TypedDict, total=False): """Typed result dict.""" version: int - type: str + type: FlowResultType flow_id: str handler: str title: str @@ -207,7 +224,7 @@ class FlowManager(abc.ABC): self._initialize_tasks[handler].remove(task) self._initializing[handler].remove(init_done) - if result["type"] != RESULT_TYPE_ABORT: + if result["type"] != FlowResultType.ABORT: await self.async_post_init(flow, result) return result @@ -252,7 +269,7 @@ class FlowManager(abc.ABC): user_input = cur_step["data_schema"](user_input) # Handle a menu navigation choice - if cur_step["type"] == RESULT_TYPE_MENU and user_input: + if cur_step["type"] == FlowResultType.MENU and user_input: result = await self._async_handle_step( flow, user_input["next_step_id"], None ) @@ -261,18 +278,25 @@ class FlowManager(abc.ABC): flow, cur_step["step_id"], user_input ) - if cur_step["type"] in (RESULT_TYPE_EXTERNAL_STEP, RESULT_TYPE_SHOW_PROGRESS): - if cur_step["type"] == RESULT_TYPE_EXTERNAL_STEP and result["type"] not in ( - RESULT_TYPE_EXTERNAL_STEP, - RESULT_TYPE_EXTERNAL_STEP_DONE, + if cur_step["type"] in ( + FlowResultType.EXTERNAL_STEP, + FlowResultType.SHOW_PROGRESS, + ): + if cur_step["type"] == FlowResultType.EXTERNAL_STEP and result[ + "type" + ] not in ( + FlowResultType.EXTERNAL_STEP, + FlowResultType.EXTERNAL_STEP_DONE, ): raise ValueError( "External step can only transition to " "external step or external step done." ) - if cur_step["type"] == RESULT_TYPE_SHOW_PROGRESS and result["type"] not in ( - RESULT_TYPE_SHOW_PROGRESS, - RESULT_TYPE_SHOW_PROGRESS_DONE, + if cur_step["type"] == FlowResultType.SHOW_PROGRESS and result[ + "type" + ] not in ( + FlowResultType.SHOW_PROGRESS, + FlowResultType.SHOW_PROGRESS_DONE, ): raise ValueError( "Show progress can only transition to show progress or show progress done." @@ -282,7 +306,7 @@ class FlowManager(abc.ABC): # the frontend. if ( cur_step["step_id"] != result.get("step_id") - or result["type"] == RESULT_TYPE_SHOW_PROGRESS + or result["type"] == FlowResultType.SHOW_PROGRESS ): # Tell frontend to reload the flow state. self.hass.bus.async_fire( @@ -345,25 +369,21 @@ class FlowManager(abc.ABC): if step_done: step_done.set_result(None) - if result["type"] not in ( - RESULT_TYPE_FORM, - RESULT_TYPE_EXTERNAL_STEP, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_ABORT, - RESULT_TYPE_EXTERNAL_STEP_DONE, - RESULT_TYPE_SHOW_PROGRESS, - RESULT_TYPE_SHOW_PROGRESS_DONE, - RESULT_TYPE_MENU, - ): - raise ValueError(f"Handler returned incorrect type: {result['type']}") + if not isinstance(result["type"], FlowResultType): + result["type"] = FlowResultType(result["type"]) # type: ignore[unreachable] + report( + "does not use FlowResultType enum for data entry flow result type. " + "This is deprecated and will stop working in Home Assistant 2022.9", + error_if_core=False, + ) if result["type"] in ( - RESULT_TYPE_FORM, - RESULT_TYPE_EXTERNAL_STEP, - RESULT_TYPE_EXTERNAL_STEP_DONE, - RESULT_TYPE_SHOW_PROGRESS, - RESULT_TYPE_SHOW_PROGRESS_DONE, - RESULT_TYPE_MENU, + FlowResultType.FORM, + FlowResultType.EXTERNAL_STEP, + FlowResultType.EXTERNAL_STEP_DONE, + FlowResultType.SHOW_PROGRESS, + FlowResultType.SHOW_PROGRESS_DONE, + FlowResultType.MENU, ): flow.cur_step = result return result @@ -372,7 +392,7 @@ class FlowManager(abc.ABC): result = await self.async_finish_flow(flow, result.copy()) # _async_finish_flow may change result type, check it again - if result["type"] == RESULT_TYPE_FORM: + if result["type"] == FlowResultType.FORM: flow.cur_step = result return result @@ -427,7 +447,7 @@ class FlowHandler: ) -> FlowResult: """Return the definition of a form to gather user input.""" return { - "type": RESULT_TYPE_FORM, + "type": FlowResultType.FORM, "flow_id": self.flow_id, "handler": self.handler, "step_id": step_id, @@ -449,7 +469,7 @@ class FlowHandler: """Finish config flow and create a config entry.""" return { "version": self.VERSION, - "type": RESULT_TYPE_CREATE_ENTRY, + "type": FlowResultType.CREATE_ENTRY, "flow_id": self.flow_id, "handler": self.handler, "title": title, @@ -480,7 +500,7 @@ class FlowHandler: ) -> FlowResult: """Return the definition of an external step for the user to take.""" return { - "type": RESULT_TYPE_EXTERNAL_STEP, + "type": FlowResultType.EXTERNAL_STEP, "flow_id": self.flow_id, "handler": self.handler, "step_id": step_id, @@ -492,7 +512,7 @@ class FlowHandler: def async_external_step_done(self, *, next_step_id: str) -> FlowResult: """Return the definition of an external step for the user to take.""" return { - "type": RESULT_TYPE_EXTERNAL_STEP_DONE, + "type": FlowResultType.EXTERNAL_STEP_DONE, "flow_id": self.flow_id, "handler": self.handler, "step_id": next_step_id, @@ -508,7 +528,7 @@ class FlowHandler: ) -> FlowResult: """Show a progress message to the user, without user input allowed.""" return { - "type": RESULT_TYPE_SHOW_PROGRESS, + "type": FlowResultType.SHOW_PROGRESS, "flow_id": self.flow_id, "handler": self.handler, "step_id": step_id, @@ -520,7 +540,7 @@ class FlowHandler: def async_show_progress_done(self, *, next_step_id: str) -> FlowResult: """Mark the progress done.""" return { - "type": RESULT_TYPE_SHOW_PROGRESS_DONE, + "type": FlowResultType.SHOW_PROGRESS_DONE, "flow_id": self.flow_id, "handler": self.handler, "step_id": next_step_id, @@ -539,7 +559,7 @@ class FlowHandler: Options dict maps step_id => i18n label """ return { - "type": RESULT_TYPE_MENU, + "type": FlowResultType.MENU, "flow_id": self.flow_id, "handler": self.handler, "step_id": step_id, @@ -558,7 +578,7 @@ def _create_abort_data( ) -> FlowResult: """Return the definition of an external step for the user to take.""" return { - "type": RESULT_TYPE_ABORT, + "type": FlowResultType.ABORT, "flow_id": flow_id, "handler": handler, "reason": reason, diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 2126e048fc5..444876a7674 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -26,7 +26,7 @@ class _BaseFlowManagerView(HomeAssistantView): self, result: data_entry_flow.FlowResult ) -> data_entry_flow.FlowResult: """Convert result to JSON.""" - if result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + if result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY: data = result.copy() data.pop("result") data.pop("data") diff --git a/homeassistant/helpers/schema_config_entry_flow.py b/homeassistant/helpers/schema_config_entry_flow.py index 4073421bc2c..12ffa7cc101 100644 --- a/homeassistant/helpers/schema_config_entry_flow.py +++ b/homeassistant/helpers/schema_config_entry_flow.py @@ -42,7 +42,7 @@ class SchemaFlowFormStep: # The next_step function is called if the schema validates successfully or if no # schema is defined. The next_step function is passed the union of config entry # options and user input from previous steps. - # If next_step returns None, the flow is ended with RESULT_TYPE_CREATE_ENTRY. + # If next_step returns None, the flow is ended with FlowResultType.CREATE_ENTRY. next_step: Callable[[dict[str, Any]], str | None] = lambda _: None # Optional function to allow amending a form schema. diff --git a/pylint/plugins/hass_imports.py b/pylint/plugins/hass_imports.py index cc160c1cfbd..31fbe8f498e 100644 --- a/pylint/plugins/hass_imports.py +++ b/pylint/plugins/hass_imports.py @@ -220,6 +220,12 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = { constant=re.compile(r"^SOURCE_(\w*)$"), ), ], + "homeassistant.data_entry_flow": [ + ObsoleteImportMatch( + reason="replaced by FlowResultType enum", + constant=re.compile(r"^RESULT_TYPE_(\w*)$"), + ), + ], "homeassistant.helpers.device_registry": [ ObsoleteImportMatch( reason="replaced by DeviceEntryDisabler enum", From 7980e3f4064e57c665a06e7a33b5b13b5e360480 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 8 Jun 2022 02:10:48 -0400 Subject: [PATCH 296/947] Tweak zwave_js firmware upload view (#73202) Small tweaks to zwave_js firmware upload view --- homeassistant/components/zwave_js/api.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index e6ce08a78a5..6c186e2f840 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -53,7 +53,6 @@ from homeassistant.components.websocket_api.const import ( ERR_UNKNOWN_ERROR, ) from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import Unauthorized from homeassistant.helpers import config_validation as cv @@ -1943,16 +1942,6 @@ class FirmwareUploadView(HomeAssistantView): raise web_exceptions.HTTPBadRequest raise web_exceptions.HTTPNotFound - if not self._dev_reg: - self._dev_reg = dr.async_get(hass) - device = self._dev_reg.async_get(device_id) - assert device - entry = next( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.entry_id in device.config_entries - ) - # Increase max payload request._client_max_size = 1024 * 1024 * 10 # pylint: disable=protected-access @@ -1965,14 +1954,14 @@ class FirmwareUploadView(HomeAssistantView): try: await begin_firmware_update( - entry.data[CONF_URL], + node.client.ws_server_url, node, uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), async_get_clientsession(hass), ) except BaseZwaveJSServerError as err: - raise web_exceptions.HTTPBadRequest from err + raise web_exceptions.HTTPBadRequest(reason=str(err)) from err return self.json(None) From 6c3d402777b78307d79e4b7f5af15e9b1318e121 Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 8 Jun 2022 14:11:41 +0800 Subject: [PATCH 297/947] Bump yolink-api to 0.0.8 (#73173) * update api libray fix hearbeat message valiation * update yolink-api ignore invalidate message --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 7fb78a4974b..d2a02a44c42 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -3,7 +3,7 @@ "name": "YoLink", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yolink", - "requirements": ["yolink-api==0.0.6"], + "requirements": ["yolink-api==0.0.8"], "dependencies": ["auth", "application_credentials"], "codeowners": ["@matrixd2"], "iot_class": "cloud_push" diff --git a/requirements_all.txt b/requirements_all.txt index b95e0b7280c..b72237ffdf1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2483,7 +2483,7 @@ yeelight==0.7.10 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.0.6 +yolink-api==0.0.8 # homeassistant.components.youless youless-api==0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a9399b0e3e..5e44767eec0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1641,7 +1641,7 @@ yalexs==1.1.25 yeelight==0.7.10 # homeassistant.components.yolink -yolink-api==0.0.6 +yolink-api==0.0.8 # homeassistant.components.youless youless-api==0.16 From 95e9bd106eb2a7077e92aa7c8653d668ebe09533 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Jun 2022 09:07:05 +0200 Subject: [PATCH 298/947] Bump actions/cache from 3.0.3 to 3.0.4 (#73203) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 30756ddb501..502006d8d2c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -172,7 +172,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: >- @@ -189,7 +189,7 @@ jobs: # ${{ runner.os }}-${{ steps.python.outputs.python-version }}-base-venv-${{ env.CACHE_VERSION }}- - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: ${{ env.PIP_CACHE }} key: >- @@ -212,7 +212,7 @@ jobs: hashFiles('.pre-commit-config.yaml') }}" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: >- @@ -241,7 +241,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -253,7 +253,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -291,7 +291,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -303,7 +303,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -342,7 +342,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -354,7 +354,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -384,7 +384,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -396,7 +396,7 @@ jobs: exit 1 - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: ${{ env.PRE_COMMIT_CACHE }} key: ${{ runner.os }}-${{ needs.prepare-base.outputs.pre-commit-key }} @@ -502,7 +502,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -531,7 +531,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ @@ -573,7 +573,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: >- @@ -590,7 +590,7 @@ jobs: # ${{ runner.os }}-${{ matrix.python-version }}-venv-${{ env.CACHE_VERSION }}- - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: ${{ env.PIP_CACHE }} key: >- @@ -629,7 +629,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -671,7 +671,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -715,7 +715,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ @@ -758,7 +758,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache@v3.0.3 + uses: actions/cache@v3.0.4 with: path: venv key: ${{ runner.os }}-${{ matrix.python-version }}-${{ From 4bc04383d143946c7c7a2be00d8fa9b1736c1648 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 8 Jun 2022 10:52:01 +0200 Subject: [PATCH 299/947] Ensure netgear devices are tracked with one enabled config entry (#72969) Co-authored-by: Martin Hjelmare --- homeassistant/components/netgear/__init__.py | 7 +++---- homeassistant/components/netgear/router.py | 17 +++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 7a4d0e7a8cd..679a93f8da1 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -18,7 +18,6 @@ from .const import ( KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, KEY_ROUTER, - MODE_ROUTER, PLATFORMS, ) from .errors import CannotLoginException @@ -72,7 +71,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update_devices() -> bool: """Fetch data from the router.""" - if router.mode == MODE_ROUTER: + if router.track_devices: return await router.async_update_device_trackers() return False @@ -107,7 +106,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_interval=SPEED_TEST_INTERVAL, ) - if router.mode == MODE_ROUTER: + if router.track_devices: await coordinator.async_config_entry_first_refresh() await coordinator_traffic_meter.async_config_entry_first_refresh() @@ -134,7 +133,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if not hass.data[DOMAIN]: hass.data.pop(DOMAIN) - if router.mode != MODE_ROUTER: + if not router.track_devices: router_id = None # Remove devices that are no longer tracked device_registry = dr.async_get(hass) diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 301906f22b6..67e573d0e92 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -80,6 +80,7 @@ class NetgearRouter: self.hardware_version = "" self.serial_number = "" + self.track_devices = True self.method_version = 1 consider_home_int = entry.options.get( CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() @@ -112,11 +113,23 @@ class NetgearRouter: self.serial_number = self._info["SerialNumber"] self.mode = self._info.get("DeviceMode", MODE_ROUTER) + enabled_entries = [ + entry + for entry in self.hass.config_entries.async_entries(DOMAIN) + if entry.disabled_by is None + ] + self.track_devices = self.mode == MODE_ROUTER or len(enabled_entries) == 1 + _LOGGER.debug( + "Netgear track_devices = '%s', device mode '%s'", + self.track_devices, + self.mode, + ) + for model in MODELS_V2: if self.model.startswith(model): self.method_version = 2 - if self.method_version == 2 and self.mode == MODE_ROUTER: + if self.method_version == 2 and self.track_devices: if not self._api.get_attached_devices_2(): _LOGGER.error( "Netgear Model '%s' in MODELS_V2 list, but failed to get attached devices using V2", @@ -133,7 +146,7 @@ class NetgearRouter: return False # set already known devices to away instead of unavailable - if self.mode == MODE_ROUTER: + if self.track_devices: device_registry = dr.async_get(self.hass) devices = dr.async_entries_for_config_entry(device_registry, self.entry_id) for device_entry in devices: From 79096864ebe6f187326289a3356341fea4252449 Mon Sep 17 00:00:00 2001 From: Matrix Date: Wed, 8 Jun 2022 17:54:32 +0800 Subject: [PATCH 300/947] Add yolink CoSmoke Sensor and Switch (#73209) add CoSmoke Sensor and Switch --- .../components/yolink/binary_sensor.py | 25 ++++++++++++++++--- homeassistant/components/yolink/const.py | 2 ++ homeassistant/components/yolink/sensor.py | 3 +++ homeassistant/components/yolink/switch.py | 16 +++++++++--- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/yolink/binary_sensor.py b/homeassistant/components/yolink/binary_sensor.py index 17b25c57d94..b296e01fa56 100644 --- a/homeassistant/components/yolink/binary_sensor.py +++ b/homeassistant/components/yolink/binary_sensor.py @@ -18,6 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_COORDINATORS, + ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, @@ -42,8 +43,10 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, + ATTR_DEVICE_CO_SMOKE_SENSOR, ] + SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( YoLinkBinarySensorEntityDescription( key="door_state", @@ -51,14 +54,14 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.DOOR, name="State", value=lambda value: value == "open" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_DOOR_SENSOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_DOOR_SENSOR, ), YoLinkBinarySensorEntityDescription( key="motion_state", device_class=BinarySensorDeviceClass.MOTION, name="Motion", value=lambda value: value == "alert" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_MOTION_SENSOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MOTION_SENSOR, ), YoLinkBinarySensorEntityDescription( key="leak_state", @@ -66,14 +69,28 @@ SENSOR_TYPES: tuple[YoLinkBinarySensorEntityDescription, ...] = ( icon="mdi:water", device_class=BinarySensorDeviceClass.MOISTURE, value=lambda value: value == "alert" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_LEAK_SENSOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_LEAK_SENSOR, ), YoLinkBinarySensorEntityDescription( key="vibration_state", name="Vibration", device_class=BinarySensorDeviceClass.VIBRATION, value=lambda value: value == "alert" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_VIBRATION_SENSOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_VIBRATION_SENSOR, + ), + YoLinkBinarySensorEntityDescription( + key="co_detected", + name="Co Detected", + device_class=BinarySensorDeviceClass.CO, + value=lambda state: state.get("gasAlarm"), + exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, + ), + YoLinkBinarySensorEntityDescription( + key="smoke_detected", + name="Smoke Detected", + device_class=BinarySensorDeviceClass.SMOKE, + value=lambda state: state.get("smokeAlarm"), + exists_fn=lambda device: device.device_type == ATTR_DEVICE_CO_SMOKE_SENSOR, ), ) diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index b154fe9178d..dba0a0ee221 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -22,3 +22,5 @@ ATTR_DEVICE_OUTLET = "Outlet" ATTR_DEVICE_SIREN = "Siren" ATTR_DEVICE_LOCK = "Lock" ATTR_DEVICE_MANIPULATOR = "Manipulator" +ATTR_DEVICE_CO_SMOKE_SENSOR = "COSmokeSensor" +ATTR_DEVICE_SWITCH = "Switch" diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 26dee7a493d..7c578fbaa73 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -20,6 +20,7 @@ from homeassistant.util import percentage from .const import ( ATTR_COORDINATORS, + ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LOCK, ATTR_DEVICE_MANIPULATOR, @@ -55,6 +56,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_CO_SMOKE_SENSOR, ] BATTERY_POWER_SENSOR = [ @@ -64,6 +66,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_VIBRATION_SENSOR, ATTR_DEVICE_LOCK, ATTR_DEVICE_MANIPULATOR, + ATTR_DEVICE_CO_SMOKE_SENSOR, ] diff --git a/homeassistant/components/yolink/switch.py b/homeassistant/components/yolink/switch.py index 6733191b943..03bb2a26183 100644 --- a/homeassistant/components/yolink/switch.py +++ b/homeassistant/components/yolink/switch.py @@ -20,6 +20,7 @@ from .const import ( ATTR_COORDINATORS, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_OUTLET, + ATTR_DEVICE_SWITCH, DOMAIN, ) from .coordinator import YoLinkCoordinator @@ -41,18 +42,25 @@ DEVICE_TYPES: tuple[YoLinkSwitchEntityDescription, ...] = ( device_class=SwitchDeviceClass.OUTLET, name="State", value=lambda value: value == "open" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_OUTLET], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_OUTLET, ), YoLinkSwitchEntityDescription( key="manipulator_state", - device_class=SwitchDeviceClass.SWITCH, name="State", + icon="mdi:pipe", value=lambda value: value == "open" if value is not None else None, - exists_fn=lambda device: device.device_type in [ATTR_DEVICE_MANIPULATOR], + exists_fn=lambda device: device.device_type == ATTR_DEVICE_MANIPULATOR, + ), + YoLinkSwitchEntityDescription( + key="switch_state", + name="State", + device_class=SwitchDeviceClass.SWITCH, + value=lambda value: value == "open" if value is not None else None, + exists_fn=lambda device: device.device_type == ATTR_DEVICE_SWITCH, ), ) -DEVICE_TYPE = [ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_OUTLET] +DEVICE_TYPE = [ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_OUTLET, ATTR_DEVICE_SWITCH] async def async_setup_entry( From 5987266e5636cf0ef42ddbe1513e3c907f2d7bdf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Jun 2022 15:55:49 +0200 Subject: [PATCH 301/947] Refactor template entity to allow reuse (#72753) * Refactor template entity to allow reuse * Fix schema and default name * Add tests * Update requirements * Improve test * Tweak TemplateSensor initializer * Drop attributes and availability from TemplateEntity * Use rest sensor for proof of concept * Revert changes in SNMP sensor * Don't set _attr_should_poll in mixin class * Update requirements --- homeassistant/components/rest/entity.py | 33 +- homeassistant/components/rest/schema.py | 15 +- homeassistant/components/rest/sensor.py | 78 ++-- .../template/alarm_control_panel.py | 2 + .../components/template/binary_sensor.py | 2 + homeassistant/components/template/button.py | 2 + homeassistant/components/template/cover.py | 2 + homeassistant/components/template/fan.py | 2 + homeassistant/components/template/light.py | 2 + homeassistant/components/template/lock.py | 2 + homeassistant/components/template/number.py | 2 + homeassistant/components/template/select.py | 2 + homeassistant/components/template/sensor.py | 38 +- homeassistant/components/template/switch.py | 7 +- .../components/template/template_entity.py | 383 +--------------- homeassistant/components/template/vacuum.py | 2 + homeassistant/components/template/weather.py | 2 + homeassistant/helpers/template_entity.py | 434 ++++++++++++++++++ tests/components/rest/test_sensor.py | 42 ++ 19 files changed, 580 insertions(+), 472 deletions(-) create mode 100644 homeassistant/helpers/template_entity.py diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py index 064396af415..f0476dc7d33 100644 --- a/homeassistant/components/rest/entity.py +++ b/homeassistant/components/rest/entity.py @@ -10,29 +10,21 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .data import RestData -class RestEntity(Entity): +class BaseRestEntity(Entity): """A class for entities using DataUpdateCoordinator or rest data directly.""" def __init__( self, coordinator: DataUpdateCoordinator[Any], rest: RestData, - name, resource_template, force_update, ) -> None: """Create the entity that may have a coordinator.""" self.coordinator = coordinator self.rest = rest - self._name = name self._resource_template = resource_template self._force_update = force_update - super().__init__() - - @property - def name(self): - """Return the name of the sensor.""" - return self._name @property def force_update(self): @@ -41,7 +33,7 @@ class RestEntity(Entity): @property def should_poll(self) -> bool: - """Poll only if we do noty have a coordinator.""" + """Poll only if we do not have a coordinator.""" return not self.coordinator @property @@ -80,3 +72,24 @@ class RestEntity(Entity): @abstractmethod def _update_from_rest_data(self): """Update state from the rest data.""" + + +class RestEntity(BaseRestEntity): + """A class for entities using DataUpdateCoordinator or rest data directly.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator[Any], + rest: RestData, + name, + resource_template, + force_update, + ) -> None: + """Create the entity that may have a coordinator.""" + self._name = name + super().__init__(coordinator, rest, resource_template, force_update) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index c5b6949bd39..d25bb50167b 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -6,12 +6,7 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, ) -from homeassistant.components.sensor import ( - DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, - DOMAIN as SENSOR_DOMAIN, - STATE_CLASSES_SCHEMA, -) -from homeassistant.components.sensor.const import CONF_STATE_CLASS +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( CONF_AUTHENTICATION, CONF_DEVICE_CLASS, @@ -26,7 +21,6 @@ from homeassistant.const import ( CONF_RESOURCE_TEMPLATE, CONF_SCAN_INTERVAL, CONF_TIMEOUT, - CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, @@ -34,6 +28,7 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from .const import ( CONF_JSON_ATTRS, @@ -41,7 +36,6 @@ from .const import ( DEFAULT_BINARY_SENSOR_NAME, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, - DEFAULT_SENSOR_NAME, DEFAULT_VERIFY_SSL, DOMAIN, METHODS, @@ -65,10 +59,7 @@ RESOURCE_SCHEMA = { } SENSOR_SCHEMA = { - vol.Optional(CONF_NAME, default=DEFAULT_SENSOR_NAME): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + **TEMPLATE_SENSOR_BASE_SCHEMA.schema, vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv, vol.Optional(CONF_JSON_ATTRS_PATH): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 73d65abda7e..2965504dc4a 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -10,31 +10,28 @@ import voluptuous as vol import xmltodict from homeassistant.components.sensor import ( - CONF_STATE_CLASS, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, SensorDeviceClass, - SensorEntity, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( - CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, - CONF_UNIT_OF_MEASUREMENT, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import TemplateSensor from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import async_get_config_and_coordinator, create_rest_data_from_config -from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH -from .entity import RestEntity +from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, DEFAULT_SENSOR_NAME +from .entity import BaseRestEntity from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -70,67 +67,54 @@ async def async_setup_platform( raise PlatformNotReady from rest.last_exception raise PlatformNotReady - name = conf.get(CONF_NAME) - unit = conf.get(CONF_UNIT_OF_MEASUREMENT) - device_class = conf.get(CONF_DEVICE_CLASS) - state_class = conf.get(CONF_STATE_CLASS) - json_attrs = conf.get(CONF_JSON_ATTRS) - json_attrs_path = conf.get(CONF_JSON_ATTRS_PATH) - value_template = conf.get(CONF_VALUE_TEMPLATE) - force_update = conf.get(CONF_FORCE_UPDATE) - resource_template = conf.get(CONF_RESOURCE_TEMPLATE) - - if value_template is not None: - value_template.hass = hass + unique_id = conf.get(CONF_UNIQUE_ID) async_add_entities( [ RestSensor( + hass, coordinator, rest, - name, - unit, - device_class, - state_class, - value_template, - json_attrs, - force_update, - resource_template, - json_attrs_path, + conf, + unique_id, ) ], ) -class RestSensor(RestEntity, SensorEntity): +class RestSensor(BaseRestEntity, TemplateSensor): """Implementation of a REST sensor.""" def __init__( self, + hass, coordinator, rest, - name, - unit_of_measurement, - device_class, - state_class, - value_template, - json_attrs, - force_update, - resource_template, - json_attrs_path, + config, + unique_id, ): """Initialize the REST sensor.""" - super().__init__(coordinator, rest, name, resource_template, force_update) + BaseRestEntity.__init__( + self, + coordinator, + rest, + config.get(CONF_RESOURCE_TEMPLATE), + config.get(CONF_FORCE_UPDATE), + ) + TemplateSensor.__init__( + self, + hass, + config=config, + fallback_name=DEFAULT_SENSOR_NAME, + unique_id=unique_id, + ) self._state = None - self._unit_of_measurement = unit_of_measurement - self._value_template = value_template - self._json_attrs = json_attrs + self._value_template = config.get(CONF_VALUE_TEMPLATE) + if (value_template := self._value_template) is not None: + value_template.hass = hass + self._json_attrs = config.get(CONF_JSON_ATTRS) self._attributes = None - self._json_attrs_path = json_attrs_path - - self._attr_native_unit_of_measurement = self._unit_of_measurement - self._attr_device_class = device_class - self._attr_state_class = state_class + self._json_attrs_path = config.get(CONF_JSON_ATTRS_PATH) @property def native_value(self): diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index a8a88c57bd3..96cc5a8330e 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -125,6 +125,8 @@ async def async_setup_platform( class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): """Representation of a templated Alarm Control Panel.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 2a537e2aa6b..e91a2925b06 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -195,6 +195,8 @@ async def async_setup_platform( class BinarySensorTemplate(TemplateEntity, BinarySensorEntity, RestoreEntity): """A virtual binary sensor that triggers from another sensor.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/button.py b/homeassistant/components/template/button.py index ac83f76ca91..2bb2f40d6b4 100644 --- a/homeassistant/components/template/button.py +++ b/homeassistant/components/template/button.py @@ -78,6 +78,8 @@ async def async_setup_platform( class TemplateButtonEntity(TemplateEntity, ButtonEntity): """Representation of a template button.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 82c5cc2578c..53b829cac9e 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -133,6 +133,8 @@ async def async_setup_platform( class CoverTemplate(TemplateEntity, CoverEntity): """Representation of a Template cover.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 55f81b697bf..ca50f6017bd 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -125,6 +125,8 @@ async def async_setup_platform( class TemplateFan(TemplateEntity, FanEntity): """A template fan component.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index eafb0b3f4d0..807e3e79ef8 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -136,6 +136,8 @@ async def async_setup_platform( class LightTemplate(TemplateEntity, LightEntity): """Representation of a templated Light, including dimmable.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 1d94194be63..f76750124ed 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -70,6 +70,8 @@ async def async_setup_platform( class TemplateLock(TemplateEntity, LockEntity): """Representation of a template lock.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index 54131990a26..bf3dcbb120b 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -100,6 +100,8 @@ async def async_setup_platform( class TemplateNumber(TemplateEntity, NumberEntity): """Representation of a template number.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/select.py b/homeassistant/components/template/select.py index 19f17096178..4aa36a378ab 100644 --- a/homeassistant/components/template/select.py +++ b/homeassistant/components/template/select.py @@ -94,6 +94,8 @@ async def async_setup_platform( class TemplateSelect(TemplateEntity, SelectEntity): """Representation of a template select.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index 126dd551c45..ee1ddfa8c2d 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -12,10 +12,8 @@ from homeassistant.components.sensor import ( DOMAIN as SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - STATE_CLASSES_SCHEMA, RestoreSensor, SensorDeviceClass, - SensorEntity, ) from homeassistant.components.sensor.helpers import async_parse_date_datetime from homeassistant.const import ( @@ -39,6 +37,10 @@ from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import ( + TEMPLATE_SENSOR_BASE_SCHEMA, + TemplateSensor, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -49,7 +51,6 @@ from .const import ( ) from .template_entity import ( TEMPLATE_ENTITY_COMMON_SCHEMA, - TemplateEntity, rewrite_common_legacy_to_modern_conf, ) from .trigger_entity import TriggerEntity @@ -61,16 +62,15 @@ LEGACY_FIELDS = { } -SENSOR_SCHEMA = vol.Schema( - { - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME): cv.template, - vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - } -).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) +SENSOR_SCHEMA = ( + vol.Schema( + { + vol.Required(CONF_STATE): cv.template, + } + ) + .extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) + .extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) +) LEGACY_SENSOR_SCHEMA = vol.All( @@ -192,9 +192,11 @@ async def async_setup_platform( ) -class SensorTemplate(TemplateEntity, SensorEntity): +class SensorTemplate(TemplateSensor): """Representation of a Template Sensor.""" + _attr_should_poll = False + def __init__( self, hass: HomeAssistant, @@ -202,17 +204,13 @@ class SensorTemplate(TemplateEntity, SensorEntity): unique_id: str | None, ) -> None: """Initialize the sensor.""" - super().__init__(hass, config=config, unique_id=unique_id) + super().__init__(hass, config=config, fallback_name=None, unique_id=unique_id) + self._template = config.get(CONF_STATE) if (object_id := config.get(CONF_OBJECT_ID)) is not None: self.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, object_id, hass=hass ) - self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - self._template = config.get(CONF_STATE) - self._attr_device_class = config.get(CONF_DEVICE_CLASS) - self._attr_state_class = config.get(CONF_STATE_CLASS) - async def async_added_to_hass(self): """Register callbacks.""" self.add_template_attribute( diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index ac01bc66812..f04f2b5ba7a 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -90,6 +90,8 @@ async def async_setup_platform( class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): """Representation of a Template switch.""" + _attr_should_poll = False + def __init__( self, hass, @@ -149,11 +151,6 @@ class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity): """Return true if device is on.""" return self._state - @property - def should_poll(self): - """Return the polling state.""" - return False - async def async_turn_on(self, **kwargs): """Fire the on action.""" await self.async_run_script(self._on_script, context=self._context) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 6e0b7f6f48f..901834237c6 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -1,38 +1,23 @@ """TemplateEntity utility class.""" from __future__ import annotations -from collections.abc import Callable -import contextlib import itertools -import logging from typing import Any import voluptuous as vol from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_ENTITY_PICTURE_TEMPLATE, CONF_FRIENDLY_NAME, CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, - EVENT_HOMEASSISTANT_START, - STATE_UNKNOWN, ) -from homeassistant.core import Context, CoreState, Event, State, callback -from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import ( - TrackTemplate, - TrackTemplateResult, - async_track_template_result, -) -from homeassistant.helpers.script import Script, _VarsType -from homeassistant.helpers.template import ( - Template, - TemplateStateFromEntityId, - result_as_boolean, +from homeassistant.helpers.template import Template +from homeassistant.helpers.template_entity import ( # noqa: F401 pylint: disable=unused-import + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, ) from .const import ( @@ -43,9 +28,6 @@ from .const import ( CONF_PICTURE, ) -_LOGGER = logging.getLogger(__name__) - - TEMPLATE_ENTITY_AVAILABILITY_SCHEMA = vol.Schema( { vol.Optional(CONF_AVAILABILITY): cv.template, @@ -62,10 +44,8 @@ TEMPLATE_ENTITY_COMMON_SCHEMA = vol.Schema( { vol.Optional(CONF_ATTRIBUTES): vol.Schema({cv.string: cv.template}), vol.Optional(CONF_AVAILABILITY): cv.template, - vol.Optional(CONF_ICON): cv.template, - vol.Optional(CONF_PICTURE): cv.template, } -) +).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) TEMPLATE_ENTITY_ATTRIBUTES_SCHEMA_LEGACY = vol.Schema( { @@ -121,356 +101,3 @@ def rewrite_common_legacy_to_modern_conf( entity_cfg[CONF_NAME] = Template(entity_cfg[CONF_NAME]) return entity_cfg - - -class _TemplateAttribute: - """Attribute value linked to template result.""" - - def __init__( - self, - entity: Entity, - attribute: str, - template: Template, - validator: Callable[[Any], Any] = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool | None = False, - ) -> None: - """Template attribute.""" - self._entity = entity - self._attribute = attribute - self.template = template - self.validator = validator - self.on_update = on_update - self.async_update = None - self.none_on_template_error = none_on_template_error - - @callback - def async_setup(self): - """Config update path for the attribute.""" - if self.on_update: - return - - if not hasattr(self._entity, self._attribute): - raise AttributeError(f"Attribute '{self._attribute}' does not exist.") - - self.on_update = self._default_update - - @callback - def _default_update(self, result): - attr_result = None if isinstance(result, TemplateError) else result - setattr(self._entity, self._attribute, attr_result) - - @callback - def handle_result( - self, - event: Event | None, - template: Template, - last_result: str | None | TemplateError, - result: str | TemplateError, - ) -> None: - """Handle a template result event callback.""" - if isinstance(result, TemplateError): - _LOGGER.error( - "TemplateError('%s') " - "while processing template '%s' " - "for attribute '%s' in entity '%s'", - result, - self.template, - self._attribute, - self._entity.entity_id, - ) - if self.none_on_template_error: - self._default_update(result) - else: - assert self.on_update - self.on_update(result) - return - - if not self.validator: - assert self.on_update - self.on_update(result) - return - - try: - validated = self.validator(result) - except vol.Invalid as ex: - _LOGGER.error( - "Error validating template result '%s' " - "from template '%s' " - "for attribute '%s' in entity %s " - "validation message '%s'", - result, - self.template, - self._attribute, - self._entity.entity_id, - ex.msg, - ) - assert self.on_update - self.on_update(None) - return - - assert self.on_update - self.on_update(validated) - return - - -class TemplateEntity(Entity): - """Entity that uses templates to calculate attributes.""" - - _attr_available = True - _attr_entity_picture = None - _attr_icon = None - _attr_should_poll = False - - def __init__( - self, - hass, - *, - availability_template=None, - icon_template=None, - entity_picture_template=None, - attribute_templates=None, - config=None, - fallback_name=None, - unique_id=None, - ): - """Template Entity.""" - self._template_attrs = {} - self._async_update = None - self._attr_extra_state_attributes = {} - self._self_ref_update_count = 0 - self._attr_unique_id = unique_id - if config is None: - self._attribute_templates = attribute_templates - self._availability_template = availability_template - self._icon_template = icon_template - self._entity_picture_template = entity_picture_template - self._friendly_name_template = None - else: - self._attribute_templates = config.get(CONF_ATTRIBUTES) - self._availability_template = config.get(CONF_AVAILABILITY) - self._icon_template = config.get(CONF_ICON) - self._entity_picture_template = config.get(CONF_PICTURE) - self._friendly_name_template = config.get(CONF_NAME) - - class DummyState(State): - """None-state for template entities not yet added to the state machine.""" - - def __init__(self) -> None: - """Initialize a new state.""" - super().__init__("unknown.unknown", STATE_UNKNOWN) - self.entity_id = None # type: ignore[assignment] - - @property - def name(self) -> str: - """Name of this state.""" - return "" - - variables = {"this": DummyState()} - - # Try to render the name as it can influence the entity ID - self._attr_name = fallback_name - if self._friendly_name_template: - self._friendly_name_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_name = self._friendly_name_template.async_render( - variables=variables, parse_result=False - ) - - # Templates will not render while the entity is unavailable, try to render the - # icon and picture templates. - if self._entity_picture_template: - self._entity_picture_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_entity_picture = self._entity_picture_template.async_render( - variables=variables, parse_result=False - ) - - if self._icon_template: - self._icon_template.hass = hass - with contextlib.suppress(TemplateError): - self._attr_icon = self._icon_template.async_render( - variables=variables, parse_result=False - ) - - @callback - def _update_available(self, result): - if isinstance(result, TemplateError): - self._attr_available = True - return - - self._attr_available = result_as_boolean(result) - - @callback - def _update_state(self, result): - if self._availability_template: - return - - self._attr_available = not isinstance(result, TemplateError) - - @callback - def _add_attribute_template(self, attribute_key, attribute_template): - """Create a template tracker for the attribute.""" - - def _update_attribute(result): - attr_result = None if isinstance(result, TemplateError) else result - self._attr_extra_state_attributes[attribute_key] = attr_result - - self.add_template_attribute( - attribute_key, attribute_template, None, _update_attribute - ) - - def add_template_attribute( - self, - attribute: str, - template: Template, - validator: Callable[[Any], Any] = None, - on_update: Callable[[Any], None] | None = None, - none_on_template_error: bool = False, - ) -> None: - """ - Call in the constructor to add a template linked to a attribute. - - Parameters - ---------- - attribute - The name of the attribute to link to. This attribute must exist - unless a custom on_update method is supplied. - template - The template to calculate. - validator - Validator function to parse the result and ensure it's valid. - on_update - Called to store the template result rather than storing it - the supplied attribute. Passed the result of the validator, or None - if the template or validator resulted in an error. - - """ - assert self.hass is not None, "hass cannot be None" - template.hass = self.hass - template_attribute = _TemplateAttribute( - self, attribute, template, validator, on_update, none_on_template_error - ) - self._template_attrs.setdefault(template, []) - self._template_attrs[template].append(template_attribute) - - @callback - def _handle_results( - self, - event: Event | None, - updates: list[TrackTemplateResult], - ) -> None: - """Call back the results to the attributes.""" - if event: - self.async_set_context(event.context) - - entity_id = event and event.data.get(ATTR_ENTITY_ID) - - if entity_id and entity_id == self.entity_id: - self._self_ref_update_count += 1 - else: - self._self_ref_update_count = 0 - - if self._self_ref_update_count > len(self._template_attrs): - for update in updates: - _LOGGER.warning( - "Template loop detected while processing event: %s, skipping template render for Template[%s]", - event, - update.template.template, - ) - return - - for update in updates: - for attr in self._template_attrs[update.template]: - attr.handle_result( - event, update.template, update.last_result, update.result - ) - - self.async_write_ha_state() - - async def _async_template_startup(self, *_) -> None: - template_var_tups: list[TrackTemplate] = [] - has_availability_template = False - - variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} - - for template, attributes in self._template_attrs.items(): - template_var_tup = TrackTemplate(template, variables) - is_availability_template = False - for attribute in attributes: - # pylint: disable-next=protected-access - if attribute._attribute == "_attr_available": - has_availability_template = True - is_availability_template = True - attribute.async_setup() - # Insert the availability template first in the list - if is_availability_template: - template_var_tups.insert(0, template_var_tup) - else: - template_var_tups.append(template_var_tup) - - result_info = async_track_template_result( - self.hass, - template_var_tups, - self._handle_results, - has_super_template=has_availability_template, - ) - self.async_on_remove(result_info.async_remove) - self._async_update = result_info.async_refresh - result_info.async_refresh() - - async def async_added_to_hass(self) -> None: - """Run when entity about to be added to hass.""" - if self._availability_template is not None: - self.add_template_attribute( - "_attr_available", - self._availability_template, - None, - self._update_available, - ) - if self._attribute_templates is not None: - for key, value in self._attribute_templates.items(): - self._add_attribute_template(key, value) - if self._icon_template is not None: - self.add_template_attribute( - "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) - ) - if self._entity_picture_template is not None: - self.add_template_attribute( - "_attr_entity_picture", self._entity_picture_template - ) - if ( - self._friendly_name_template is not None - and not self._friendly_name_template.is_static - ): - self.add_template_attribute("_attr_name", self._friendly_name_template) - - if self.hass.state == CoreState.running: - await self._async_template_startup() - return - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_template_startup - ) - - async def async_update(self) -> None: - """Call for forced update.""" - self._async_update() - - async def async_run_script( - self, - script: Script, - *, - run_variables: _VarsType | None = None, - context: Context | None = None, - ) -> None: - """Run an action script.""" - if run_variables is None: - run_variables = {} - return await script.async_run( - run_variables={ - "this": TemplateStateFromEntityId(self.hass, self.entity_id), - **run_variables, - }, - context=context, - ) diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py index 4b278ef6aec..5f306bfa5e1 100644 --- a/homeassistant/components/template/vacuum.py +++ b/homeassistant/components/template/vacuum.py @@ -126,6 +126,8 @@ async def async_setup_platform( class TemplateVacuum(TemplateEntity, StateVacuumEntity): """A template vacuum component.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index d4069096553..5d1a48269aa 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -105,6 +105,8 @@ async def async_setup_platform( class WeatherTemplate(TemplateEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_should_poll = False + def __init__( self, hass, diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py new file mode 100644 index 00000000000..e92ba233121 --- /dev/null +++ b/homeassistant/helpers/template_entity.py @@ -0,0 +1,434 @@ +"""TemplateEntity utility class.""" +from __future__ import annotations + +from collections.abc import Callable +import contextlib +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA, + STATE_CLASSES_SCHEMA, + SensorEntity, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + EVENT_HOMEASSISTANT_START, + STATE_UNKNOWN, +) +from homeassistant.core import Context, CoreState, Event, HomeAssistant, State, callback +from homeassistant.exceptions import TemplateError + +from . import config_validation as cv +from .entity import Entity +from .event import TrackTemplate, TrackTemplateResult, async_track_template_result +from .script import Script, _VarsType +from .template import Template, TemplateStateFromEntityId, result_as_boolean +from .typing import ConfigType + +_LOGGER = logging.getLogger(__name__) + +CONF_AVAILABILITY = "availability" +CONF_ATTRIBUTES = "attributes" +CONF_PICTURE = "picture" + +TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_PICTURE): cv.template, + } +) + +TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME): cv.template, + vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + } +).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) + + +class _TemplateAttribute: + """Attribute value linked to template result.""" + + def __init__( + self, + entity: Entity, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool | None = False, + ) -> None: + """Template attribute.""" + self._entity = entity + self._attribute = attribute + self.template = template + self.validator = validator + self.on_update = on_update + self.async_update = None + self.none_on_template_error = none_on_template_error + + @callback + def async_setup(self) -> None: + """Config update path for the attribute.""" + if self.on_update: + return + + if not hasattr(self._entity, self._attribute): + raise AttributeError(f"Attribute '{self._attribute}' does not exist.") + + self.on_update = self._default_update + + @callback + def _default_update(self, result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + setattr(self._entity, self._attribute, attr_result) + + @callback + def handle_result( + self, + event: Event | None, + template: Template, + last_result: str | None | TemplateError, + result: str | TemplateError, + ) -> None: + """Handle a template result event callback.""" + if isinstance(result, TemplateError): + _LOGGER.error( + "TemplateError('%s') " + "while processing template '%s' " + "for attribute '%s' in entity '%s'", + result, + self.template, + self._attribute, + self._entity.entity_id, + ) + if self.none_on_template_error: + self._default_update(result) + else: + assert self.on_update + self.on_update(result) + return + + if not self.validator: + assert self.on_update + self.on_update(result) + return + + try: + validated = self.validator(result) + except vol.Invalid as ex: + _LOGGER.error( + "Error validating template result '%s' " + "from template '%s' " + "for attribute '%s' in entity %s " + "validation message '%s'", + result, + self.template, + self._attribute, + self._entity.entity_id, + ex.msg, + ) + assert self.on_update + self.on_update(None) + return + + assert self.on_update + self.on_update(validated) + return + + +class TemplateEntity(Entity): + """Entity that uses templates to calculate attributes.""" + + _attr_available = True + _attr_entity_picture = None + _attr_icon = None + + def __init__( + self, + hass: HomeAssistant, + *, + availability_template: Template | None = None, + icon_template: Template | None = None, + entity_picture_template: Template | None = None, + attribute_templates: dict[str, Template] | None = None, + config: ConfigType | None = None, + fallback_name: str | None = None, + unique_id: str | None = None, + ) -> None: + """Template Entity.""" + self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} + self._async_update: Callable[[], None] | None = None + self._attr_extra_state_attributes = {} + self._self_ref_update_count = 0 + self._attr_unique_id = unique_id + if config is None: + self._attribute_templates = attribute_templates + self._availability_template = availability_template + self._icon_template = icon_template + self._entity_picture_template = entity_picture_template + self._friendly_name_template = None + else: + self._attribute_templates = config.get(CONF_ATTRIBUTES) + self._availability_template = config.get(CONF_AVAILABILITY) + self._icon_template = config.get(CONF_ICON) + self._entity_picture_template = config.get(CONF_PICTURE) + self._friendly_name_template = config.get(CONF_NAME) + + class DummyState(State): + """None-state for template entities not yet added to the state machine.""" + + def __init__(self) -> None: + """Initialize a new state.""" + super().__init__("unknown.unknown", STATE_UNKNOWN) + self.entity_id = None # type: ignore[assignment] + + @property + def name(self) -> str: + """Name of this state.""" + return "" + + variables = {"this": DummyState()} + + # Try to render the name as it can influence the entity ID + self._attr_name = fallback_name + if self._friendly_name_template: + self._friendly_name_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_name = self._friendly_name_template.async_render( + variables=variables, parse_result=False + ) + + # Templates will not render while the entity is unavailable, try to render the + # icon and picture templates. + if self._entity_picture_template: + self._entity_picture_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_entity_picture = self._entity_picture_template.async_render( + variables=variables, parse_result=False + ) + + if self._icon_template: + self._icon_template.hass = hass + with contextlib.suppress(TemplateError): + self._attr_icon = self._icon_template.async_render( + variables=variables, parse_result=False + ) + + @callback + def _update_available(self, result: str | TemplateError) -> None: + if isinstance(result, TemplateError): + self._attr_available = True + return + + self._attr_available = result_as_boolean(result) + + @callback + def _update_state(self, result: str | TemplateError) -> None: + if self._availability_template: + return + + self._attr_available = not isinstance(result, TemplateError) + + @callback + def _add_attribute_template( + self, attribute_key: str, attribute_template: Template + ) -> None: + """Create a template tracker for the attribute.""" + + def _update_attribute(result: str | TemplateError) -> None: + attr_result = None if isinstance(result, TemplateError) else result + self._attr_extra_state_attributes[attribute_key] = attr_result + + self.add_template_attribute( + attribute_key, attribute_template, None, _update_attribute + ) + + def add_template_attribute( + self, + attribute: str, + template: Template, + validator: Callable[[Any], Any] | None = None, + on_update: Callable[[Any], None] | None = None, + none_on_template_error: bool = False, + ) -> None: + """ + Call in the constructor to add a template linked to a attribute. + + Parameters + ---------- + attribute + The name of the attribute to link to. This attribute must exist + unless a custom on_update method is supplied. + template + The template to calculate. + validator + Validator function to parse the result and ensure it's valid. + on_update + Called to store the template result rather than storing it + the supplied attribute. Passed the result of the validator, or None + if the template or validator resulted in an error. + + """ + assert self.hass is not None, "hass cannot be None" + template.hass = self.hass + template_attribute = _TemplateAttribute( + self, attribute, template, validator, on_update, none_on_template_error + ) + self._template_attrs.setdefault(template, []) + self._template_attrs[template].append(template_attribute) + + @callback + def _handle_results( + self, + event: Event | None, + updates: list[TrackTemplateResult], + ) -> None: + """Call back the results to the attributes.""" + if event: + self.async_set_context(event.context) + + entity_id = event and event.data.get(ATTR_ENTITY_ID) + + if entity_id and entity_id == self.entity_id: + self._self_ref_update_count += 1 + else: + self._self_ref_update_count = 0 + + if self._self_ref_update_count > len(self._template_attrs): + for update in updates: + _LOGGER.warning( + "Template loop detected while processing event: %s, skipping template render for Template[%s]", + event, + update.template.template, + ) + return + + for update in updates: + for attr in self._template_attrs[update.template]: + attr.handle_result( + event, update.template, update.last_result, update.result + ) + + self.async_write_ha_state() + + async def _async_template_startup(self, *_: Any) -> None: + template_var_tups: list[TrackTemplate] = [] + has_availability_template = False + + variables = {"this": TemplateStateFromEntityId(self.hass, self.entity_id)} + + for template, attributes in self._template_attrs.items(): + template_var_tup = TrackTemplate(template, variables) + is_availability_template = False + for attribute in attributes: + # pylint: disable-next=protected-access + if attribute._attribute == "_attr_available": + has_availability_template = True + is_availability_template = True + attribute.async_setup() + # Insert the availability template first in the list + if is_availability_template: + template_var_tups.insert(0, template_var_tup) + else: + template_var_tups.append(template_var_tup) + + result_info = async_track_template_result( + self.hass, + template_var_tups, + self._handle_results, + has_super_template=has_availability_template, + ) + self.async_on_remove(result_info.async_remove) + self._async_update = result_info.async_refresh + result_info.async_refresh() + + async def async_added_to_hass(self) -> None: + """Run when entity about to be added to hass.""" + if self._availability_template is not None: + self.add_template_attribute( + "_attr_available", + self._availability_template, + None, + self._update_available, + ) + if self._attribute_templates is not None: + for key, value in self._attribute_templates.items(): + self._add_attribute_template(key, value) + if self._icon_template is not None: + self.add_template_attribute( + "_attr_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon) + ) + if self._entity_picture_template is not None: + self.add_template_attribute( + "_attr_entity_picture", self._entity_picture_template + ) + if ( + self._friendly_name_template is not None + and not self._friendly_name_template.is_static + ): + self.add_template_attribute("_attr_name", self._friendly_name_template) + + if self.hass.state == CoreState.running: + await self._async_template_startup() + return + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._async_template_startup + ) + + async def async_update(self) -> None: + """Call for forced update.""" + assert self._async_update + self._async_update() + + async def async_run_script( + self, + script: Script, + *, + run_variables: _VarsType | None = None, + context: Context | None = None, + ) -> None: + """Run an action script.""" + if run_variables is None: + run_variables = {} + return await script.async_run( + run_variables={ + "this": TemplateStateFromEntityId(self.hass, self.entity_id), + **run_variables, + }, + context=context, + ) + + +class TemplateSensor(TemplateEntity, SensorEntity): + """Representation of a Template Sensor.""" + + def __init__( + self, + hass: HomeAssistant, + *, + config: dict[str, Any], + fallback_name: str | None, + unique_id: str | None, + ) -> None: + """Initialize the sensor.""" + super().__init__( + hass, config=config, fallback_name=fallback_name, unique_id=unique_id + ) + + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) + self._attr_state_class = config.get(CONF_STATE_CLASS) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 86ce816f932..a89d20f2510 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -24,6 +24,8 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -864,3 +866,43 @@ async def test_reload(hass): assert hass.states.get("sensor.mockreset") is None assert hass.states.get("sensor.rollout") + + +@respx.mock +async def test_entity_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + DOMAIN: { + # REST configuration + "platform": "rest", + "method": "GET", + "resource": "http://localhost", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "device_class": "temperature", + "name": "{{'REST' + ' ' + 'Sensor'}}", + "state_class": "measurement", + "unique_id": "very_unique", + "unit_of_measurement": "beardsecond", + }, + } + + respx.get("http://localhost") % HTTPStatus.OK + assert await async_setup_component(hass, DOMAIN, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert entity_registry.async_get("sensor.rest_sensor").unique_id == "very_unique" + + state = hass.states.get("sensor.rest_sensor") + assert state.state == "" + assert state.attributes == { + "device_class": "temperature", + "entity_picture": "blabla.png", + "friendly_name": "REST Sensor", + "icon": "mdi:one_two_three", + "state_class": "measurement", + "unit_of_measurement": "beardsecond", + } From e74c711ef381ef3f95fe4f66cfe759260c2762b7 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 8 Jun 2022 07:09:32 -0700 Subject: [PATCH 302/947] Add application credentials description strings (#73014) --- .../application_credentials/__init__.py | 24 ++++++++++++++++--- .../google/application_credentials.py | 9 +++++++ homeassistant/components/google/strings.json | 3 +++ .../components/google/translations/en.json | 3 +++ script/hassfest/translations.py | 3 +++ .../application_credentials/test_init.py | 6 ++++- 6 files changed, 44 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index 1a128c5c378..14ae049cfca 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -253,6 +253,11 @@ class ApplicationCredentialsProtocol(Protocol): ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: """Return a custom auth implementation.""" + async def async_get_description_placeholders( + self, hass: HomeAssistant + ) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + async def _get_platform( hass: HomeAssistant, integration_domain: str @@ -282,6 +287,14 @@ async def _get_platform( return platform +async def _async_integration_config(hass: HomeAssistant, domain: str) -> dict[str, Any]: + platform = await _get_platform(hass, domain) + if platform and hasattr(platform, "async_get_description_placeholders"): + placeholders = await platform.async_get_description_placeholders(hass) + return {"description_placeholders": placeholders} + return {} + + @websocket_api.websocket_command( {vol.Required("type"): "application_credentials/config"} ) @@ -290,6 +303,11 @@ async def handle_integration_list( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle integrations command.""" - connection.send_result( - msg["id"], {"domains": await async_get_application_credentials(hass)} - ) + domains = await async_get_application_credentials(hass) + result = { + "domains": domains, + "integrations": { + domain: await _async_integration_config(hass, domain) for domain in domains + }, + } + connection.send_result(msg["id"], result) diff --git a/homeassistant/components/google/application_credentials.py b/homeassistant/components/google/application_credentials.py index 2f1fcba8084..3d557630b05 100644 --- a/homeassistant/components/google/application_credentials.py +++ b/homeassistant/components/google/application_credentials.py @@ -21,3 +21,12 @@ async def async_get_auth_implementation( ) -> config_entry_oauth2_flow.AbstractOAuth2Implementation: """Return auth implementation.""" return DeviceAuth(hass, auth_domain, credential, AUTHORIZATION_SERVER) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/google/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + } diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index e32223627be..6652806cd0f 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -36,5 +36,8 @@ } } } + }, + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" } } diff --git a/homeassistant/components/google/translations/en.json b/homeassistant/components/google/translations/en.json index 58c89834ca5..54936b0d81c 100644 --- a/homeassistant/components/google/translations/en.json +++ b/homeassistant/components/google/translations/en.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) page and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" + }, "config": { "abort": { "already_configured": "Account is already configured", diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py index 0dcdbc133a6..a1f520808f6 100644 --- a/script/hassfest/translations.py +++ b/script/hassfest/translations.py @@ -224,6 +224,9 @@ def gen_strings_schema(config: Config, integration: Integration): ), slug_validator=vol.Any("_", cv.slug), ), + vol.Optional("application_credentials"): { + vol.Optional("description"): cv.string_with_no_html, + }, } ) diff --git a/tests/components/application_credentials/test_init.py b/tests/components/application_credentials/test_init.py index b89a60f42e4..dd5995a8f4f 100644 --- a/tests/components/application_credentials/test_init.py +++ b/tests/components/application_credentials/test_init.py @@ -701,7 +701,11 @@ async def test_websocket_integration_list(ws_client: ClientFixture): "homeassistant.loader.APPLICATION_CREDENTIALS", ["example1", "example2"] ): assert await client.cmd_result("config") == { - "domains": ["example1", "example2"] + "domains": ["example1", "example2"], + "integrations": { + "example1": {}, + "example2": {}, + }, } From 0dc1e7d1e64a0172c49a1114f811797283c12de7 Mon Sep 17 00:00:00 2001 From: b3nj1 Date: Wed, 8 Jun 2022 07:43:24 -0700 Subject: [PATCH 303/947] Fix VeSync device to match pyvesync type (#73034) * vesync: change device to match pyvesync type * MartinHjelmare's suggestion for derived classes Co-authored-by: Martin Hjelmare * MartinHjelmare's suggestion for derived classes Co-authored-by: Martin Hjelmare * MartinHjelmare's suggestion for derived classes Co-authored-by: Martin Hjelmare * MartinHjelmare's suggestion for annotations * vesync: fix imports Co-authored-by: Martin Hjelmare --- homeassistant/components/vesync/sensor.py | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 24ba6f2f0a0..6e0c09b2f60 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -1,8 +1,14 @@ """Support for power & energy sensors for VeSync outlets.""" +from __future__ import annotations + from collections.abc import Callable from dataclasses import dataclass import logging +from pyvesync.vesyncfan import VeSyncAirBypass +from pyvesync.vesyncoutlet import VeSyncOutlet +from pyvesync.vesyncswitch import VeSyncSwitch + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -22,7 +28,7 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .common import VeSyncBaseEntity, VeSyncDevice +from .common import VeSyncBaseEntity from .const import DEV_TYPE_TO_HA, DOMAIN, SKU_TO_BASE_DEVICE, VS_DISCOVERY, VS_SENSORS _LOGGER = logging.getLogger(__name__) @@ -32,7 +38,7 @@ _LOGGER = logging.getLogger(__name__) class VeSyncSensorEntityDescriptionMixin: """Mixin for required keys.""" - value_fn: Callable[[VeSyncDevice], StateType] + value_fn: Callable[[VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], StateType] @dataclass @@ -41,8 +47,12 @@ class VeSyncSensorEntityDescription( ): """Describe VeSync sensor entity.""" - exists_fn: Callable[[VeSyncDevice], bool] = lambda _: True - update_fn: Callable[[VeSyncDevice], None] = lambda _: None + exists_fn: Callable[ + [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], bool + ] = lambda _: True + update_fn: Callable[ + [VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch], None + ] = lambda _: None def update_energy(device): @@ -107,7 +117,7 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, state_class=SensorStateClass.TOTAL_INCREASING, - value_fn=lambda device: device.details["energy"], + value_fn=lambda device: device.energy_today, update_fn=update_energy, exists_fn=lambda device: ha_dev_type(device) == "outlet", ), @@ -151,7 +161,7 @@ class VeSyncSensorEntity(VeSyncBaseEntity, SensorEntity): def __init__( self, - device: VeSyncDevice, + device: VeSyncAirBypass | VeSyncOutlet | VeSyncSwitch, description: VeSyncSensorEntityDescription, ) -> None: """Initialize the VeSync outlet device.""" From 56d28e13f7808a657c1637e584431d8913e7f492 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 8 Jun 2022 20:12:44 +0200 Subject: [PATCH 304/947] Update apprise to 0.9.9 (#73218) --- homeassistant/components/apprise/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index b4422a49ef4..450e0a964df 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -2,7 +2,7 @@ "domain": "apprise", "name": "Apprise", "documentation": "https://www.home-assistant.io/integrations/apprise", - "requirements": ["apprise==0.9.8.3"], + "requirements": ["apprise==0.9.9"], "codeowners": ["@caronc"], "iot_class": "cloud_push", "loggers": ["apprise"] diff --git a/requirements_all.txt b/requirements_all.txt index b72237ffdf1..866615632f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -313,7 +313,7 @@ anthemav==1.2.0 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==0.9.8.3 +apprise==0.9.9 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e44767eec0..fd396ce2564 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -267,7 +267,7 @@ ambiclimate==0.2.1 androidtv[async]==0.0.67 # homeassistant.components.apprise -apprise==0.9.8.3 +apprise==0.9.9 # homeassistant.components.aprs aprslib==0.7.0 From b4a5abce16a52b44626dcafd2b992916445d705e Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 8 Jun 2022 14:15:07 -0400 Subject: [PATCH 305/947] Clean up phone modem (#73181) --- homeassistant/components/modem_callerid/sensor.py | 4 ---- homeassistant/components/modem_callerid/services.yaml | 7 ------- 2 files changed, 11 deletions(-) delete mode 100644 homeassistant/components/modem_callerid/services.yaml diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py index e50eabb17aa..4f84abd4533 100644 --- a/homeassistant/components/modem_callerid/sensor.py +++ b/homeassistant/components/modem_callerid/sensor.py @@ -1,8 +1,6 @@ """A sensor for incoming calls using a USB modem that supports caller ID.""" from __future__ import annotations -import logging - from phone_modem import PhoneModem from homeassistant.components.sensor import SensorEntity @@ -13,8 +11,6 @@ from homeassistant.helpers import entity_platform from .const import CID, DATA_KEY_API, DOMAIN, ICON -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/modem_callerid/services.yaml b/homeassistant/components/modem_callerid/services.yaml deleted file mode 100644 index 7ec8aaf3f94..00000000000 --- a/homeassistant/components/modem_callerid/services.yaml +++ /dev/null @@ -1,7 +0,0 @@ -reject_call: - name: Reject Call - description: Reject incoming call. - target: - entity: - integration: modem_callerid - domain: sensor From f7bd88c952d398fa4e3b8d2510aa61e21db8007a Mon Sep 17 00:00:00 2001 From: d0nni3q84 <62199227+d0nni3q84@users.noreply.github.com> Date: Wed, 8 Jun 2022 13:32:01 -0500 Subject: [PATCH 306/947] Fix Feedreader Atom feeds using `updated` date (#73208) * Feedreader: Properly support Atom feeds that use only the `updated` date format and resolve #73207. * Revert "Feedreader: Properly support Atom feeds that use only the `updated` date format and resolve #73207." This reverts commit 4dbd11ee04b4e8f935a22dfb51405b7bdaaba676. * Properly support Atom feeds that use only the `updated` date format and resolve #73207. * Revert "Properly support Atom feeds that use only the `updated` date format and resolve #73207." This reverts commit 14366c6a2491584282b8bb96fe3779fd41849897. * Properly support Atom feeds that use only the `updated` date format and resolve #73207. --- .../components/feedreader/__init__.py | 31 ++++++++++++++---- tests/components/feedreader/test_init.py | 32 ++++++++++++++++++- tests/fixtures/feedreader5.xml | 18 +++++++++++ 3 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 tests/fixtures/feedreader5.xml diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 11a3d4b0498..b3f1a916012 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -70,6 +70,7 @@ class FeedManager: self._last_entry_timestamp = None self._last_update_successful = False self._has_published_parsed = False + self._has_updated_parsed = False self._event_type = EVENT_FEEDREADER self._feed_id = url hass.bus.listen_once(EVENT_HOMEASSISTANT_START, lambda _: self._update()) @@ -122,7 +123,7 @@ class FeedManager: ) self._filter_entries() self._publish_new_entries() - if self._has_published_parsed: + if self._has_published_parsed or self._has_updated_parsed: self._storage.put_timestamp( self._feed_id, self._last_entry_timestamp ) @@ -143,7 +144,7 @@ class FeedManager: def _update_and_fire_entry(self, entry): """Update last_entry_timestamp and fire entry.""" - # Check if the entry has a published date. + # Check if the entry has a published or updated date. if "published_parsed" in entry and entry.published_parsed: # We are lucky, `published_parsed` data available, let's make use of # it to publish only new available entries since the last run @@ -151,9 +152,20 @@ class FeedManager: self._last_entry_timestamp = max( entry.published_parsed, self._last_entry_timestamp ) + elif "updated_parsed" in entry and entry.updated_parsed: + # We are lucky, `updated_parsed` data available, let's make use of + # it to publish only new available entries since the last run + self._has_updated_parsed = True + self._last_entry_timestamp = max( + entry.updated_parsed, self._last_entry_timestamp + ) else: self._has_published_parsed = False - _LOGGER.debug("No published_parsed info available for entry %s", entry) + self._has_updated_parsed = False + _LOGGER.debug( + "No published_parsed or updated_parsed info available for entry %s", + entry, + ) entry.update({"feed_url": self._url}) self._hass.bus.fire(self._event_type, entry) @@ -167,9 +179,16 @@ class FeedManager: # Set last entry timestamp as epoch time if not available self._last_entry_timestamp = datetime.utcfromtimestamp(0).timetuple() for entry in self._feed.entries: - if self._firstrun or ( - "published_parsed" in entry - and entry.published_parsed > self._last_entry_timestamp + if ( + self._firstrun + or ( + "published_parsed" in entry + and entry.published_parsed > self._last_entry_timestamp + ) + or ( + "updated_parsed" in entry + and entry.updated_parsed > self._last_entry_timestamp + ) ): self._update_and_fire_entry(entry) new_entries = True diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 34f27c36a6c..be5ebb42a6d 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -23,6 +23,7 @@ VALID_CONFIG_1 = {feedreader.DOMAIN: {CONF_URLS: [URL]}} VALID_CONFIG_2 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_SCAN_INTERVAL: 60}} VALID_CONFIG_3 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 100}} VALID_CONFIG_4 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 5}} +VALID_CONFIG_5 = {feedreader.DOMAIN: {CONF_URLS: [URL], CONF_MAX_ENTRIES: 1}} def load_fixture_bytes(src): @@ -56,6 +57,12 @@ def fixture_feed_three_events(hass): return load_fixture_bytes("feedreader3.xml") +@pytest.fixture(name="feed_atom_event") +def fixture_feed_atom_event(hass): + """Load test feed data for atom event.""" + return load_fixture_bytes("feedreader5.xml") + + @pytest.fixture(name="events") async def fixture_events(hass): """Fixture that catches alexa events.""" @@ -98,7 +105,7 @@ async def test_setup_max_entries(hass): async def test_feed(hass, events, feed_one_event): - """Test simple feed with valid data.""" + """Test simple rss feed with valid data.""" with patch( "feedparser.http.get", return_value=feed_one_event, @@ -120,6 +127,29 @@ async def test_feed(hass, events, feed_one_event): assert events[0].data.published_parsed.tm_min == 10 +async def test_atom_feed(hass, events, feed_atom_event): + """Test simple atom feed with valid data.""" + with patch( + "feedparser.http.get", + return_value=feed_atom_event, + ): + assert await async_setup_component(hass, feedreader.DOMAIN, VALID_CONFIG_5) + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].data.title == "Atom-Powered Robots Run Amok" + assert events[0].data.description == "Some text." + assert events[0].data.link == "http://example.org/2003/12/13/atom03" + assert events[0].data.id == "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a" + assert events[0].data.updated_parsed.tm_year == 2003 + assert events[0].data.updated_parsed.tm_mon == 12 + assert events[0].data.updated_parsed.tm_mday == 13 + assert events[0].data.updated_parsed.tm_hour == 18 + assert events[0].data.updated_parsed.tm_min == 30 + + async def test_feed_updates(hass, events, feed_one_event, feed_two_event): """Test feed updates.""" side_effect = [ diff --git a/tests/fixtures/feedreader5.xml b/tests/fixtures/feedreader5.xml new file mode 100644 index 00000000000..d9b1dda1ad2 --- /dev/null +++ b/tests/fixtures/feedreader5.xml @@ -0,0 +1,18 @@ + + + Example Feed + + 2003-12-13T18:30:02Z + + John Doe + + urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6 + + Atom-Powered Robots Run Amok + + urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a + 2003-12-13T18:30:02Z + Some text. + + From 921245a490a4aa91fe24751c2feb218fffdf5545 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Jun 2022 20:47:47 +0200 Subject: [PATCH 307/947] Remove deprecated temperature conversion of non sensors (#73222) --- homeassistant/helpers/entity.py | 66 +-------------------------------- tests/helpers/test_entity.py | 59 ----------------------------- 2 files changed, 1 insertion(+), 124 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 1c03e2334fe..39af16892f5 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -32,17 +32,8 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.core import ( - CALLBACK_TYPE, - Context, - Event, - HomeAssistant, - callback, - split_entity_id, ) +from homeassistant.core import CALLBACK_TYPE, Context, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify @@ -259,9 +250,6 @@ class Entity(ABC): # If we reported this entity is updated while disabled _disabled_reported = False - # If we reported this entity is relying on deprecated temperature conversion - _temperature_reported = False - # Protect for multiple updates _update_staged = False @@ -618,58 +606,6 @@ class Entity(ABC): if DATA_CUSTOMIZE in self.hass.data: attr.update(self.hass.data[DATA_CUSTOMIZE].get(self.entity_id)) - def _convert_temperature(state: str, attr: dict[str, Any]) -> str: - # Convert temperature if we detect one - # pylint: disable-next=import-outside-toplevel - from homeassistant.components.sensor import SensorEntity - - unit_of_measure = attr.get(ATTR_UNIT_OF_MEASUREMENT) - units = self.hass.config.units - if unit_of_measure == units.temperature_unit or unit_of_measure not in ( - TEMP_CELSIUS, - TEMP_FAHRENHEIT, - ): - return state - - domain = split_entity_id(self.entity_id)[0] - if domain != "sensor": - if not self._temperature_reported: - self._temperature_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Entity %s (%s) relies on automatic temperature conversion, this will " - "be unsupported in Home Assistant Core 2022.7. Please %s", - self.entity_id, - type(self), - report_issue, - ) - elif not isinstance(self, SensorEntity): - if not self._temperature_reported: - self._temperature_reported = True - report_issue = self._suggest_report_issue() - _LOGGER.warning( - "Temperature sensor %s (%s) does not inherit SensorEntity, " - "this will be unsupported in Home Assistant Core 2022.7." - "Please %s", - self.entity_id, - type(self), - report_issue, - ) - else: - return state - - try: - prec = len(state) - state.index(".") - 1 if "." in state else 0 - temp = units.temperature(float(state), unit_of_measure) - state = str(round(temp) if prec == 0 else round(temp, prec)) - attr[ATTR_UNIT_OF_MEASUREMENT] = units.temperature_unit - except ValueError: - # Could not convert state to float - pass - return state - - state = _convert_temperature(state, attr) - if ( self._context_set is not None and dt_util.utcnow() - self._context_set > self.context_recent_time diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index e345d7d7258..7141c5f0903 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -14,7 +14,6 @@ from homeassistant.const import ( ATTR_DEVICE_CLASS, STATE_UNAVAILABLE, STATE_UNKNOWN, - TEMP_FAHRENHEIT, ) from homeassistant.core import Context, HomeAssistantError from homeassistant.helpers import entity, entity_registry @@ -816,64 +815,6 @@ async def test_float_conversion(hass): assert state.state == "3.6" -async def test_temperature_conversion(hass, caplog): - """Test conversion of temperatures.""" - # Non sensor entity reporting a temperature - with patch.object( - entity.Entity, "state", PropertyMock(return_value=100) - ), patch.object( - entity.Entity, "unit_of_measurement", PropertyMock(return_value=TEMP_FAHRENHEIT) - ): - ent = entity.Entity() - ent.hass = hass - ent.entity_id = "hello.world" - ent.async_write_ha_state() - - state = hass.states.get("hello.world") - assert state is not None - assert state.state == "38" - assert ( - "Entity hello.world () relies on automatic " - "temperature conversion, this will be unsupported in Home Assistant Core 2022.7. " - "Please create a bug report" in caplog.text - ) - - # Sensor entity, not extending SensorEntity, reporting a temperature - with patch.object( - entity.Entity, "state", PropertyMock(return_value=100) - ), patch.object( - entity.Entity, "unit_of_measurement", PropertyMock(return_value=TEMP_FAHRENHEIT) - ): - ent = entity.Entity() - ent.hass = hass - ent.entity_id = "sensor.temp" - ent.async_write_ha_state() - - state = hass.states.get("sensor.temp") - assert state is not None - assert state.state == "38" - assert ( - "Temperature sensor sensor.temp () " - "does not inherit SensorEntity, this will be unsupported in Home Assistant Core " - "2022.7.Please create a bug report" in caplog.text - ) - - # Sensor entity, not extending SensorEntity, not reporting a number - with patch.object( - entity.Entity, "state", PropertyMock(return_value="really warm") - ), patch.object( - entity.Entity, "unit_of_measurement", PropertyMock(return_value=TEMP_FAHRENHEIT) - ): - ent = entity.Entity() - ent.hass = hass - ent.entity_id = "sensor.temp" - ent.async_write_ha_state() - - state = hass.states.get("sensor.temp") - assert state is not None - assert state.state == "really warm" - - async def test_attribution_attribute(hass): """Test attribution attribute.""" mock_entity = entity.Entity() From 6bf219550ef549289e775201408a3242e6795443 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Jun 2022 21:27:40 +0200 Subject: [PATCH 308/947] Cleanup some code in SensorEntity (#73241) --- homeassistant/components/sensor/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6a69c27f9b6..2bbcf3b119d 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -426,7 +426,7 @@ class SensorEntity(Entity): if ( value is not None and native_unit_of_measurement != unit_of_measurement - and self.device_class in UNIT_CONVERSIONS + and device_class in UNIT_CONVERSIONS ): assert unit_of_measurement assert native_unit_of_measurement @@ -439,8 +439,8 @@ class SensorEntity(Entity): ratio_log = max( 0, log10( - UNIT_RATIOS[self.device_class][native_unit_of_measurement] - / UNIT_RATIOS[self.device_class][unit_of_measurement] + UNIT_RATIOS[device_class][native_unit_of_measurement] + / UNIT_RATIOS[device_class][unit_of_measurement] ), ) prec = prec + floor(ratio_log) @@ -448,7 +448,7 @@ class SensorEntity(Entity): # Suppress ValueError (Could not convert sensor_value to float) with suppress(ValueError): value_f = float(value) # type: ignore[arg-type] - value_f_new = UNIT_CONVERSIONS[self.device_class]( + value_f_new = UNIT_CONVERSIONS[device_class]( value_f, native_unit_of_measurement, unit_of_measurement, From 4435c641decd0269e03ba752c35e0aca468c1ab3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 8 Jun 2022 21:36:43 +0200 Subject: [PATCH 309/947] Enforce RegistryEntryHider in entity registry (#73219) --- homeassistant/helpers/entity_registry.py | 12 +++++- tests/components/group/test_config_flow.py | 5 ++- tests/components/group/test_init.py | 10 ++--- .../switch_as_x/test_config_flow.py | 4 +- tests/components/switch_as_x/test_init.py | 4 +- tests/helpers/test_entity_registry.py | 42 ++++++++++++++++--- 6 files changed, 60 insertions(+), 17 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d03d272b1ac..eb5590b7fdf 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -369,6 +369,8 @@ class EntityRegistry: if disabled_by and not isinstance(disabled_by, RegistryEntryDisabler): raise ValueError("disabled_by must be a RegistryEntryDisabler value") + if hidden_by and not isinstance(hidden_by, RegistryEntryHider): + raise ValueError("hidden_by must be a RegistryEntryHider value") if ( disabled_by is None @@ -520,6 +522,12 @@ class EntityRegistry: and not isinstance(disabled_by, RegistryEntryDisabler) ): raise ValueError("disabled_by must be a RegistryEntryDisabler value") + if ( + hidden_by + and hidden_by is not UNDEFINED + and not isinstance(hidden_by, RegistryEntryHider) + ): + raise ValueError("hidden_by must be a RegistryEntryHider value") from .entity import EntityCategory # pylint: disable=import-outside-toplevel @@ -729,7 +737,9 @@ class EntityRegistry: if entity["entity_category"] else None, entity_id=entity["entity_id"], - hidden_by=entity["hidden_by"], + hidden_by=RegistryEntryHider(entity["hidden_by"]) + if entity["hidden_by"] + else None, icon=entity["icon"], id=entity["id"], name=entity["name"], diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index 83741a2e851..9d6b099557d 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -348,7 +348,10 @@ async def test_all_options( @pytest.mark.parametrize( "hide_members,hidden_by_initial,hidden_by", - ((False, "integration", None), (True, None, "integration")), + ( + (False, er.RegistryEntryHider.INTEGRATION, None), + (True, None, er.RegistryEntryHider.INTEGRATION), + ), ) @pytest.mark.parametrize( "group_type,extra_input", diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 56553ff263c..945f6555789 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -1421,12 +1421,12 @@ async def test_setup_and_remove_config_entry( @pytest.mark.parametrize( "hide_members,hidden_by_initial,hidden_by", ( - (False, "integration", "integration"), + (False, er.RegistryEntryHider.INTEGRATION, er.RegistryEntryHider.INTEGRATION), (False, None, None), - (False, "user", "user"), - (True, "integration", None), + (False, er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), + (True, er.RegistryEntryHider.INTEGRATION, None), (True, None, None), - (True, "user", "user"), + (True, er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), ), ) @pytest.mark.parametrize( @@ -1444,7 +1444,7 @@ async def test_unhide_members_on_remove( group_type: str, extra_options: dict[str, Any], hide_members: bool, - hidden_by_initial: str, + hidden_by_initial: er.RegistryEntryHider, hidden_by: str, ) -> None: """Test removing a config entry.""" diff --git a/tests/components/switch_as_x/test_config_flow.py b/tests/components/switch_as_x/test_config_flow.py index dc4ca96aa97..d80f7e24bb1 100644 --- a/tests/components/switch_as_x/test_config_flow.py +++ b/tests/components/switch_as_x/test_config_flow.py @@ -65,8 +65,8 @@ async def test_config_flow( @pytest.mark.parametrize( "hidden_by_before,hidden_by_after", ( - (er.RegistryEntryHider.USER.value, er.RegistryEntryHider.USER.value), - (None, er.RegistryEntryHider.INTEGRATION.value), + (er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), + (None, er.RegistryEntryHider.INTEGRATION), ), ) @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/components/switch_as_x/test_init.py b/tests/components/switch_as_x/test_init.py index e2b875b813b..9c3eec1884c 100644 --- a/tests/components/switch_as_x/test_init.py +++ b/tests/components/switch_as_x/test_init.py @@ -365,8 +365,8 @@ async def test_setup_and_remove_config_entry( @pytest.mark.parametrize( "hidden_by_before,hidden_by_after", ( - (er.RegistryEntryHider.USER.value, er.RegistryEntryHider.USER.value), - (er.RegistryEntryHider.INTEGRATION.value, None), + (er.RegistryEntryHider.USER, er.RegistryEntryHider.USER), + (er.RegistryEntryHider.INTEGRATION, None), ), ) @pytest.mark.parametrize("target_domain", PLATFORMS_TO_TEST) diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 21d29736bd0..8f5b4a7d333 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -79,6 +79,7 @@ def test_get_or_create_updates_data(registry): device_id="mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", @@ -97,6 +98,7 @@ def test_get_or_create_updates_data(registry): device_id="mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, icon=None, id=orig_entry.id, name=None, @@ -119,6 +121,7 @@ def test_get_or_create_updates_data(registry): device_id="new-mock-dev-id", disabled_by=er.RegistryEntryDisabler.USER, entity_category=None, + hidden_by=er.RegistryEntryHider.USER, original_device_class="new-mock-device-class", original_icon="updated-original_icon", original_name="updated-original_name", @@ -137,6 +140,7 @@ def test_get_or_create_updates_data(registry): device_id="new-mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, # Should not be updated entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, # Should not be updated icon=None, id=orig_entry.id, name=None, @@ -191,6 +195,7 @@ async def test_loading_saving_data(hass, registry): device_id="mock-dev-id", disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, + hidden_by=er.RegistryEntryHider.INTEGRATION, original_device_class="mock-device-class", original_icon="hass:original-icon", original_name="Original Name", @@ -231,6 +236,7 @@ async def test_loading_saving_data(hass, registry): assert new_entry2.disabled_by is er.RegistryEntryDisabler.HASS assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" + assert new_entry2.hidden_by == er.RegistryEntryHider.INTEGRATION assert new_entry2.name == "User Name" assert new_entry2.options == {"light": {"minimum_brightness": 20}} assert new_entry2.original_device_class == "mock-device-class" @@ -261,8 +267,8 @@ def test_is_registered(registry): @pytest.mark.parametrize("load_registries", [False]) -async def test_loading_extra_values(hass, hass_storage): - """Test we load extra data from the registry.""" +async def test_filter_on_load(hass, hass_storage): + """Test we transform some data when loading from storage.""" hass_storage[er.STORAGE_KEY] = { "version": er.STORAGE_VERSION_MAJOR, "minor_version": 1, @@ -274,6 +280,7 @@ async def test_loading_extra_values(hass, hass_storage): "unique_id": "with-name", "name": "registry override", }, + # This entity's name should be None { "entity_id": "test.no_name", "platform": "super_platform", @@ -283,20 +290,22 @@ async def test_loading_extra_values(hass, hass_storage): "entity_id": "test.disabled_user", "platform": "super_platform", "unique_id": "disabled-user", - "disabled_by": er.RegistryEntryDisabler.USER, + "disabled_by": "user", # We store the string representation }, { "entity_id": "test.disabled_hass", "platform": "super_platform", "unique_id": "disabled-hass", - "disabled_by": er.RegistryEntryDisabler.HASS, + "disabled_by": "hass", # We store the string representation }, + # This entry should not be loaded because the entity_id is invalid { "entity_id": "test.invalid__entity", "platform": "super_platform", "unique_id": "invalid-hass", - "disabled_by": er.RegistryEntryDisabler.HASS, + "disabled_by": "hass", # We store the string representation }, + # This entry should have the entity_category reset to None { "entity_id": "test.system_entity", "platform": "super_platform", @@ -311,6 +320,13 @@ async def test_loading_extra_values(hass, hass_storage): registry = er.async_get(hass) assert len(registry.entities) == 5 + assert set(registry.entities.keys()) == { + "test.disabled_hass", + "test.disabled_user", + "test.named", + "test.no_name", + "test.system_entity", + } entry_with_name = registry.async_get_or_create( "test", "super_platform", "with-name" @@ -1221,7 +1237,7 @@ def test_entity_registry_items(): async def test_disabled_by_str_not_allowed(hass): - """Test we need to pass entity category type.""" + """Test we need to pass disabled by type.""" reg = er.async_get(hass) with pytest.raises(ValueError): @@ -1252,6 +1268,20 @@ async def test_entity_category_str_not_allowed(hass): ) +async def test_hidden_by_str_not_allowed(hass): + """Test we need to pass hidden by type.""" + reg = er.async_get(hass) + + with pytest.raises(ValueError): + reg.async_get_or_create( + "light", "hue", "1234", hidden_by=er.RegistryEntryHider.USER.value + ) + + entity_id = reg.async_get_or_create("light", "hue", "1234").entity_id + with pytest.raises(ValueError): + reg.async_update_entity(entity_id, hidden_by=er.RegistryEntryHider.USER.value) + + def test_migrate_entity_to_new_platform(hass, registry): """Test migrate_entity_to_new_platform.""" orig_config_entry = MockConfigEntry(domain="light") From 4c45cb5c5252eebbd00375677ff20fd8e62a2114 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 8 Jun 2022 18:29:46 -0400 Subject: [PATCH 310/947] Add UniFi Protect chime button/camera switch (#73195) --- .../components/unifiprotect/button.py | 15 +++++- .../components/unifiprotect/camera.py | 12 ++++- .../components/unifiprotect/switch.py | 8 ++++ tests/components/unifiprotect/test_camera.py | 47 ++++++++++++++++++- tests/components/unifiprotect/test_switch.py | 6 +-- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 3728b6b4224..9ed5ecc4967 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -43,6 +43,15 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ), ) +SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( + ProtectButtonEntityDescription( + key="clear_tamper", + name="Clear Tamper", + icon="mdi:notification-clear-all", + ufp_press="clear_tamper", + ), +) + CHIME_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( ProtectButtonEntityDescription( key="play", @@ -69,7 +78,11 @@ async def async_setup_entry( data: ProtectData = hass.data[DOMAIN][entry.entry_id] entities: list[ProtectDeviceEntity] = async_all_device_entities( - data, ProtectButton, all_descs=ALL_DEVICE_BUTTONS, chime_descs=CHIME_BUTTONS + data, + ProtectButton, + all_descs=ALL_DEVICE_BUTTONS, + chime_descs=CHIME_BUTTONS, + sense_descs=SENSOR_BUTTONS, ) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index ca076e490a2..8020d5e8aab 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -140,9 +140,9 @@ class ProtectCamera(ProtectDeviceEntity, Camera): def _async_update_device_from_protect(self) -> None: super()._async_update_device_from_protect() self.channel = self.device.channels[self.channel.id] + motion_enabled = self.device.recording_settings.enable_motion_detection self._attr_motion_detection_enabled = ( - self.device.state == StateType.CONNECTED - and self.device.feature_flags.has_motion_zones + motion_enabled if motion_enabled is not None else True ) self._attr_is_recording = ( self.device.state == StateType.CONNECTED and self.device.is_recording @@ -171,3 +171,11 @@ class ProtectCamera(ProtectDeviceEntity, Camera): async def stream_source(self) -> str | None: """Return the Stream Source.""" return self._stream_source + + async def async_enable_motion_detection(self) -> None: + """Call the job and enable motion detection.""" + await self.device.set_motion_detection(True) + + async def async_disable_motion_detection(self) -> None: + """Call the job and disable motion detection.""" + await self.device.set_motion_detection(False) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 971c637a8c2..d8542da2f7f 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -133,6 +133,14 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_value="osd_settings.is_debug_enabled", ufp_set_method="set_osd_bitrate", ), + ProtectSwitchEntityDescription( + key="motion", + name="Detections: Motion", + icon="mdi:run-fast", + entity_category=EntityCategory.CONFIG, + ufp_value="recording_settings.enable_motion_detection", + ufp_set_method="set_motion_detection", + ), ProtectSwitchEntityDescription( key="smart_person", name="Detections: Person", diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index d7fc2a62325..538b3bf7652 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -48,6 +48,9 @@ async def camera_fixture( ): """Fixture for a single camera for testing the camera platform.""" + # disable pydantic validation so mocking can happen + ProtectCamera.__config__.validate_assignment = False + camera_obj = mock_camera.copy(deep=True) camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api @@ -68,7 +71,9 @@ async def camera_fixture( assert_entity_counts(hass, Platform.CAMERA, 2, 1) - return (camera_obj, "camera.test_camera_high") + yield (camera_obj, "camera.test_camera_high") + + ProtectCamera.__config__.validate_assignment = True @pytest.fixture(name="camera_package") @@ -572,3 +577,43 @@ async def test_camera_ws_update_offline( state = hass.states.get(camera[1]) assert state and state.state == "idle" + + +async def test_camera_enable_motion( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[ProtectCamera, str], +): + """Tests generic entity update service.""" + + camera[0].__fields__["set_motion_detection"] = Mock() + camera[0].set_motion_detection = AsyncMock() + + await hass.services.async_call( + "camera", + "enable_motion_detection", + {ATTR_ENTITY_ID: camera[1]}, + blocking=True, + ) + + camera[0].set_motion_detection.assert_called_once_with(True) + + +async def test_camera_disable_motion( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + camera: tuple[ProtectCamera, str], +): + """Tests generic entity update service.""" + + camera[0].__fields__["set_motion_detection"] = Mock() + camera[0].set_motion_detection = AsyncMock() + + await hass.services.async_call( + "camera", + "disable_motion_detection", + {ATTR_ENTITY_ID: camera[1]}, + blocking=True, + ) + + camera[0].set_motion_detection.assert_called_once_with(False) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 7918ea0b6cf..bc0c8387c29 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -118,7 +118,7 @@ async def camera_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.SWITCH, 12, 11) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) yield camera_obj @@ -161,7 +161,7 @@ async def camera_none_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.SWITCH, 5, 4) + assert_entity_counts(hass, Platform.SWITCH, 6, 5) yield camera_obj @@ -205,7 +205,7 @@ async def camera_privacy_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.SWITCH, 6, 5) + assert_entity_counts(hass, Platform.SWITCH, 7, 6) yield camera_obj From 8af0d91676f118c400037c0354de3e5812cd55d4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 8 Jun 2022 16:31:39 -0600 Subject: [PATCH 311/947] Bump regenmaschine to 2022.06.1 (#73250) --- homeassistant/components/rainmachine/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json index a61283ea298..e9df60e4697 100644 --- a/homeassistant/components/rainmachine/manifest.json +++ b/homeassistant/components/rainmachine/manifest.json @@ -3,7 +3,7 @@ "name": "RainMachine", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rainmachine", - "requirements": ["regenmaschine==2022.06.0"], + "requirements": ["regenmaschine==2022.06.1"], "codeowners": ["@bachya"], "iot_class": "local_polling", "homekit": { diff --git a/requirements_all.txt b/requirements_all.txt index 866615632f3..a1a0b387dac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2065,7 +2065,7 @@ raincloudy==0.0.7 raspyrfm-client==1.2.8 # homeassistant.components.rainmachine -regenmaschine==2022.06.0 +regenmaschine==2022.06.1 # homeassistant.components.renault renault-api==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd396ce2564..17c29461b31 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ radios==0.1.1 radiotherm==2.1.0 # homeassistant.components.rainmachine -regenmaschine==2022.06.0 +regenmaschine==2022.06.1 # homeassistant.components.renault renault-api==0.1.11 From 5c49d0a761c3ae52c50aed5cae57c396f95e0c01 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 8 Jun 2022 19:58:06 -0400 Subject: [PATCH 312/947] Bumps version of pyunifiprotect to 3.9.1 (#73252) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 52f69abea00..a27c0125da3 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.9.0", "unifi-discovery==1.1.3"], + "requirements": ["pyunifiprotect==3.9.1", "unifi-discovery==1.1.3"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index a1a0b387dac..c1cd7f05b64 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,7 +1993,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.0 +pyunifiprotect==3.9.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17c29461b31..9d380d8bf96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1322,7 +1322,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.0 +pyunifiprotect==3.9.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 004ff8fb30b0dbf22abab9dbcd94304954431e27 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 8 Jun 2022 20:13:56 -0400 Subject: [PATCH 313/947] Overhaul UniFi Protect NVR Disk sensors (#73197) * Overhauls NVR Disk sensors * Updates from latest version of pyunifiprotect --- .../components/unifiprotect/binary_sensor.py | 47 ++++-- .../unifiprotect/fixtures/sample_nvr.json | 152 ++++++++++++++++++ .../unifiprotect/test_binary_sensor.py | 10 +- 3 files changed, 187 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 7613ffb8ebf..34c1119eb60 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -6,6 +6,7 @@ from dataclasses import dataclass import logging from pyunifiprotect.data import NVR, Camera, Event, Light, MountType, Sensor +from pyunifiprotect.data.nvr import UOSDisk from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -13,7 +14,6 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODEL from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -131,7 +131,6 @@ DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( DISK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ProtectBinaryEntityDescription( key="disk_health", - name="Disk {index} Health", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -182,14 +181,18 @@ def _async_nvr_entities( ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] device = data.api.bootstrap.nvr - for index, _ in enumerate(device.system_info.storage.devices): + if device.system_info.ustorage is None: + return entities + + for disk in device.system_info.ustorage.disks: for description in DISK_SENSORS: - entities.append( - ProtectDiskBinarySensor(data, device, description, index=index) - ) + if not disk.has_disk: + continue + + entities.append(ProtectDiskBinarySensor(data, device, description, disk)) _LOGGER.debug( "Adding binary sensor entity %s", - (description.name or "{index}").format(index=index), + f"{disk.type} {disk.slot}", ) return entities @@ -216,6 +219,7 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): """A UniFi Protect NVR Disk Binary Sensor.""" + _disk: UOSDisk entity_description: ProtectBinaryEntityDescription def __init__( @@ -223,26 +227,35 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): data: ProtectData, device: NVR, description: ProtectBinaryEntityDescription, - index: int, + disk: UOSDisk, ) -> None: """Initialize the Binary Sensor.""" + self._disk = disk + # backwards compat with old unique IDs + index = self._disk.slot - 1 + description = copy(description) description.key = f"{description.key}_{index}" - description.name = (description.name or "{index}").format(index=index) - self._index = index + description.name = f"{disk.type} {disk.slot}" super().__init__(data, device, description) @callback def _async_update_device_from_protect(self) -> None: super()._async_update_device_from_protect() - disks = self.device.system_info.storage.devices - disk_available = len(disks) > self._index - self._attr_available = self._attr_available and disk_available - if disk_available: - disk = disks[self._index] - self._attr_is_on = not disk.healthy - self._attr_extra_state_attributes = {ATTR_MODEL: disk.model} + slot = self._disk.slot + self._attr_available = False + + if self.device.system_info.ustorage is None: + return + + for disk in self.device.system_info.ustorage.disks: + if disk.slot == slot: + self._disk = disk + self._attr_available = True + break + + self._attr_is_on = not self._disk.is_healthy class ProtectEventBinarySensor(EventThumbnailMixin, ProtectDeviceBinarySensor): diff --git a/tests/components/unifiprotect/fixtures/sample_nvr.json b/tests/components/unifiprotect/fixtures/sample_nvr.json index 728f92c3e32..507e75fec09 100644 --- a/tests/components/unifiprotect/fixtures/sample_nvr.json +++ b/tests/components/unifiprotect/fixtures/sample_nvr.json @@ -117,6 +117,158 @@ } ] }, + "ustorage": { + "disks": [ + { + "slot": 1, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 52, + "poweronhrs": 4242, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 2, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 52, + "poweronhrs": 4242, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 3, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 51, + "poweronhrs": 4242, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 4, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 50, + "poweronhrs": 2443, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 5, + "type": "HDD", + "model": "ST16000VE000-2L2103", + "serial": "ABCD1234", + "firmware": "EV02", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 50, + "poweronhrs": 783, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "slot": 6, + "state": "nodisk" + }, + { + "slot": 7, + "type": "HDD", + "model": "ST16000VE002-3BR101", + "serial": "ABCD1234", + "firmware": "EV01", + "rpm": 7200, + "ata": "ACS-4", + "sata": "SATA 3.3", + "action": "expanding", + "healthy": "good", + "state": "expanding", + "reason": null, + "temperature": 45, + "poweronhrs": 18, + "life_span": null, + "bad_sector": 0, + "threshold": 10, + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + } + ], + "space": [ + { + "device": "md3", + "total_bytes": 63713403555840, + "used_bytes": 57006577086464, + "action": "expanding", + "progress": 21.390607518939174, + "estimate": 234395.73300748435 + }, + { + "device": "md0", + "total_bytes": 0, + "used_bytes": 0, + "action": "syncing", + "progress": 0, + "estimate": null + } + ] + }, "tmpfs": { "available": 934204, "total": 1048576, diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 88b42d36994..834f8634ee1 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -71,7 +71,7 @@ async def camera_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 9) yield camera_obj @@ -103,7 +103,7 @@ async def light_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) yield light_obj @@ -138,7 +138,7 @@ async def camera_none_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) yield camera_obj @@ -179,7 +179,7 @@ async def sensor_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) yield sensor_obj @@ -215,7 +215,7 @@ async def sensor_none_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) yield sensor_obj From 8f8c1348ba5d3ba777b1325a3b4b3ba3369c8dfb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 9 Jun 2022 00:23:40 +0000 Subject: [PATCH 314/947] [ci skip] Translation update --- homeassistant/components/google/translations/ca.json | 3 +++ homeassistant/components/google/translations/en.json | 2 +- homeassistant/components/google/translations/fr.json | 3 +++ homeassistant/components/google/translations/hu.json | 3 +++ homeassistant/components/google/translations/pl.json | 3 +++ .../components/google/translations/zh-Hant.json | 3 +++ homeassistant/components/radiotherm/translations/ca.json | 9 +++++++++ .../components/sensibo/translations/sensor.hu.json | 8 ++++++++ 8 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sensibo/translations/sensor.hu.json diff --git a/homeassistant/components/google/translations/ca.json b/homeassistant/components/google/translations/ca.json index 2c9190e4bfd..27c9a11f92b 100644 --- a/homeassistant/components/google/translations/ca.json +++ b/homeassistant/components/google/translations/ca.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Segueix les [instruccions]({more_info_url}) de [la pantalla de consentiment OAuth]({oauth_consent_url}) perqu\u00e8 Home Assistant tingui acc\u00e9s al teu calendari de Google. Tamb\u00e9 has de crear les credencials d'aplicaci\u00f3 enlla\u00e7ades al calendari:\n 1. V\u00e9s a [Credencials]({oauth_creds_url}) i fes clic a **Crear credencials**.\n 2 A la llista desplegable, selecciona **ID de client OAuth**.\n 3. Selecciona **Dispositius TV i d'entrada limitada** al tipus d'aplicaci\u00f3.\n \n " + }, "config": { "abort": { "already_configured": "El compte ja est\u00e0 configurat", diff --git a/homeassistant/components/google/translations/en.json b/homeassistant/components/google/translations/en.json index 54936b0d81c..1bebde4f63a 100644 --- a/homeassistant/components/google/translations/en.json +++ b/homeassistant/components/google/translations/en.json @@ -1,6 +1,6 @@ { "application_credentials": { - "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) page and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" + "description": "Follow the [instructions]({more_info_url}) for [OAuth consent screen]({oauth_consent_url}) to give Home Assistant access to your Google Calendar. You also need to create Application Credentials linked to your Calendar:\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **TV and Limited Input devices** for the Application Type.\n\n" }, "config": { "abort": { diff --git a/homeassistant/components/google/translations/fr.json b/homeassistant/components/google/translations/fr.json index 06903bdff07..ed60ccb1c1b 100644 --- a/homeassistant/components/google/translations/fr.json +++ b/homeassistant/components/google/translations/fr.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Suivez les [instructions]({more_info_url}) de l'[\u00e9cran d'autorisation OAuth]({oauth_consent_url}) pour permettre \u00e0 Home Assistant d'acc\u00e9der \u00e0 votre agenda Google. Vous devez \u00e9galement cr\u00e9er des informations d'identification d'application li\u00e9es \u00e0 votre agenda\u00a0:\n1. Rendez-vous sur [Informations d'identification]({oauth_creds_url}) et cliquez sur **Cr\u00e9er des informations d'identification**.\n2. Dans la liste d\u00e9roulante, s\u00e9lectionnez **ID client OAuth**.\n3. S\u00e9lectionnez **TV et p\u00e9riph\u00e9riques d'entr\u00e9e limit\u00e9s** comme type d'application.\n\n" + }, "config": { "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", diff --git a/homeassistant/components/google/translations/hu.json b/homeassistant/components/google/translations/hu.json index 66ea71c72ec..6a5c7d2d68c 100644 --- a/homeassistant/components/google/translations/hu.json +++ b/homeassistant/components/google/translations/hu.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Az [OAuth beleegyez\u00e9si k\u00e9perny\u0151]({oauth_consent_url}) az [utas\u00edt\u00e1sok]({more_info_url}) k\u00f6vet\u00e9s\u00e9vel hozz\u00e1f\u00e9r\u00e9st biztos\u00edthat a Home Assistant-nak a Google Napt\u00e1rhoz. L\u00e9tre kell hoznia a napt\u00e1rhoz csatolt alkalmaz\u00e1s-hiteles\u00edt\u0151 adatokat is:\n1. Nyissa meg a [Credentials]({oauth_creds_url}) webhelyet, \u00e9s kattintson a **Hiteles\u00edt\u0151 adatok l\u00e9trehoz\u00e1sa**-ra.\n1. A leg\u00f6rd\u00fcl\u0151 list\u00e1b\u00f3l v\u00e1lassza a **OAuth \u00fcgyf\u00e9lazonos\u00edt\u00f3**-t.\n1. V\u00e1lassza a **TV \u00e9s a korl\u00e1tozott beviteli eszk\u00f6z\u00f6k** lehet\u0151s\u00e9get az alkalmaz\u00e1st\u00edpushoz." + }, "config": { "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", diff --git a/homeassistant/components/google/translations/pl.json b/homeassistant/components/google/translations/pl.json index fff2a20ee39..8013e775e62 100644 --- a/homeassistant/components/google/translations/pl.json +++ b/homeassistant/components/google/translations/pl.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Post\u0119puj zgodnie z [instrukcjami]( {more_info_url} ) na [ekran akceptacji OAuth]( {oauth_consent_url} ), aby przyzna\u0107 Home Assistantowi dost\u0119p do Twojego Kalendarza Google. Musisz r\u00f3wnie\u017c utworzy\u0107 po\u015bwiadczenia aplikacji po\u0142\u0105czone z Twoim kalendarzem:\n1. Przejd\u017a do [Po\u015bwiadczenia]( {oauth_creds_url} ) i kliknij **Utw\u00f3rz po\u015bwiadczenia**.\n2. Z listy rozwijanej wybierz **ID klienta OAuth**.\n3. Wybierz **TV i urz\u0105dzenia z ograniczonym wej\u015bciem** jako typ aplikacji.\n\n" + }, "config": { "abort": { "already_configured": "Konto jest ju\u017c skonfigurowane", diff --git a/homeassistant/components/google/translations/zh-Hant.json b/homeassistant/components/google/translations/zh-Hant.json index 988c6629af7..7b83154db1a 100644 --- a/homeassistant/components/google/translations/zh-Hant.json +++ b/homeassistant/components/google/translations/zh-Hant.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "\u8ddf\u96a8[\u8aaa\u660e]({more_info_url})\u4ee5\u8a2d\u5b9a\u81f3 [OAuth \u540c\u610f\u756b\u9762]({oauth_consent_url})\u3001\u4f9b Home Assistant \u5b58\u53d6\u60a8\u7684 Google \u65e5\u66c6\u3002\u540c\u6642\u9700\u8981\u65b0\u589e\u9023\u7d50\u81f3\u65e5\u66c6\u7684\u61c9\u7528\u7a0b\u5f0f\u6191\u8b49\uff1a\n1. \u700f\u89bd\u81f3 [\u6191\u8b49]({oauth_creds_url}) \u9801\u9762\u4e26\u9ede\u9078 **\u5efa\u7acb\u6191\u8b49**\u3002\n1. \u7531\u4e0b\u62c9\u9078\u55ae\u4e2d\u9078\u64c7 **OAuth \u7528\u6236\u7aef ID**\u3002\n1. \u61c9\u7528\u7a0b\u5f0f\u985e\u578b\u5247\u9078\u64c7 **\u96fb\u8996\u548c\u53d7\u9650\u5236\u7684\u8f38\u5165\u88dd\u7f6e**\u3002\n\n" + }, "config": { "abort": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", diff --git a/homeassistant/components/radiotherm/translations/ca.json b/homeassistant/components/radiotherm/translations/ca.json index 1008a0e4988..d1249ddd23f 100644 --- a/homeassistant/components/radiotherm/translations/ca.json +++ b/homeassistant/components/radiotherm/translations/ca.json @@ -18,5 +18,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Configura bloqueig permanent quan s'ajusti la temperatura." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.hu.json b/homeassistant/components/sensibo/translations/sensor.hu.json new file mode 100644 index 00000000000..38a372f0f78 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.hu.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Norm\u00e1l", + "s": "\u00c9rz\u00e9keny" + } + } +} \ No newline at end of file From 8b735ffabec49379c0c28a52e1f701b6b0b4fe49 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Thu, 9 Jun 2022 03:30:13 +0200 Subject: [PATCH 315/947] Fix handling of connection error during Synology DSM setup (#73248) * dont reload on conection error during setup * also fetch API errors during update --- .../components/synology_dsm/common.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index e27c7475251..2ca9cbf3ccf 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta import logging from synology_dsm import SynologyDSM @@ -98,7 +97,7 @@ class SynoApi: self._async_setup_api_requests() await self._hass.async_add_executor_job(self._fetch_device_configuration) - await self.async_update() + await self.async_update(first_setup=True) @callback def subscribe(self, api_key: str, unique_id: str) -> Callable[[], None]: @@ -251,7 +250,7 @@ class SynoApi: # ignore API errors during logout pass - async def async_update(self, now: timedelta | None = None) -> None: + async def async_update(self, first_setup: bool = False) -> None: """Update function for updating API information.""" LOGGER.debug("Start data update for '%s'", self._entry.unique_id) self._async_setup_api_requests() @@ -259,14 +258,22 @@ class SynoApi: await self._hass.async_add_executor_job( self.dsm.update, self._with_information ) - except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: - LOGGER.warning( - "Connection error during update, fallback by reloading the entry" - ) + except ( + SynologyDSMLoginFailedException, + SynologyDSMRequestException, + SynologyDSMAPIErrorException, + ) as err: LOGGER.debug( "Connection error during update of '%s' with exception: %s", self._entry.unique_id, err, ) + + if first_setup: + raise err + + LOGGER.warning( + "Connection error during update, fallback by reloading the entry" + ) await self._hass.config_entries.async_reload(self._entry.entry_id) return From d6e7a3e537564c6bfc52580e2271491f4f7dc698 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Thu, 9 Jun 2022 14:46:45 +1000 Subject: [PATCH 316/947] Add powerview advanced features (#73061) Co-authored-by: J. Nick Koston --- .coveragerc | 3 +- .../hunterdouglas_powerview/__init__.py | 2 +- .../hunterdouglas_powerview/button.py | 124 ++++++++++++++++++ .../hunterdouglas_powerview/cover.py | 3 +- .../hunterdouglas_powerview/entity.py | 5 + 5 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/hunterdouglas_powerview/button.py diff --git a/.coveragerc b/.coveragerc index 3db90953f74..44b205746df 100644 --- a/.coveragerc +++ b/.coveragerc @@ -505,9 +505,10 @@ omit = homeassistant/components/huawei_lte/switch.py homeassistant/components/hue/light.py homeassistant/components/hunterdouglas_powerview/__init__.py + homeassistant/components/hunterdouglas_powerview/button.py homeassistant/components/hunterdouglas_powerview/coordinator.py - homeassistant/components/hunterdouglas_powerview/diagnostics.py homeassistant/components/hunterdouglas_powerview/cover.py + homeassistant/components/hunterdouglas_powerview/diagnostics.py homeassistant/components/hunterdouglas_powerview/entity.py homeassistant/components/hunterdouglas_powerview/scene.py homeassistant/components/hunterdouglas_powerview/sensor.py diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index 88aa5214c9b..fe039964e5d 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -56,7 +56,7 @@ PARALLEL_UPDATES = 1 CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) -PLATFORMS = [Platform.COVER, Platform.SCENE, Platform.SENSOR] +PLATFORMS = [Platform.BUTTON, Platform.COVER, Platform.SCENE, Platform.SENSOR] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py new file mode 100644 index 00000000000..131ef279a20 --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -0,0 +1,124 @@ +"""Buttons for Hunter Douglas Powerview advanced features.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from aiopvapi.resources.shade import BaseShade, factory as PvShade + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ( + COORDINATOR, + DEVICE_INFO, + DOMAIN, + PV_API, + PV_ROOM_DATA, + PV_SHADE_DATA, + ROOM_ID_IN_SHADE, + ROOM_NAME_UNICODE, +) +from .coordinator import PowerviewShadeUpdateCoordinator +from .entity import ShadeEntity + + +@dataclass +class PowerviewButtonDescriptionMixin: + """Mixin to describe a Button entity.""" + + press_action: Callable[[BaseShade], Any] + + +@dataclass +class PowerviewButtonDescription( + ButtonEntityDescription, PowerviewButtonDescriptionMixin +): + """Class to describe a Button entity.""" + + +BUTTONS: Final = [ + PowerviewButtonDescription( + key="calibrate", + name="Calibrate", + icon="mdi:swap-vertical-circle-outline", + device_class=ButtonDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda shade: shade.calibrate(), + ), + PowerviewButtonDescription( + key="identify", + name="Identify", + icon="mdi:crosshairs-question", + device_class=ButtonDeviceClass.UPDATE, + entity_category=EntityCategory.DIAGNOSTIC, + press_action=lambda shade: shade.jog(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the hunter douglas advanced feature buttons.""" + + pv_data = hass.data[DOMAIN][entry.entry_id] + room_data: dict[str | int, Any] = pv_data[PV_ROOM_DATA] + shade_data = pv_data[PV_SHADE_DATA] + pv_request = pv_data[PV_API] + coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR] + device_info: dict[str, Any] = pv_data[DEVICE_INFO] + + entities: list[ButtonEntity] = [] + for raw_shade in shade_data.values(): + shade: BaseShade = PvShade(raw_shade, pv_request) + name_before_refresh = shade.name + room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) + room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + + for description in BUTTONS: + entities.append( + PowerviewButton( + coordinator, + device_info, + room_name, + shade, + name_before_refresh, + description, + ) + ) + + async_add_entities(entities) + + +class PowerviewButton(ShadeEntity, ButtonEntity): + """Representation of an advanced feature button.""" + + def __init__( + self, + coordinator: PowerviewShadeUpdateCoordinator, + device_info: dict[str, Any], + room_name: str, + shade: BaseShade, + name: str, + description: PowerviewButtonDescription, + ) -> None: + """Initialize the button entity.""" + super().__init__(coordinator, device_info, room_name, shade, name) + self.entity_description: PowerviewButtonDescription = description + self._attr_name = f"{self._shade_name} {description.name}" + self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" + + async def async_press(self) -> None: + """Handle the button press.""" + await self.entity_description.press_action(self._shade) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 565bac6a5c8..c3061c75301 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -323,8 +323,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): async def _async_force_refresh_state(self) -> None: """Refresh the cover state and force the device cache to be bypassed.""" - await self._shade.refresh() - self._async_update_shade_data(self._shade.raw_data) + await self.async_update() self.async_write_ha_state() async def async_added_to_hass(self) -> None: diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 04885fa576e..7814ba9cb12 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -117,3 +117,8 @@ class ShadeEntity(HDEntity): device_info[ATTR_SW_VERSION] = sw_version return device_info + + async def async_update(self) -> None: + """Refresh shade position.""" + await self._shade.refresh() + self.data.update_shade_positions(self._shade.raw_data) From 86723ea02b1a024dbc2d40671ac9e35f4775ea56 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jun 2022 09:41:55 +0200 Subject: [PATCH 317/947] Bump actions/setup-python from 3.1.2 to 4.0.0 (#73265) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 6 +++--- .github/workflows/ci.yaml | 12 ++++++------ .github/workflows/translations.yaml | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 48c0782dafa..664f9b3910e 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -70,7 +70,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -104,7 +104,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 502006d8d2c..fb46ab63202 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -155,7 +155,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} - name: Generate partial Python venv restore key @@ -235,7 +235,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -285,7 +285,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -336,7 +336,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -378,7 +378,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -525,7 +525,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/translations.yaml b/.github/workflows/translations.yaml index 716ae870b50..0d3ddc4ca18 100644 --- a/.github/workflows/translations.yaml +++ b/.github/workflows/translations.yaml @@ -24,7 +24,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v3.0.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v3.1.2 + uses: actions/setup-python@v4.0.0 with: python-version: ${{ env.DEFAULT_PYTHON }} From a7398b8a733239ea1d27496b9a9905b5445a7404 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 9 Jun 2022 12:06:59 +0300 Subject: [PATCH 318/947] Remove deprecated yaml and code cleanup for `nfandroidtv` (#73227) --- .../components/nfandroidtv/__init__.py | 45 +++++-------- .../components/nfandroidtv/config_flow.py | 34 +++------- homeassistant/components/nfandroidtv/const.py | 2 + .../components/nfandroidtv/notify.py | 63 ++++--------------- .../nfandroidtv/test_config_flow.py | 41 +----------- 5 files changed, 38 insertions(+), 147 deletions(-) diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py index 458015c5bb6..38622fc0060 100644 --- a/homeassistant/components/nfandroidtv/__init__.py +++ b/homeassistant/components/nfandroidtv/__init__.py @@ -1,58 +1,46 @@ """The NFAndroidTV integration.""" from notifications_android_tv.notifications import ConnectError, Notifications -from homeassistant.components.notify import DOMAIN as NOTIFY -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM, Platform +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import DOMAIN +from .const import DATA_HASS_CONFIG, DOMAIN PLATFORMS = [Platform.NOTIFY] +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the NFAndroidTV component.""" - hass.data.setdefault(DOMAIN, {}) - # Iterate all entries for notify to only get nfandroidtv - if NOTIFY in config: - for entry in config[NOTIFY]: - if entry[CONF_PLATFORM] == DOMAIN: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=entry - ) - ) + hass.data[DATA_HASS_CONFIG] = config return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NFAndroidTV from a config entry.""" - host = entry.data[CONF_HOST] - name = entry.data[CONF_NAME] - try: - await hass.async_add_executor_job(Notifications, host) + await hass.async_add_executor_job(Notifications, entry.data[CONF_HOST]) except ConnectError as ex: - raise ConfigEntryNotReady("Failed to connect") from ex + raise ConfigEntryNotReady( + f"Failed to connect to host: {entry.data[CONF_HOST]}" + ) from ex hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - CONF_HOST: host, - CONF_NAME: name, - } hass.async_create_task( discovery.async_load_platform( hass, Platform.NOTIFY, DOMAIN, - hass.data[DOMAIN][entry.entry_id], - hass.data[DOMAIN], + dict(entry.data), + hass.data[DATA_HASS_CONFIG], ) ) @@ -61,9 +49,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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 + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/nfandroidtv/config_flow.py b/homeassistant/components/nfandroidtv/config_flow.py index defa4467f3a..88eebe1b4d4 100644 --- a/homeassistant/components/nfandroidtv/config_flow.py +++ b/homeassistant/components/nfandroidtv/config_flow.py @@ -26,46 +26,28 @@ class NFAndroidTVFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} if user_input is not None: - host = user_input[CONF_HOST] - name = user_input[CONF_NAME] - await self.async_set_unique_id(host) - self._abort_if_unique_id_configured() - error = await self._async_try_connect(host) - if error is None: + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_NAME: user_input[CONF_NAME]} + ) + if not (error := await self._async_try_connect(user_input[CONF_HOST])): return self.async_create_entry( - title=name, - data={CONF_HOST: host, CONF_NAME: name}, + title=user_input[CONF_NAME], + data=user_input, ) errors["base"] = error - user_input = user_input or {} return self.async_show_form( step_id="user", data_schema=vol.Schema( { - vol.Required(CONF_HOST, default=user_input.get(CONF_HOST)): str, - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, + vol.Required(CONF_HOST): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, } ), errors=errors, ) - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - for entry in self._async_current_entries(): - if entry.data[CONF_HOST] == import_config[CONF_HOST]: - _LOGGER.warning( - "Already configured. This yaml configuration has already been imported. Please remove it" - ) - return self.async_abort(reason="already_configured") - if CONF_NAME not in import_config: - import_config[CONF_NAME] = f"{DEFAULT_NAME} {import_config[CONF_HOST]}" - - return await self.async_step_user(import_config) - async def _async_try_connect(self, host: str) -> str | None: """Try connecting to Android TV / Fire TV.""" try: diff --git a/homeassistant/components/nfandroidtv/const.py b/homeassistant/components/nfandroidtv/const.py index 12449a9b046..4d4a7c82ecb 100644 --- a/homeassistant/components/nfandroidtv/const.py +++ b/homeassistant/components/nfandroidtv/const.py @@ -7,6 +7,8 @@ CONF_TRANSPARENCY = "transparency" CONF_COLOR = "color" CONF_INTERRUPT = "interrupt" +DATA_HASS_CONFIG = "nfandroid_hass_config" + DEFAULT_NAME = "Android TV / Fire TV" DEFAULT_TIMEOUT = 5 diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index b5e4962e9be..c70272d3835 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -14,10 +14,9 @@ from homeassistant.components.notify import ( ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_HOST, CONF_TIMEOUT +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -43,53 +42,21 @@ from .const import ( ATTR_INTERRUPT, ATTR_POSITION, ATTR_TRANSPARENCY, - CONF_COLOR, - CONF_DURATION, - CONF_FONTSIZE, - CONF_INTERRUPT, - CONF_POSITION, - CONF_TRANSPARENCY, DEFAULT_TIMEOUT, ) _LOGGER = logging.getLogger(__name__) -# Deprecated in Home Assistant 2021.8 -PLATFORM_SCHEMA = cv.deprecated( - vol.All( - PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_DURATION): vol.Coerce(int), - vol.Optional(CONF_FONTSIZE): vol.In(Notifications.FONTSIZES.keys()), - vol.Optional(CONF_POSITION): vol.In(Notifications.POSITIONS.keys()), - vol.Optional(CONF_TRANSPARENCY): vol.In( - Notifications.TRANSPARENCIES.keys() - ), - vol.Optional(CONF_COLOR): vol.In(Notifications.BKG_COLORS.keys()), - vol.Optional(CONF_TIMEOUT): vol.Coerce(int), - vol.Optional(CONF_INTERRUPT): cv.boolean, - } - ), - ) -) - async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, -) -> NFAndroidTVNotificationService: +) -> NFAndroidTVNotificationService | None: """Get the NFAndroidTV notification service.""" - if discovery_info is not None: - notify = await hass.async_add_executor_job( - Notifications, discovery_info[CONF_HOST] - ) - return NFAndroidTVNotificationService( - notify, - hass.config.is_allowed_path, - ) - notify = await hass.async_add_executor_job(Notifications, config.get(CONF_HOST)) + if discovery_info is None: + return None + notify = await hass.async_add_executor_job(Notifications, discovery_info[CONF_HOST]) return NFAndroidTVNotificationService( notify, hass.config.is_allowed_path, @@ -128,21 +95,21 @@ class NFAndroidTVNotificationService(BaseNotificationService): ) except ValueError: _LOGGER.warning( - "Invalid duration-value: %s", str(data.get(ATTR_DURATION)) + "Invalid duration-value: %s", data.get(ATTR_DURATION) ) if ATTR_FONTSIZE in data: if data.get(ATTR_FONTSIZE) in Notifications.FONTSIZES: fontsize = data.get(ATTR_FONTSIZE) else: _LOGGER.warning( - "Invalid fontsize-value: %s", str(data.get(ATTR_FONTSIZE)) + "Invalid fontsize-value: %s", data.get(ATTR_FONTSIZE) ) if ATTR_POSITION in data: if data.get(ATTR_POSITION) in Notifications.POSITIONS: position = data.get(ATTR_POSITION) else: _LOGGER.warning( - "Invalid position-value: %s", str(data.get(ATTR_POSITION)) + "Invalid position-value: %s", data.get(ATTR_POSITION) ) if ATTR_TRANSPARENCY in data: if data.get(ATTR_TRANSPARENCY) in Notifications.TRANSPARENCIES: @@ -150,24 +117,21 @@ class NFAndroidTVNotificationService(BaseNotificationService): else: _LOGGER.warning( "Invalid transparency-value: %s", - str(data.get(ATTR_TRANSPARENCY)), + data.get(ATTR_TRANSPARENCY), ) if ATTR_COLOR in data: if data.get(ATTR_COLOR) in Notifications.BKG_COLORS: bkgcolor = data.get(ATTR_COLOR) else: - _LOGGER.warning( - "Invalid color-value: %s", str(data.get(ATTR_COLOR)) - ) + _LOGGER.warning("Invalid color-value: %s", data.get(ATTR_COLOR)) if ATTR_INTERRUPT in data: try: interrupt = cv.boolean(data.get(ATTR_INTERRUPT)) except vol.Invalid: _LOGGER.warning( - "Invalid interrupt-value: %s", str(data.get(ATTR_INTERRUPT)) + "Invalid interrupt-value: %s", data.get(ATTR_INTERRUPT) ) - imagedata = data.get(ATTR_IMAGE) if data else None - if imagedata is not None: + if imagedata := data.get(ATTR_IMAGE): image_file = self.load_file( url=imagedata.get(ATTR_IMAGE_URL), local_path=imagedata.get(ATTR_IMAGE_PATH), @@ -175,8 +139,7 @@ class NFAndroidTVNotificationService(BaseNotificationService): password=imagedata.get(ATTR_IMAGE_PASSWORD), auth=imagedata.get(ATTR_IMAGE_AUTH), ) - icondata = data.get(ATTR_ICON) if data else None - if icondata is not None: + if icondata := data.get(ATTR_ICON): icon = self.load_file( url=icondata.get(ATTR_ICON_URL), local_path=icondata.get(ATTR_ICON_PATH), diff --git a/tests/components/nfandroidtv/test_config_flow.py b/tests/components/nfandroidtv/test_config_flow.py index b16b053c70f..a8b7b5fef53 100644 --- a/tests/components/nfandroidtv/test_config_flow.py +++ b/tests/components/nfandroidtv/test_config_flow.py @@ -4,8 +4,7 @@ from unittest.mock import patch from notifications_android_tv.notifications import ConnectError from homeassistant import config_entries, data_entry_flow -from homeassistant.components.nfandroidtv.const import DEFAULT_NAME, DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.components.nfandroidtv.const import DOMAIN from . import ( CONF_CONFIG_FLOW, @@ -95,41 +94,3 @@ async def test_flow_user_unknown_error(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"] == {"base": "unknown"} - - -async def test_flow_import(hass): - """Test an import flow.""" - mocked_tv = await _create_mocked_tv(True) - with _patch_config_flow_tv(mocked_tv), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONF_CONFIG_FLOW, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == CONF_DATA - - with _patch_config_flow_tv(mocked_tv), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=CONF_CONFIG_FLOW, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_configured" - - -async def test_flow_import_missing_optional(hass): - """Test an import flow with missing options.""" - mocked_tv = await _create_mocked_tv(True) - with _patch_config_flow_tv(mocked_tv), _patch_setup(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: HOST}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["data"] == {CONF_HOST: HOST, CONF_NAME: f"{DEFAULT_NAME} {HOST}"} From 69050d59426a18758858015644507dac077b7233 Mon Sep 17 00:00:00 2001 From: b3nj1 Date: Thu, 9 Jun 2022 02:14:18 -0700 Subject: [PATCH 319/947] Add Vesync voltage sensor, and yearly, weekly, montly energy sensors (#72570) --- homeassistant/components/vesync/common.py | 2 +- homeassistant/components/vesync/sensor.py | 43 ++++++++++++++++++++++- homeassistant/components/vesync/switch.py | 12 ------- 3 files changed, 43 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py index acee8e20961..e11897ea9ae 100644 --- a/homeassistant/components/vesync/common.py +++ b/homeassistant/components/vesync/common.py @@ -30,7 +30,7 @@ async def async_process_devices(hass, manager): if manager.outlets: devices[VS_SWITCHES].extend(manager.outlets) - # Expose outlets' power & energy usage as separate sensors + # Expose outlets' voltage, power & energy usage as separate sensors devices[VS_SENSORS].extend(manager.outlets) _LOGGER.info("%d VeSync outlets found", len(manager.outlets)) diff --git a/homeassistant/components/vesync/sensor.py b/homeassistant/components/vesync/sensor.py index 6e0c09b2f60..2da6d8ea6b7 100644 --- a/homeassistant/components/vesync/sensor.py +++ b/homeassistant/components/vesync/sensor.py @@ -1,4 +1,4 @@ -"""Support for power & energy sensors for VeSync outlets.""" +"""Support for voltage, power & energy sensors for VeSync outlets.""" from __future__ import annotations from collections.abc import Callable @@ -18,6 +18,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, @@ -121,6 +122,46 @@ SENSORS: tuple[VeSyncSensorEntityDescription, ...] = ( update_fn=update_energy, exists_fn=lambda device: ha_dev_type(device) == "outlet", ), + VeSyncSensorEntityDescription( + key="energy-weekly", + name="energy use weekly", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.weekly_energy_total, + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy-monthly", + name="energy use monthly", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.monthly_energy_total, + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="energy-yearly", + name="energy use yearly", + device_class=SensorDeviceClass.ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda device: device.yearly_energy_total, + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), + VeSyncSensorEntityDescription( + key="voltage", + name="current voltage", + device_class=SensorDeviceClass.VOLTAGE, + native_unit_of_measurement=ELECTRIC_POTENTIAL_VOLT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.details["voltage"], + update_fn=update_energy, + exists_fn=lambda device: ha_dev_type(device) == "outlet", + ), ) diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py index e5fd4c829fe..68b10e40bcb 100644 --- a/homeassistant/components/vesync/switch.py +++ b/homeassistant/components/vesync/switch.py @@ -66,18 +66,6 @@ class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity): super().__init__(plug) self.smartplug = plug - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - if not hasattr(self.smartplug, "weekly_energy_total"): - return {} - return { - "voltage": self.smartplug.voltage, - "weekly_energy_total": self.smartplug.weekly_energy_total, - "monthly_energy_total": self.smartplug.monthly_energy_total, - "yearly_energy_total": self.smartplug.yearly_energy_total, - } - def update(self): """Update outlet details and energy usage.""" self.smartplug.update() From b3677cdff69cc8b0a60ba757d788a33278eb5e7c Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Thu, 9 Jun 2022 12:26:20 +0200 Subject: [PATCH 320/947] Bump velbus-aio version to 2022.6.1 (#73261) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index f759eea0a34..e627412a00e 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,7 +2,7 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["velbus-aio==2022.5.1"], + "requirements": ["velbus-aio==2022.6.1"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "dependencies": ["usb"], diff --git a/requirements_all.txt b/requirements_all.txt index c1cd7f05b64..7aa8f60f961 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,7 +2384,7 @@ vallox-websocket-api==2.11.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.5.1 +velbus-aio==2022.6.1 # homeassistant.components.venstar venstarcolortouch==0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d380d8bf96..f68ac122bd2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1572,7 +1572,7 @@ vallox-websocket-api==2.11.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.5.1 +velbus-aio==2022.6.1 # homeassistant.components.venstar venstarcolortouch==0.15 From 1dc8c085e9ced7147e11c4e674907d1a5528791d Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 9 Jun 2022 13:48:39 +0200 Subject: [PATCH 321/947] Improve Netgear logging (#73274) * improve logging * fix black * invert checks --- homeassistant/components/netgear/sensor.py | 44 ++++++++++++++-------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 9dec1ab3390..a1cf134beda 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -5,6 +5,7 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime from decimal import Decimal +import logging from homeassistant.components.sensor import ( RestoreSensor, @@ -34,6 +35,8 @@ from .const import ( ) from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterEntity +_LOGGER = logging.getLogger(__name__) + SENSOR_TYPES = { "type": SensorEntityDescription( key="type", @@ -114,7 +117,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:upload", index=0, - value=lambda data: data[0] if data is not None else None, + value=lambda data: data[0], ), NetgearSensorEntityDescription( key="NewWeekUpload", @@ -123,7 +126,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:upload", index=1, - value=lambda data: data[1] if data is not None else None, + value=lambda data: data[1], ), NetgearSensorEntityDescription( key="NewWeekDownload", @@ -132,7 +135,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:download", index=0, - value=lambda data: data[0] if data is not None else None, + value=lambda data: data[0], ), NetgearSensorEntityDescription( key="NewWeekDownload", @@ -141,7 +144,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:download", index=1, - value=lambda data: data[1] if data is not None else None, + value=lambda data: data[1], ), NetgearSensorEntityDescription( key="NewMonthUpload", @@ -150,7 +153,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:upload", index=0, - value=lambda data: data[0] if data is not None else None, + value=lambda data: data[0], ), NetgearSensorEntityDescription( key="NewMonthUpload", @@ -159,7 +162,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:upload", index=1, - value=lambda data: data[1] if data is not None else None, + value=lambda data: data[1], ), NetgearSensorEntityDescription( key="NewMonthDownload", @@ -168,7 +171,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:download", index=0, - value=lambda data: data[0] if data is not None else None, + value=lambda data: data[0], ), NetgearSensorEntityDescription( key="NewMonthDownload", @@ -177,7 +180,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:download", index=1, - value=lambda data: data[1] if data is not None else None, + value=lambda data: data[1], ), NetgearSensorEntityDescription( key="NewLastMonthUpload", @@ -186,7 +189,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:upload", index=0, - value=lambda data: data[0] if data is not None else None, + value=lambda data: data[0], ), NetgearSensorEntityDescription( key="NewLastMonthUpload", @@ -195,7 +198,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:upload", index=1, - value=lambda data: data[1] if data is not None else None, + value=lambda data: data[1], ), NetgearSensorEntityDescription( key="NewLastMonthDownload", @@ -204,7 +207,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:download", index=0, - value=lambda data: data[0] if data is not None else None, + value=lambda data: data[0], ), NetgearSensorEntityDescription( key="NewLastMonthDownload", @@ -213,7 +216,7 @@ SENSOR_TRAFFIC_TYPES = [ native_unit_of_measurement=DATA_MEGABYTES, icon="mdi:download", index=1, - value=lambda data: data[1] if data is not None else None, + value=lambda data: data[1], ), ] @@ -372,6 +375,17 @@ class NetgearRouterSensorEntity(NetgearRouterEntity, RestoreSensor): @callback def async_update_device(self) -> None: """Update the Netgear device.""" - if self.coordinator.data is not None: - data = self.coordinator.data.get(self.entity_description.key) - self._value = self.entity_description.value(data) + if self.coordinator.data is None: + return + + data = self.coordinator.data.get(self.entity_description.key) + if data is None: + self._value = None + _LOGGER.debug( + "key '%s' not in Netgear router response '%s'", + self.entity_description.key, + data, + ) + return + + self._value = self.entity_description.value(data) From 91cd61804eb678dd251c0fd6bcbb29bcc82f96aa Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 9 Jun 2022 07:08:08 -0700 Subject: [PATCH 322/947] Deprecate google calendar add_event service, replaced with entity service (#72473) * Deprecate google calendar add_event service, replaced with entity service * Fix inconsistencies and bugs in input validation * Update validation rules and exceptions * Resolve merge conflicts --- homeassistant/components/google/__init__.py | 60 ++-- homeassistant/components/google/calendar.py | 131 ++++++++- homeassistant/components/google/const.py | 12 + homeassistant/components/google/services.yaml | 51 ++++ tests/components/google/test_init.py | 261 ++++++++++++++---- 5 files changed, 427 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 1ddb44e570b..e86f1c43ebf 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -43,6 +43,16 @@ from .const import ( DATA_SERVICE, DEVICE_AUTH_IMPL, DOMAIN, + EVENT_DESCRIPTION, + EVENT_END_DATE, + EVENT_END_DATETIME, + EVENT_IN, + EVENT_IN_DAYS, + EVENT_IN_WEEKS, + EVENT_START_DATE, + EVENT_START_DATETIME, + EVENT_SUMMARY, + EVENT_TYPES_CONF, FeatureAccess, ) @@ -61,18 +71,6 @@ CONF_MAX_RESULTS = "max_results" DEFAULT_CONF_OFFSET = "!!" EVENT_CALENDAR_ID = "calendar_id" -EVENT_DESCRIPTION = "description" -EVENT_END_CONF = "end" -EVENT_END_DATE = "end_date" -EVENT_END_DATETIME = "end_date_time" -EVENT_IN = "in" -EVENT_IN_DAYS = "days" -EVENT_IN_WEEKS = "weeks" -EVENT_START_CONF = "start" -EVENT_START_DATE = "start_date" -EVENT_START_DATETIME = "start_date_time" -EVENT_SUMMARY = "summary" -EVENT_TYPES_CONF = "event_types" NOTIFICATION_ID = "google_calendar_notification" NOTIFICATION_TITLE = "Google Calendar Setup" @@ -138,17 +136,31 @@ _EVENT_IN_TYPES = vol.Schema( } ) -ADD_EVENT_SERVICE_SCHEMA = vol.Schema( +ADD_EVENT_SERVICE_SCHEMA = vol.All( + cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), { vol.Required(EVENT_CALENDAR_ID): cv.string, vol.Required(EVENT_SUMMARY): cv.string, vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, - vol.Exclusive(EVENT_START_DATE, EVENT_START_CONF): cv.date, - vol.Exclusive(EVENT_END_DATE, EVENT_END_CONF): cv.date, - vol.Exclusive(EVENT_START_DATETIME, EVENT_START_CONF): cv.datetime, - vol.Exclusive(EVENT_END_DATETIME, EVENT_END_CONF): cv.datetime, - vol.Exclusive(EVENT_IN, EVENT_START_CONF, EVENT_END_CONF): _EVENT_IN_TYPES, - } + vol.Inclusive( + EVENT_START_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_END_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_START_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Inclusive( + EVENT_END_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Optional(EVENT_IN): _EVENT_IN_TYPES, + }, ) @@ -276,6 +288,12 @@ async def async_setup_add_event_service( async def _add_event(call: ServiceCall) -> None: """Add a new event to calendar.""" + _LOGGER.warning( + "The Google Calendar add_event service has been deprecated, and " + "will be removed in a future Home Assistant release. Please move " + "calls to the create_event service" + ) + start: DateOrDatetime | None = None end: DateOrDatetime | None = None @@ -298,11 +316,11 @@ async def async_setup_add_event_service( start = DateOrDatetime(date=start_in) end = DateOrDatetime(date=end_in) - elif EVENT_START_DATE in call.data: + elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data: start = DateOrDatetime(date=call.data[EVENT_START_DATE]) end = DateOrDatetime(date=call.data[EVENT_END_DATE]) - elif EVENT_START_DATETIME in call.data: + elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data: start_dt = call.data[EVENT_START_DATETIME] end_dt = call.data[EVENT_END_DATETIME] start = DateOrDatetime( diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 78661ed792f..39e3d69e6b9 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -9,7 +9,8 @@ from typing import Any from gcal_sync.api import GoogleCalendarService, ListEventsRequest from gcal_sync.exceptions import ApiException -from gcal_sync.model import Event +from gcal_sync.model import DateOrDatetime, Event +import voluptuous as vol from homeassistant.components.calendar import ( ENTITY_ID_FORMAT, @@ -20,8 +21,9 @@ from homeassistant.components.calendar import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle @@ -38,22 +40,66 @@ from . import ( load_config, update_config, ) +from .api import get_feature_access +from .const import ( + EVENT_DESCRIPTION, + EVENT_END_DATE, + EVENT_END_DATETIME, + EVENT_IN, + EVENT_IN_DAYS, + EVENT_IN_WEEKS, + EVENT_START_DATE, + EVENT_START_DATETIME, + EVENT_SUMMARY, + EVENT_TYPES_CONF, + FeatureAccess, +) _LOGGER = logging.getLogger(__name__) -DEFAULT_GOOGLE_SEARCH_PARAMS = { - "orderBy": "startTime", - "singleEvents": True, -} - MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) # Events have a transparency that determine whether or not they block time on calendar. # When an event is opaque, it means "Show me as busy" which is the default. Events that # are not opaque are ignored by default. -TRANSPARENCY = "transparency" OPAQUE = "opaque" +_EVENT_IN_TYPES = vol.Schema( + { + vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int, + vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int, + } +) + +SERVICE_CREATE_EVENT = "create_event" +CREATE_EVENT_SCHEMA = vol.All( + cv.has_at_least_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.has_at_most_one_key(EVENT_START_DATE, EVENT_START_DATETIME, EVENT_IN), + cv.make_entity_service_schema( + { + vol.Required(EVENT_SUMMARY): cv.string, + vol.Optional(EVENT_DESCRIPTION, default=""): cv.string, + vol.Inclusive( + EVENT_START_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_END_DATE, "dates", "Start and end dates must both be specified" + ): cv.date, + vol.Inclusive( + EVENT_START_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Inclusive( + EVENT_END_DATETIME, + "datetimes", + "Start and end datetimes must both be specified", + ): cv.datetime, + vol.Optional(EVENT_IN): _EVENT_IN_TYPES, + } + ), +) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -116,6 +162,14 @@ async def async_setup_entry( await hass.async_add_executor_job(append_calendars_to_config) + platform = entity_platform.async_get_current_platform() + if get_feature_access(hass, entry) is FeatureAccess.read_write: + platform.async_register_entity_service( + SERVICE_CREATE_EVENT, + CREATE_EVENT_SCHEMA, + async_create_event, + ) + class GoogleCalendarEntity(CalendarEntity): """A calendar event device.""" @@ -130,8 +184,8 @@ class GoogleCalendarEntity(CalendarEntity): entity_enabled: bool, ) -> None: """Create the Calendar event device.""" - self._calendar_service = calendar_service - self._calendar_id = calendar_id + self.calendar_service = calendar_service + self.calendar_id = calendar_id self._search: str | None = data.get(CONF_SEARCH) self._ignore_availability: bool = data.get(CONF_IGNORE_AVAILABILITY, False) self._event: CalendarEvent | None = None @@ -178,14 +232,14 @@ class GoogleCalendarEntity(CalendarEntity): """Get all events in a specific time frame.""" request = ListEventsRequest( - calendar_id=self._calendar_id, + calendar_id=self.calendar_id, start_time=start_date, end_time=end_date, search=self._search, ) result_items = [] try: - result = await self._calendar_service.async_list_events(request) + result = await self.calendar_service.async_list_events(request) async for result_page in result: result_items.extend(result_page.items) except ApiException as err: @@ -199,9 +253,9 @@ class GoogleCalendarEntity(CalendarEntity): @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: """Get the latest data.""" - request = ListEventsRequest(calendar_id=self._calendar_id, search=self._search) + request = ListEventsRequest(calendar_id=self.calendar_id, search=self._search) try: - result = await self._calendar_service.async_list_events(request) + result = await self.calendar_service.async_list_events(request) except ApiException as err: _LOGGER.error("Unable to connect to Google: %s", err) return @@ -226,3 +280,52 @@ def _get_calendar_event(event: Event) -> CalendarEvent: description=event.description, location=event.location, ) + + +async def async_create_event(entity: GoogleCalendarEntity, call: ServiceCall) -> None: + """Add a new event to calendar.""" + start: DateOrDatetime | None = None + end: DateOrDatetime | None = None + hass = entity.hass + + if EVENT_IN in call.data: + if EVENT_IN_DAYS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(days=call.data[EVENT_IN][EVENT_IN_DAYS]) + end_in = start_in + timedelta(days=1) + + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) + + elif EVENT_IN_WEEKS in call.data[EVENT_IN]: + now = datetime.now() + + start_in = now + timedelta(weeks=call.data[EVENT_IN][EVENT_IN_WEEKS]) + end_in = start_in + timedelta(days=1) + + start = DateOrDatetime(date=start_in) + end = DateOrDatetime(date=end_in) + + elif EVENT_START_DATE in call.data and EVENT_END_DATE in call.data: + start = DateOrDatetime(date=call.data[EVENT_START_DATE]) + end = DateOrDatetime(date=call.data[EVENT_END_DATE]) + + elif EVENT_START_DATETIME in call.data and EVENT_END_DATETIME in call.data: + start_dt = call.data[EVENT_START_DATETIME] + end_dt = call.data[EVENT_END_DATETIME] + start = DateOrDatetime(date_time=start_dt, timezone=str(hass.config.time_zone)) + end = DateOrDatetime(date_time=end_dt, timezone=str(hass.config.time_zone)) + + if start is None or end is None: + raise ValueError("Missing required fields to set start or end date/datetime") + + await entity.calendar_service.async_create_event( + entity.calendar_id, + Event( + summary=call.data[EVENT_SUMMARY], + description=call.data[EVENT_DESCRIPTION], + start=start, + end=end, + ), + ) diff --git a/homeassistant/components/google/const.py b/homeassistant/components/google/const.py index fba9b01b600..f07958c2e6e 100644 --- a/homeassistant/components/google/const.py +++ b/homeassistant/components/google/const.py @@ -29,3 +29,15 @@ class FeatureAccess(Enum): DEFAULT_FEATURE_ACCESS = FeatureAccess.read_write + + +EVENT_DESCRIPTION = "description" +EVENT_END_DATE = "end_date" +EVENT_END_DATETIME = "end_date_time" +EVENT_IN = "in" +EVENT_IN_DAYS = "days" +EVENT_IN_WEEKS = "weeks" +EVENT_START_DATE = "start_date" +EVENT_START_DATETIME = "start_date_time" +EVENT_SUMMARY = "summary" +EVENT_TYPES_CONF = "event_types" diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml index baa069aaedf..a303ad7e18d 100644 --- a/homeassistant/components/google/services.yaml +++ b/homeassistant/components/google/services.yaml @@ -52,3 +52,54 @@ add_event: example: '"days": 2 or "weeks": 2' selector: object: +create_event: + name: Create event + description: Add a new calendar event. + target: + entity: + integration: google + domain: calendar + fields: + summary: + name: Summary + description: Acts as the title of the event. + required: true + example: "Bowling" + selector: + text: + description: + name: Description + description: The description of the event. Optional. + example: "Birthday bowling" + selector: + text: + start_date_time: + name: Start time + description: The date and time the event should start. + example: "2022-03-22 20:00:00" + selector: + text: + end_date_time: + name: End time + description: The date and time the event should end. + example: "2022-03-22 22:00:00" + selector: + text: + start_date: + name: Start date + description: The date the whole day event should start. + example: "2022-03-10" + selector: + text: + end_date: + name: End date + description: The date the whole day event should end. + example: "2022-03-11" + selector: + text: + in: + name: In + description: Days or weeks that you want to create the event in. + example: '"days": 2 or "weeks": 2' + selector: + object: diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index cadb444c26f..b2a81b47718 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -9,12 +9,14 @@ from typing import Any from unittest.mock import Mock, patch import pytest +import voluptuous as vol from homeassistant.components.application_credentials import ( ClientCredential, async_import_client_credential, ) from homeassistant.components.google import DOMAIN, SERVICE_ADD_EVENT +from homeassistant.components.google.calendar import SERVICE_CREATE_EVENT from homeassistant.components.google.const import CONF_CALENDAR_ACCESS from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_OFF @@ -40,6 +42,9 @@ EXPIRED_TOKEN_TIMESTAMP = datetime.datetime(2022, 4, 8).timestamp() # Typing helpers HassApi = Callable[[], Awaitable[dict[str, Any]]] +TEST_EVENT_SUMMARY = "Test Summary" +TEST_EVENT_DESCRIPTION = "Test Description" + def assert_state(actual: State | None, expected: State | None) -> None: """Assert that the two states are equal.""" @@ -59,6 +64,45 @@ def setup_config_entry( config_entry.add_to_hass(hass) +@pytest.fixture( + params=[ + ( + SERVICE_ADD_EVENT, + {"calendar_id": CALENDAR_ID}, + None, + ), + ( + SERVICE_CREATE_EVENT, + {}, + {"entity_id": TEST_YAML_ENTITY}, + ), + ], + ids=("add_event", "create_event"), +) +def add_event_call_service( + hass: HomeAssistant, + request: Any, +) -> Callable[dict[str, Any], Awaitable[None]]: + """Fixture for calling the add or create event service.""" + (service_call, data, target) = request.param + + async def call_service(params: dict[str, Any]) -> None: + await hass.services.async_call( + DOMAIN, + service_call, + { + **data, + **params, + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, + }, + target=target, + blocking=True, + ) + + return call_service + + async def test_unload_entry( hass: HomeAssistant, component_setup: ComponentSetup, @@ -297,28 +341,145 @@ async def test_calendar_config_track_new( assert_state(state, expected_state) -async def test_add_event_missing_required_fields( +@pytest.mark.parametrize( + "date_fields,expected_error,error_match", + [ + ( + {}, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in", + ), + ( + { + "start_date": "2022-04-01", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T06:00:00", + }, + vol.error.MultipleInvalid, + "Start and end datetimes must both be specified", + ), + ( + { + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "must contain at least one of start_date, start_date_time, in.", + ), + ( + { + "start_date": "2022-04-01", + "start_date_time": "2022-04-01T06:00:00", + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T06:00:00", + "end_date_time": "2022-04-01T07:00:00", + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "start_date": "2022-04-01", + "end_date_time": "2022-04-02T07:00:00", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "start_date_time": "2022-04-01T07:00:00", + "end_date": "2022-04-02", + }, + vol.error.MultipleInvalid, + "Start and end dates must both be specified", + ), + ( + { + "in": { + "days": 2, + "weeks": 2, + } + }, + vol.error.MultipleInvalid, + "two or more values in the same group of exclusion 'event_types'", + ), + ( + { + "start_date": "2022-04-01", + "end_date": "2022-04-02", + "in": { + "days": 2, + }, + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ( + { + "start_date_time": "2022-04-01T07:00:00", + "end_date_time": "2022-04-01T07:00:00", + "in": { + "days": 2, + }, + }, + vol.error.MultipleInvalid, + "must contain at most one of start_date, start_date_time, in.", + ), + ], + ids=[ + "missing_all", + "missing_end_date", + "missing_start_date", + "missing_end_datetime", + "missing_start_datetime", + "multiple_start", + "multiple_end", + "missing_end_date", + "missing_end_date_time", + "multiple_in", + "unexpected_in_with_date", + "unexpected_in_with_datetime", + ], +) +async def test_add_event_invalid_params( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], + mock_calendars_yaml: None, + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], + date_fields: dict[str, Any], + expected_error: type[Exception], + error_match: str | None, ) -> None: - """Test service call that adds an event missing required fields.""" + """Test service calls with incorrect fields.""" + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() - with pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, - { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", - }, - blocking=True, - ) + with pytest.raises(expected_error, match=error_match): + await add_event_call_service(date_fields) @pytest.mark.parametrize( @@ -343,40 +504,35 @@ async def test_add_event_date_in_x( mock_calendars_list: ApiResult, mock_insert_event: Callable[[..., dict[str, Any]], None], test_api_calendar: dict[str, Any], + mock_calendars_yaml: None, + mock_events_list: ApiResult, date_fields: dict[str, Any], start_timedelta: datetime.timedelta, end_timedelta: datetime.timedelta, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with various time ranges.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() now = datetime.datetime.now() start_date = now + start_timedelta end_date = now + end_timedelta + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, - { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", - **date_fields, - }, - blocking=True, - ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + await add_event_call_service(date_fields) + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": {"date": start_date.date().isoformat()}, "end": {"date": end_date.date().isoformat()}, } @@ -386,39 +542,39 @@ async def test_add_event_date( hass: HomeAssistant, component_setup: ComponentSetup, mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], mock_insert_event: Callable[[str, dict[str, Any]], None], + mock_calendars_yaml: None, + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that sets a date range.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() now = utcnow() today = now.date() end_date = today + datetime.timedelta(days=2) + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, + await add_event_call_service( { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", "start_date": today.isoformat(), "end_date": end_date.isoformat(), }, - blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": {"date": today.isoformat()}, "end": {"date": end_date.isoformat()}, } @@ -430,38 +586,37 @@ async def test_add_event_date_time( mock_calendars_list: ApiResult, mock_insert_event: Callable[[str, dict[str, Any]], None], test_api_calendar: dict[str, Any], + mock_calendars_yaml: None, + mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, + add_event_call_service: Callable[dict[str, Any], Awaitable[None]], ) -> None: """Test service call that adds an event with a date time range.""" - mock_calendars_list({}) + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) assert await component_setup() start_datetime = datetime.datetime.now() delta = datetime.timedelta(days=3, hours=3) end_datetime = start_datetime + delta + aioclient_mock.clear_requests() mock_insert_event( calendar_id=CALENDAR_ID, ) - await hass.services.async_call( - DOMAIN, - SERVICE_ADD_EVENT, + await add_event_call_service( { - "calendar_id": CALENDAR_ID, - "summary": "Summary", - "description": "Description", "start_date_time": start_datetime.isoformat(), "end_date_time": end_datetime.isoformat(), }, - blocking=True, ) - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == { - "summary": "Summary", - "description": "Description", + assert len(aioclient_mock.mock_calls) == 1 + assert aioclient_mock.mock_calls[0][2] == { + "summary": TEST_EVENT_SUMMARY, + "description": TEST_EVENT_DESCRIPTION, "start": { "dateTime": start_datetime.isoformat(timespec="seconds"), "timeZone": "America/Regina", From 542eae1cf3ee92c9b9066f406291baeb3ecabcc5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Jun 2022 16:09:00 +0200 Subject: [PATCH 323/947] Add additional board types to hassio (#73267) * Add additional board types to hassio * Remove unsupported boards * Add rpi2 back --- homeassistant/components/hassio/__init__.py | 10 ++++++- tests/components/hassio/test_init.py | 31 +++++++++++++-------- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index cab17c94f0c..0df29a6153b 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -206,7 +206,15 @@ MAP_SERVICE_API = { } HARDWARE_INTEGRATIONS = { - "rpi": "raspberry_pi", + "odroid-c2": "hardkernel", + "odroid-c4": "hardkernel", + "odroid-n2": "hardkernel", + "odroid-xu4": "hardkernel", + "rpi2": "raspberry_pi", + "rpi3": "raspberry_pi", + "rpi3-64": "raspberry_pi", + "rpi4": "raspberry_pi", + "rpi4-64": "raspberry_pi", } diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index c47b3bfbeca..1569e834562 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -21,12 +21,18 @@ MOCK_ENVIRON = {"SUPERVISOR": "127.0.0.1", "SUPERVISOR_TOKEN": "abcdefgh"} @pytest.fixture() -def os_info(): +def extra_os_info(): + """Extra os/info.""" + return {} + + +@pytest.fixture() +def os_info(extra_os_info): """Mock os/info.""" return { "json": { "result": "ok", - "data": {"version_latest": "1.0.0", "version": "1.0.0"}, + "data": {"version_latest": "1.0.0", "version": "1.0.0", **extra_os_info}, } } @@ -715,21 +721,24 @@ async def test_coordinator_updates(hass, caplog): @pytest.mark.parametrize( - "os_info", + "extra_os_info, integration", [ - { - "json": { - "result": "ok", - "data": {"version_latest": "1.0.0", "version": "1.0.0", "board": "rpi"}, - } - } + ({"board": "odroid-c2"}, "hardkernel"), + ({"board": "odroid-c4"}, "hardkernel"), + ({"board": "odroid-n2"}, "hardkernel"), + ({"board": "odroid-xu4"}, "hardkernel"), + ({"board": "rpi2"}, "raspberry_pi"), + ({"board": "rpi3"}, "raspberry_pi"), + ({"board": "rpi3-64"}, "raspberry_pi"), + ({"board": "rpi4"}, "raspberry_pi"), + ({"board": "rpi4-64"}, "raspberry_pi"), ], ) -async def test_setup_hardware_integration(hass, aioclient_mock): +async def test_setup_hardware_integration(hass, aioclient_mock, integration): """Test setup initiates hardware integration.""" with patch.dict(os.environ, MOCK_ENVIRON), patch( - "homeassistant.components.raspberry_pi.async_setup_entry", + f"homeassistant.components.{integration}.async_setup_entry", return_value=True, ) as mock_setup_entry: result = await async_setup_component(hass, "hassio", {"hassio": {}}) From 82afbd1d125b75a4296e818758381dfcbf996c7d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 9 Jun 2022 16:12:10 +0200 Subject: [PATCH 324/947] Improve raspberry_pi tests (#73269) * Improve raspberry_pi tests * Address review comments --- tests/components/raspberry_pi/test_init.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/components/raspberry_pi/test_init.py b/tests/components/raspberry_pi/test_init.py index dd86da7bce0..4bf64c7999a 100644 --- a/tests/components/raspberry_pi/test_init.py +++ b/tests/components/raspberry_pi/test_init.py @@ -1,6 +1,8 @@ """Test the Raspberry Pi integration.""" from unittest.mock import patch +import pytest + from homeassistant.components.raspberry_pi.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -8,6 +10,16 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, MockModule, mock_integration +@pytest.fixture(autouse=True) +def mock_rpi_power(): + """Mock the rpi_power integration.""" + with patch( + "homeassistant.components.rpi_power.async_setup_entry", + return_value=True, + ): + yield + + async def test_setup_entry(hass: HomeAssistant) -> None: """Test setup of a config entry.""" mock_integration(hass, MockModule("hassio")) @@ -20,14 +32,19 @@ async def test_setup_entry(hass: HomeAssistant) -> None: title="Raspberry Pi", ) config_entry.add_to_hass(hass) + assert not hass.config_entries.async_entries("rpi_power") with patch( "homeassistant.components.raspberry_pi.get_os_info", return_value={"board": "rpi"}, - ) as mock_get_os_info: + ) as mock_get_os_info, patch( + "homeassistant.components.rpi_power.config_flow.new_under_voltage" + ): assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert len(mock_get_os_info.mock_calls) == 1 + assert len(hass.config_entries.async_entries("rpi_power")) == 1 + async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: """Test setup of a config entry with wrong board type.""" From f25fdf0d2e787faf1e9ec2096034b2def26e5e8b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 9 Jun 2022 12:46:13 -0700 Subject: [PATCH 325/947] Fix reloading themes crashing if no themes configured (#73287) --- homeassistant/components/frontend/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index c1deb02fc6a..b3907143eb9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -460,7 +460,7 @@ async def _async_setup_themes( async def reload_themes(_: ServiceCall) -> None: """Reload themes.""" config = await async_hass_config_yaml(hass) - new_themes = config[DOMAIN].get(CONF_THEMES, {}) + new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) hass.data[DATA_THEMES] = new_themes if hass.data[DATA_DEFAULT_THEME] not in new_themes: hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME From 1d6068fa0950bbe23efed5c42e9d69a4450f93e9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 9 Jun 2022 21:47:21 +0200 Subject: [PATCH 326/947] Update google-cloud-texttospeech to 2.11.1 (#73210) --- homeassistant/components/google_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json index 87da1f55fca..633c5edc453 100644 --- a/homeassistant/components/google_cloud/manifest.json +++ b/homeassistant/components/google_cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "google_cloud", "name": "Google Cloud Platform", "documentation": "https://www.home-assistant.io/integrations/google_cloud", - "requirements": ["google-cloud-texttospeech==2.11.0"], + "requirements": ["google-cloud-texttospeech==2.11.1"], "codeowners": ["@lufton"], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 7aa8f60f961..369cedf3d05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -741,7 +741,7 @@ goodwe==0.2.15 google-cloud-pubsub==2.11.0 # homeassistant.components.google_cloud -google-cloud-texttospeech==2.11.0 +google-cloud-texttospeech==2.11.1 # homeassistant.components.nest google-nest-sdm==2.0.0 From 22daea27c205b9c931de8fb7cb42a630866b4960 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 10:22:16 -1000 Subject: [PATCH 327/947] Cleanup coordinators in synology_dsm (#73257) Co-authored-by: Michael <35783820+mib1185@users.noreply.github.com> --- .coveragerc | 1 + .../components/synology_dsm/__init__.py | 170 +++++------------- .../components/synology_dsm/binary_sensor.py | 10 +- .../components/synology_dsm/button.py | 9 +- .../components/synology_dsm/camera.py | 26 +-- .../components/synology_dsm/common.py | 9 +- .../components/synology_dsm/const.py | 30 +++- .../components/synology_dsm/coordinator.py | 148 +++++++++++++++ .../components/synology_dsm/diagnostics.py | 18 +- .../components/synology_dsm/models.py | 21 +++ .../components/synology_dsm/sensor.py | 10 +- .../components/synology_dsm/service.py | 18 +- .../components/synology_dsm/switch.py | 33 ++-- .../components/synology_dsm/update.py | 11 +- tests/components/synology_dsm/test_init.py | 6 +- 15 files changed, 287 insertions(+), 233 deletions(-) create mode 100644 homeassistant/components/synology_dsm/coordinator.py create mode 100644 homeassistant/components/synology_dsm/models.py diff --git a/.coveragerc b/.coveragerc index 44b205746df..ea2c41f7e46 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1200,6 +1200,7 @@ omit = homeassistant/components/synology_dsm/binary_sensor.py homeassistant/components/synology_dsm/button.py homeassistant/components/synology_dsm/camera.py + homeassistant/components/synology_dsm/coordinator.py homeassistant/components/synology_dsm/diagnostics.py homeassistant/components/synology_dsm/common.py homeassistant/components/synology_dsm/entity.py diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index ece38bf7326..d8d768d36e4 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -1,47 +1,32 @@ """The Synology DSM component.""" from __future__ import annotations -from datetime import timedelta import logging -from typing import Any -import async_timeout from synology_dsm.api.surveillance_station import SynoSurveillanceStation -from synology_dsm.api.surveillance_station.camera import SynoCamera -from synology_dsm.exceptions import ( - SynologyDSMAPIErrorException, - SynologyDSMLogin2SARequiredException, - SynologyDSMLoginDisabledAccountException, - SynologyDSMLoginFailedException, - SynologyDSMLoginInvalidException, - SynologyDSMLoginPermissionDeniedException, - SynologyDSMRequestException, -) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MAC, CONF_SCAN_INTERVAL, CONF_VERIFY_SSL +from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .common import SynoApi from .const import ( - COORDINATOR_CAMERAS, - COORDINATOR_CENTRAL, - COORDINATOR_SWITCHES, - DEFAULT_SCAN_INTERVAL, DEFAULT_VERIFY_SSL, DOMAIN, EXCEPTION_DETAILS, EXCEPTION_UNKNOWN, PLATFORMS, - SIGNAL_CAMERA_SOURCE_CHANGED, - SYNO_API, - SYSTEM_LOADED, - UNDO_UPDATE_LISTENER, + SYNOLOGY_AUTH_FAILED_EXCEPTIONS, + SYNOLOGY_CONNECTION_EXCEPTIONS, ) +from .coordinator import ( + SynologyDSMCameraUpdateCoordinator, + SynologyDSMCentralUpdateCoordinator, + SynologyDSMSwitchUpdateCoordinator, +) +from .models import SynologyDSMData from .service import async_setup_services CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -79,31 +64,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = SynoApi(hass, entry) try: await api.async_setup() - except ( - SynologyDSMLogin2SARequiredException, - SynologyDSMLoginDisabledAccountException, - SynologyDSMLoginInvalidException, - SynologyDSMLoginPermissionDeniedException, - ) as err: + except SYNOLOGY_AUTH_FAILED_EXCEPTIONS as err: if err.args[0] and isinstance(err.args[0], dict): details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) else: details = EXCEPTION_UNKNOWN raise ConfigEntryAuthFailed(f"reason: {details}") from err - except (SynologyDSMLoginFailedException, SynologyDSMRequestException) as err: + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: if err.args[0] and isinstance(err.args[0], dict): details = err.args[0].get(EXCEPTION_DETAILS, EXCEPTION_UNKNOWN) else: details = EXCEPTION_UNKNOWN raise ConfigEntryNotReady(details) from err - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.unique_id] = { - UNDO_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener), - SYNO_API: api, - SYSTEM_LOADED: True, - } - # Services await async_setup_services(hass) @@ -114,111 +87,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, CONF_MAC: network.macs} ) - async def async_coordinator_update_data_cameras() -> dict[ - str, dict[str, SynoCamera] - ] | None: - """Fetch all camera data from api.""" - if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: - raise UpdateFailed("System not fully loaded") + # These all create executor jobs so we do not gather here + coordinator_central = SynologyDSMCentralUpdateCoordinator(hass, entry, api) + await coordinator_central.async_config_entry_first_refresh() - if SynoSurveillanceStation.CAMERA_API_KEY not in api.dsm.apis: - return None + available_apis = api.dsm.apis - surveillance_station = api.surveillance_station - current_data: dict[str, SynoCamera] = { - camera.id: camera for camera in surveillance_station.get_all_cameras() - } + # The central coordinator needs to be refreshed first since + # the next two rely on data from it + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None = None + if SynoSurveillanceStation.CAMERA_API_KEY in available_apis: + coordinator_cameras = SynologyDSMCameraUpdateCoordinator(hass, entry, api) + await coordinator_cameras.async_config_entry_first_refresh() + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None = None + if ( + SynoSurveillanceStation.INFO_API_KEY in available_apis + and SynoSurveillanceStation.HOME_MODE_API_KEY in available_apis + ): + coordinator_switches = SynologyDSMSwitchUpdateCoordinator(hass, entry, api) + await coordinator_switches.async_config_entry_first_refresh() try: - async with async_timeout.timeout(30): - await hass.async_add_executor_job(surveillance_station.update) - except SynologyDSMAPIErrorException as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err + await coordinator_switches.async_setup() + except SYNOLOGY_CONNECTION_EXCEPTIONS as ex: + raise ConfigEntryNotReady from ex - new_data: dict[str, SynoCamera] = { - camera.id: camera for camera in surveillance_station.get_all_cameras() - } - - for cam_id, cam_data_new in new_data.items(): - if ( - (cam_data_current := current_data.get(cam_id)) is not None - and cam_data_current.live_view.rtsp != cam_data_new.live_view.rtsp - ): - async_dispatcher_send( - hass, - f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{entry.entry_id}_{cam_id}", - cam_data_new.live_view.rtsp, - ) - - return {"cameras": new_data} - - async def async_coordinator_update_data_central() -> None: - """Fetch all device and sensor data from api.""" - try: - await api.async_update() - except Exception as err: - raise UpdateFailed(f"Error communicating with API: {err}") from err - return None - - async def async_coordinator_update_data_switches() -> dict[ - str, dict[str, Any] - ] | None: - """Fetch all switch data from api.""" - if not hass.data[DOMAIN][entry.unique_id][SYSTEM_LOADED]: - raise UpdateFailed("System not fully loaded") - if SynoSurveillanceStation.HOME_MODE_API_KEY not in api.dsm.apis: - return None - - surveillance_station = api.surveillance_station - - return { - "switches": { - "home_mode": await hass.async_add_executor_job( - surveillance_station.get_home_mode_status - ) - } - } - - hass.data[DOMAIN][entry.unique_id][COORDINATOR_CAMERAS] = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{entry.unique_id}_cameras", - update_method=async_coordinator_update_data_cameras, - update_interval=timedelta(seconds=30), + synology_data = SynologyDSMData( + api=api, + coordinator_central=coordinator_central, + coordinator_cameras=coordinator_cameras, + coordinator_switches=coordinator_switches, ) - - hass.data[DOMAIN][entry.unique_id][COORDINATOR_CENTRAL] = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{entry.unique_id}_central", - update_method=async_coordinator_update_data_central, - update_interval=timedelta( - minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ), - ) - - hass.data[DOMAIN][entry.unique_id][COORDINATOR_SWITCHES] = DataUpdateCoordinator( - hass, - _LOGGER, - name=f"{entry.unique_id}_switches", - update_method=async_coordinator_update_data_switches, - update_interval=timedelta(seconds=30), - ) - + hass.data.setdefault(DOMAIN, {})[entry.unique_id] = synology_data hass.config_entries.async_setup_platforms(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Synology DSM sensors.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - if unload_ok: - entry_data = hass.data[DOMAIN][entry.unique_id] - entry_data[UNDO_UPDATE_LISTENER]() - await entry_data[SYNO_API].async_unload() + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + entry_data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + await entry_data.api.async_unload() hass.data[DOMAIN].pop(entry.unique_id) - return unload_ok diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index a5c96575307..b5f5effbb8e 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -22,12 +22,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi -from .const import COORDINATOR_CENTRAL, DOMAIN, SYNO_API +from .const import DOMAIN from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) +from .models import SynologyDSMData @dataclass @@ -80,10 +81,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS binary sensor.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - coordinator = data[COORDINATOR_CENTRAL] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + api = data.api + coordinator = data.coordinator_central entities: list[ SynoDSMSecurityBinarySensor diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 58f1a0dfdd7..a1337e672f6 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -17,7 +17,8 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import SynoApi -from .const import DOMAIN, SYNO_API +from .const import DOMAIN +from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) @@ -60,10 +61,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set buttons for device.""" - data = hass.data[DOMAIN][entry.unique_id] - syno_api: SynoApi = data[SYNO_API] - - async_add_entities(SynologyDSMButton(syno_api, button) for button in BUTTONS) + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + async_add_entities(SynologyDSMButton(data.api, button) for button in BUTTONS) class SynologyDSMButton(ButtonEntity): diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index 0a6934b45a7..6dac67cf72d 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -25,13 +25,12 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi from .const import ( CONF_SNAPSHOT_QUALITY, - COORDINATOR_CAMERAS, DEFAULT_SNAPSHOT_QUALITY, DOMAIN, SIGNAL_CAMERA_SOURCE_CHANGED, - SYNO_API, ) from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription +from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -47,23 +46,12 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS cameras.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - - if SynoSurveillanceStation.CAMERA_API_KEY not in api.dsm.apis: - return - - # initial data fetch - coordinator: DataUpdateCoordinator[dict[str, dict[str, SynoCamera]]] = data[ - COORDINATOR_CAMERAS - ] - await coordinator.async_config_entry_first_refresh() - - async_add_entities( - SynoDSMCamera(api, coordinator, camera_id) - for camera_id in coordinator.data["cameras"] - ) + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + if coordinator := data.coordinator_cameras: + async_add_entities( + SynoDSMCamera(data.api, coordinator, camera_id) + for camera_id in coordinator.data["cameras"] + ) class SynoDSMCamera(SynologyDSMBaseEntity, Camera): diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 2ca9cbf3ccf..088686660e4 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -32,7 +32,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback -from .const import CONF_DEVICE_TOKEN, DOMAIN, SYSTEM_LOADED +from .const import CONF_DEVICE_TOKEN LOGGER = logging.getLogger(__name__) @@ -217,11 +217,6 @@ class SynoApi: ) self.surveillance_station = self.dsm.surveillance_station - def _set_system_loaded(self, state: bool = False) -> None: - """Set system loaded flag.""" - dsm_device = self._hass.data[DOMAIN].get(self.information.serial) - dsm_device[SYSTEM_LOADED] = state - async def _syno_api_executer(self, api_call: Callable) -> None: """Synology api call wrapper.""" try: @@ -235,12 +230,10 @@ class SynoApi: async def async_reboot(self) -> None: """Reboot NAS.""" await self._syno_api_executer(self.system.reboot) - self._set_system_loaded() async def async_shutdown(self) -> None: """Shutdown NAS.""" await self._syno_api_executer(self.system.shutdown) - self._set_system_loaded() async def async_unload(self) -> None: """Stop interacting with the NAS and prepare for removal from hass.""" diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py index f716130a5e4..c5c9e590684 100644 --- a/homeassistant/components/synology_dsm/const.py +++ b/homeassistant/components/synology_dsm/const.py @@ -2,6 +2,15 @@ from __future__ import annotations from synology_dsm.api.surveillance_station.const import SNAPSHOT_PROFILE_BALANCED +from synology_dsm.exceptions import ( + SynologyDSMAPIErrorException, + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, + SynologyDSMLoginFailedException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, + SynologyDSMRequestException, +) from homeassistant.const import Platform @@ -15,17 +24,9 @@ PLATFORMS = [ Platform.SWITCH, Platform.UPDATE, ] -COORDINATOR_CAMERAS = "coordinator_cameras" -COORDINATOR_CENTRAL = "coordinator_central" -COORDINATOR_SWITCHES = "coordinator_switches" -SYSTEM_LOADED = "system_loaded" EXCEPTION_DETAILS = "details" EXCEPTION_UNKNOWN = "unknown" -# Entry keys -SYNO_API = "syno_api" -UNDO_UPDATE_LISTENER = "undo_update_listener" - # Configuration CONF_SERIAL = "serial" CONF_VOLUMES = "volumes" @@ -53,3 +54,16 @@ SERVICES = [ SERVICE_REBOOT, SERVICE_SHUTDOWN, ] + +SYNOLOGY_AUTH_FAILED_EXCEPTIONS = ( + SynologyDSMLogin2SARequiredException, + SynologyDSMLoginDisabledAccountException, + SynologyDSMLoginInvalidException, + SynologyDSMLoginPermissionDeniedException, +) + +SYNOLOGY_CONNECTION_EXCEPTIONS = ( + SynologyDSMAPIErrorException, + SynologyDSMLoginFailedException, + SynologyDSMRequestException, +) diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py new file mode 100644 index 00000000000..332efb50bc8 --- /dev/null +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -0,0 +1,148 @@ +"""synology_dsm coordinators.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +import async_timeout +from synology_dsm.api.surveillance_station.camera import SynoCamera +from synology_dsm.exceptions import SynologyDSMAPIErrorException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .common import SynoApi +from .const import ( + DEFAULT_SCAN_INTERVAL, + SIGNAL_CAMERA_SOURCE_CHANGED, + SYNOLOGY_CONNECTION_EXCEPTIONS, +) + +_LOGGER = logging.getLogger(__name__) + + +class SynologyDSMUpdateCoordinator(DataUpdateCoordinator): + """DataUpdateCoordinator base class for synology_dsm.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + update_interval: timedelta, + ) -> None: + """Initialize synology_dsm DataUpdateCoordinator.""" + self.api = api + self.entry = entry + super().__init__( + hass, + _LOGGER, + name=f"{entry.title} {self.__class__.__name__}", + update_interval=update_interval, + ) + + +class SynologyDSMSwitchUpdateCoordinator(SynologyDSMUpdateCoordinator): + """DataUpdateCoordinator to gather data for a synology_dsm switch devices.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + ) -> None: + """Initialize DataUpdateCoordinator for switch devices.""" + super().__init__(hass, entry, api, timedelta(seconds=30)) + self.version: str | None = None + + async def async_setup(self) -> None: + """Set up the coordinator initial data.""" + info = await self.hass.async_add_executor_job( + self.api.dsm.surveillance_station.get_info + ) + self.version = info["data"]["CMSMinVersion"] + + async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]] | None: + """Fetch all data from api.""" + surveillance_station = self.api.surveillance_station + return { + "switches": { + "home_mode": await self.hass.async_add_executor_job( + surveillance_station.get_home_mode_status + ) + } + } + + +class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator): + """DataUpdateCoordinator to gather data for a synology_dsm central device.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + ) -> None: + """Initialize DataUpdateCoordinator for central device.""" + super().__init__( + hass, + entry, + api, + timedelta( + minutes=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ), + ) + + async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]] | None: + """Fetch all data from api.""" + try: + await self.api.async_update() + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + return None + + +class SynologyDSMCameraUpdateCoordinator(SynologyDSMUpdateCoordinator): + """DataUpdateCoordinator to gather data for a synology_dsm cameras.""" + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + api: SynoApi, + ) -> None: + """Initialize DataUpdateCoordinator for cameras.""" + super().__init__(hass, entry, api, timedelta(seconds=30)) + + async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]] | None: + """Fetch all camera data from api.""" + surveillance_station = self.api.surveillance_station + current_data: dict[str, SynoCamera] = { + camera.id: camera for camera in surveillance_station.get_all_cameras() + } + + try: + async with async_timeout.timeout(30): + await self.hass.async_add_executor_job(surveillance_station.update) + except SynologyDSMAPIErrorException as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + new_data: dict[str, SynoCamera] = { + camera.id: camera for camera in surveillance_station.get_all_cameras() + } + + for cam_id, cam_data_new in new_data.items(): + if ( + (cam_data_current := current_data.get(cam_id)) is not None + and cam_data_current.live_view.rtsp != cam_data_new.live_view.rtsp + ): + async_dispatcher_send( + self.hass, + f"{SIGNAL_CAMERA_SOURCE_CHANGED}_{self.entry.entry_id}_{cam_id}", + cam_data_new.live_view.rtsp, + ) + + return {"cameras": new_data} diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index 8709170a6f8..485a44b290a 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from . import SynoApi -from .const import CONF_DEVICE_TOKEN, DOMAIN, SYNO_API, SYSTEM_LOADED +from .const import CONF_DEVICE_TOKEN, DOMAIN +from .models import SynologyDSMData TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN} @@ -18,8 +18,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict: """Return diagnostics for a config entry.""" - data: dict = hass.data[DOMAIN][entry.unique_id] - syno_api: SynoApi = data[SYNO_API] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + syno_api = data.api dsm_info = syno_api.dsm.information diag_data = { @@ -36,7 +36,7 @@ async def async_get_config_entry_diagnostics( "surveillance_station": {"cameras": {}}, "upgrade": {}, "utilisation": {}, - "is_system_loaded": data[SYSTEM_LOADED], + "is_system_loaded": True, "api_details": { "fetching_entities": syno_api._fetching_entities, # pylint: disable=protected-access }, @@ -45,7 +45,7 @@ async def async_get_config_entry_diagnostics( if syno_api.network is not None: intf: dict for intf in syno_api.network.interfaces: - diag_data["network"]["interfaces"][intf["id"]] = { + diag_data["network"]["interfaces"][intf["id"]] = { # type: ignore[index] "type": intf["type"], "ip": intf["ip"], } @@ -53,7 +53,7 @@ async def async_get_config_entry_diagnostics( if syno_api.storage is not None: disk: dict for disk in syno_api.storage.disks: - diag_data["storage"]["disks"][disk["id"]] = { + diag_data["storage"]["disks"][disk["id"]] = { # type: ignore[index] "name": disk["name"], "vendor": disk["vendor"], "model": disk["model"], @@ -64,7 +64,7 @@ async def async_get_config_entry_diagnostics( volume: dict for volume in syno_api.storage.volumes: - diag_data["storage"]["volumes"][volume["id"]] = { + diag_data["storage"]["volumes"][volume["id"]] = { # type: ignore[index] "name": volume["fs_type"], "size": volume["size"], } @@ -72,7 +72,7 @@ async def async_get_config_entry_diagnostics( if syno_api.surveillance_station is not None: camera: SynoCamera for camera in syno_api.surveillance_station.get_all_cameras(): - diag_data["surveillance_station"]["cameras"][camera.id] = { + diag_data["surveillance_station"]["cameras"][camera.id] = { # type: ignore[index] "name": camera.name, "is_enabled": camera.is_enabled, "is_motion_detection_enabled": camera.is_motion_detection_enabled, diff --git a/homeassistant/components/synology_dsm/models.py b/homeassistant/components/synology_dsm/models.py new file mode 100644 index 00000000000..8c4341a2d37 --- /dev/null +++ b/homeassistant/components/synology_dsm/models.py @@ -0,0 +1,21 @@ +"""The synology_dsm integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from .common import SynoApi +from .coordinator import ( + SynologyDSMCameraUpdateCoordinator, + SynologyDSMCentralUpdateCoordinator, + SynologyDSMSwitchUpdateCoordinator, +) + + +@dataclass +class SynologyDSMData: + """Data for the synology_dsm integration.""" + + api: SynoApi + coordinator_central: SynologyDSMCentralUpdateCoordinator + coordinator_cameras: SynologyDSMCameraUpdateCoordinator | None + coordinator_switches: SynologyDSMSwitchUpdateCoordinator | None diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index 6015dc689b7..6a2a92b9fd5 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -31,12 +31,13 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util.dt import utcnow from . import SynoApi -from .const import CONF_VOLUMES, COORDINATOR_CENTRAL, DOMAIN, ENTITY_UNIT_LOAD, SYNO_API +from .const import CONF_VOLUMES, DOMAIN, ENTITY_UNIT_LOAD from .entity import ( SynologyDSMBaseEntity, SynologyDSMDeviceEntity, SynologyDSMEntityDescription, ) +from .models import SynologyDSMData @dataclass @@ -279,10 +280,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS Sensor.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - coordinator = data[COORDINATOR_CENTRAL] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + api = data.api + coordinator = data.coordinator_central entities: list[SynoDSMUtilSensor | SynoDSMStorageSensor | SynoDSMInfoSensor] = [ SynoDSMUtilSensor(api, coordinator, description) diff --git a/homeassistant/components/synology_dsm/service.py b/homeassistant/components/synology_dsm/service.py index 130ad110b46..0cb2bf7d822 100644 --- a/homeassistant/components/synology_dsm/service.py +++ b/homeassistant/components/synology_dsm/service.py @@ -7,15 +7,8 @@ from synology_dsm.exceptions import SynologyDSMException from homeassistant.core import HomeAssistant, ServiceCall -from .common import SynoApi -from .const import ( - CONF_SERIAL, - DOMAIN, - SERVICE_REBOOT, - SERVICE_SHUTDOWN, - SERVICES, - SYNO_API, -) +from .const import CONF_SERIAL, DOMAIN, SERVICE_REBOOT, SERVICE_SHUTDOWN, SERVICES +from .models import SynologyDSMData LOGGER = logging.getLogger(__name__) @@ -29,7 +22,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: dsm_devices = hass.data[DOMAIN] if serial: - dsm_device = dsm_devices.get(serial) + dsm_device: SynologyDSMData = hass.data[DOMAIN][serial] elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) serial = next(iter(dsm_devices)) @@ -45,7 +38,7 @@ async def async_setup_services(hass: HomeAssistant) -> None: return if call.service in [SERVICE_REBOOT, SERVICE_SHUTDOWN]: - if not (dsm_device := hass.data[DOMAIN].get(serial)): + if serial not in hass.data[DOMAIN]: LOGGER.error("DSM with specified serial %s not found", serial) return LOGGER.debug("%s DSM with serial %s", call.service, serial) @@ -53,7 +46,8 @@ async def async_setup_services(hass: HomeAssistant) -> None: "The %s service is deprecated and will be removed in future release. Please use the corresponding button entity", call.service, ) - dsm_api: SynoApi = dsm_device[SYNO_API] + dsm_device = hass.data[DOMAIN][serial] + dsm_api = dsm_device.api try: await getattr(dsm_api, f"async_{call.service}")() except SynologyDSMException as ex: diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index eb61b8334ca..26909ceddd9 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -15,8 +15,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from . import SynoApi -from .const import COORDINATOR_SWITCHES, DOMAIN, SYNO_API +from .const import DOMAIN from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription +from .models import SynologyDSMData _LOGGER = logging.getLogger(__name__) @@ -42,30 +43,16 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Synology NAS switch.""" - - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - - entities = [] - - if SynoSurveillanceStation.INFO_API_KEY in api.dsm.apis: - info = await hass.async_add_executor_job(api.dsm.surveillance_station.get_info) - version = info["data"]["CMSMinVersion"] - - # initial data fetch - coordinator: DataUpdateCoordinator = data[COORDINATOR_SWITCHES] - await coordinator.async_refresh() - entities.extend( - [ - SynoDSMSurveillanceHomeModeToggle( - api, version, coordinator, description - ) - for description in SURVEILLANCE_SWITCH - ] + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + if coordinator := data.coordinator_switches: + assert coordinator.version is not None + async_add_entities( + SynoDSMSurveillanceHomeModeToggle( + data.api, coordinator.version, coordinator, description + ) + for description in SURVEILLANCE_SWITCH ) - async_add_entities(entities, True) - class SynoDSMSurveillanceHomeModeToggle(SynologyDSMBaseEntity, SwitchEntity): """Representation a Synology Surveillance Station Home Mode toggle.""" diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 48b3eeca2ed..d3f3cc56eac 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -13,9 +13,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import SynoApi -from .const import COORDINATOR_CENTRAL, DOMAIN, SYNO_API +from .const import DOMAIN from .entity import SynologyDSMBaseEntity, SynologyDSMEntityDescription +from .models import SynologyDSMData @dataclass @@ -39,12 +39,9 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Synology DSM update entities.""" - data = hass.data[DOMAIN][entry.unique_id] - api: SynoApi = data[SYNO_API] - coordinator = data[COORDINATOR_CENTRAL] - + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] async_add_entities( - SynoDSMUpdateEntity(api, coordinator, description) + SynoDSMUpdateEntity(data.api, data.coordinator_central, description) for description in UPDATE_ENTITIES ) diff --git a/tests/components/synology_dsm/test_init.py b/tests/components/synology_dsm/test_init.py index 4d6708a2e79..db373f41656 100644 --- a/tests/components/synology_dsm/test_init.py +++ b/tests/components/synology_dsm/test_init.py @@ -24,9 +24,9 @@ from tests.common import MockConfigEntry @pytest.mark.no_bypass_setup async def test_services_registered(hass: HomeAssistant): """Test if all services are registered.""" - with patch( - "homeassistant.components.synology_dsm.SynoApi.async_setup", return_value=True - ), patch("homeassistant.components.synology_dsm.PLATFORMS", return_value=[]): + with patch("homeassistant.components.synology_dsm.common.SynologyDSM"), patch( + "homeassistant.components.synology_dsm.PLATFORMS", return_value=[] + ): entry = MockConfigEntry( domain=DOMAIN, data={ From e67aa09bf24a90c090fec4cee5d03d708b2218c2 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Thu, 9 Jun 2022 22:40:01 +0100 Subject: [PATCH 328/947] Add zeroconf discovery to hive (#73290) Co-authored-by: Dave T --- homeassistant/components/hive/manifest.json | 3 +++ homeassistant/generated/zeroconf.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index d8cd56abe0b..6e76826d305 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -2,6 +2,9 @@ "domain": "hive", "name": "Hive", "config_flow": true, + "homekit": { + "models": ["HHKBridge*"] + }, "documentation": "https://www.home-assistant.io/integrations/hive", "requirements": ["pyhiveapi==0.5.5"], "codeowners": ["@Rendili", "@KJonline"], diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 692132c9a75..8d4f1148582 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -419,6 +419,7 @@ HOMEKIT = { "C105X": "roku", "C135X": "roku", "EB-*": "ecobee", + "HHKBridge*": "hive", "Healty Home Coach": "netatmo", "Iota": "abode", "LIFX A19": "lifx", From c74159109ac7ad9a24346f5fd7b1628d036cb0d6 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 10 Jun 2022 00:25:26 +0000 Subject: [PATCH 329/947] [ci skip] Translation update --- homeassistant/components/google/translations/de.json | 3 +++ homeassistant/components/google/translations/el.json | 3 +++ homeassistant/components/google/translations/ja.json | 3 +++ homeassistant/components/google/translations/no.json | 3 +++ homeassistant/components/google/translations/pt-BR.json | 3 +++ 5 files changed, 15 insertions(+) diff --git a/homeassistant/components/google/translations/de.json b/homeassistant/components/google/translations/de.json index c75cc90eb13..2111e0c8bab 100644 --- a/homeassistant/components/google/translations/de.json +++ b/homeassistant/components/google/translations/de.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Folge den [Anweisungen]({more_info_url}) f\u00fcr den [OAuth-Zustimmungsbildschirm]({oauth_consent_url}), um Home Assistant Zugriff auf deinen Google-Kalender zu geben. Du musst auch Anwendungsnachweise erstellen, die mit deinem Kalender verkn\u00fcpft sind:\n1. Gehe zu [Credentials]({oauth_creds_url}) und klicke auf **Create Credentials**.\n1. W\u00e4hle in der Dropdown-Liste **OAuth-Client-ID**.\n1. W\u00e4hle **TV und eingeschr\u00e4nkte Eingabeger\u00e4te** f\u00fcr den Anwendungstyp.\n\n" + }, "config": { "abort": { "already_configured": "Konto wurde bereits konfiguriert", diff --git a/homeassistant/components/google/translations/el.json b/homeassistant/components/google/translations/el.json index dd93a5ab3c8..fdcf17d26ce 100644 --- a/homeassistant/components/google/translations/el.json +++ b/homeassistant/components/google/translations/el.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "\u0391\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b9\u03c2 [\u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2]({more_info_url}) \u03b3\u03b9\u03b1 \u03c4\u03b7\u03bd [\u03bf\u03b8\u03cc\u03bd\u03b7 \u03c3\u03c5\u03bd\u03b1\u03af\u03bd\u03b5\u03c3\u03b7\u03c2 OAuth]({oauth_consent_url}) \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03ce\u03c3\u03b5\u03c4\u03b5 \u03c3\u03c4\u03bf\u03bd \u0392\u03bf\u03b7\u03b8\u03cc Home \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7 \u03c3\u03c4\u03bf \u0397\u03bc\u03b5\u03c1\u03bf\u03bb\u03cc\u03b3\u03b9\u03bf Google \u03c3\u03b1\u03c2. \u03a0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03b5\u03c0\u03af\u03c3\u03b7\u03c2 \u03bd\u03b1 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03b5\u03c4\u03b5 \u0394\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1 \u0395\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2 \u03c3\u03c5\u03bd\u03b4\u03b5\u03b4\u03b5\u03bc\u03ad\u03bd\u03b1 \u03bc\u03b5 \u03c4\u03bf \u0397\u03bc\u03b5\u03c1\u03bf\u03bb\u03cc\u03b3\u03b9\u03cc \u03c3\u03b1\u03c2:\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b1 [\u0394\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1] ({oauth_creds_url}) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03b7\u03c1\u03af\u03c9\u03bd**.\n 1. \u0391\u03c0\u03cc \u03c4\u03b7\u03bd \u03b1\u03bd\u03b1\u03c0\u03c4\u03c5\u03c3\u03c3\u03cc\u03bc\u03b5\u03bd\u03b7 \u03bb\u03af\u03c3\u03c4\u03b1 \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **OAuth \u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7**.\n 1. \u0395\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **\u03a4\u03b7\u03bb\u03b5\u03cc\u03c1\u03b1\u03c3\u03b7 \u03ba\u03b1\u03b9 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 \u03c0\u03b5\u03c1\u03b9\u03bf\u03c1\u03b9\u03c3\u03bc\u03ad\u03bd\u03b7\u03c2 \u03b5\u03b9\u03c3\u03cc\u03b4\u03bf\u03c5** \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03a4\u03cd\u03c0\u03bf \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2. \n\n" + }, "config": { "abort": { "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", diff --git a/homeassistant/components/google/translations/ja.json b/homeassistant/components/google/translations/ja.json index 854e7ba1961..d5b52c197a8 100644 --- a/homeassistant/components/google/translations/ja.json +++ b/homeassistant/components/google/translations/ja.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "[OAuth\u540c\u610f\u753b\u9762] ({more_info_url}) \u306e\u624b\u9806 ({oauth_consent_url}) \u306b\u5f93\u3063\u3066\u3001Home Assistant\u304c\u3042\u306a\u305f\u306eGoogle\u30ab\u30ec\u30f3\u30c0\u30fc\u306b\u30a2\u30af\u30bb\u30b9\u3067\u304d\u308b\u3088\u3046\u306b\u3057\u307e\u3059\u3002\u307e\u305f\u3001\u30ab\u30ec\u30f3\u30c0\u30fc\u306b\u30ea\u30f3\u30af\u3057\u305f\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u8cc7\u683c\u60c5\u5831\u3092\u4f5c\u6210\u3059\u308b\u5fc5\u8981\u304c\u3042\u308a\u307e\u3059\u3002\n 1. [\u8cc7\u683c\u60c5\u5831]({oauth_creds_url} \u306b\u79fb\u52d5\u3057\u3001**Create Credentials** \u3092\u30af\u30ea\u30c3\u30af\u3057\u307e\u3059\u3002\n 1. \u30c9\u30ed\u30c3\u30d7\u30c0\u30a6\u30f3\u30ea\u30b9\u30c8\u304b\u3089 **OAuth client ID** \u3092\u9078\u629e\u3057\u307e\u3059\u3002\n 1. \u30a2\u30d7\u30ea\u30b1\u30fc\u30b7\u30e7\u30f3\u30bf\u30a4\u30d7\u3068\u3057\u3066**TV\u304a\u3088\u3073\u5236\u9650\u4ed8\u304d\u5165\u529b\u30c7\u30d0\u30a4\u30b9** \u3092\u9078\u629e\u3057\u307e\u3059\u3002\n\n" + }, "config": { "abort": { "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", diff --git a/homeassistant/components/google/translations/no.json b/homeassistant/components/google/translations/no.json index ef65c7fe9a5..4fcbeb1dae8 100644 --- a/homeassistant/components/google/translations/no.json +++ b/homeassistant/components/google/translations/no.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "F\u00f8lg [instruksjonene]( {more_info_url} ) for [OAuth-samtykkeskjermen]( {oauth_consent_url} ) for \u00e5 gi Home Assistant tilgang til Google-kalenderen din. Du m\u00e5 ogs\u00e5 opprette applikasjonslegitimasjon knyttet til kalenderen din:\n 1. G\u00e5 til [Credentials]( {oauth_creds_url} ) og klikk p\u00e5 **Create Credentials**.\n 1. Velg **OAuth-klient-ID** fra rullegardinlisten.\n 1. Velg **TV og begrensede inngangsenheter** for applikasjonstype. " + }, "config": { "abort": { "already_configured": "Kontoen er allerede konfigurert", diff --git a/homeassistant/components/google/translations/pt-BR.json b/homeassistant/components/google/translations/pt-BR.json index 85c7254b9a7..381976e0284 100644 --- a/homeassistant/components/google/translations/pt-BR.json +++ b/homeassistant/components/google/translations/pt-BR.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Siga as [instru\u00e7\u00f5es]( {more_info_url} ) para [tela de consentimento da OAuth]( {oauth_consent_url} ) para conceder ao Home Assistant acesso ao seu Google Agenda. Voc\u00ea tamb\u00e9m precisa criar credenciais de aplicativo vinculadas ao seu calend\u00e1rio:\n 1. Acesse [Credentials]( {oauth_creds_url} ) e clique em **Create Credentials**.\n 1. Na lista suspensa, selecione **ID do cliente OAuth**.\n 1. Selecione **TV e dispositivos de entrada limitada** para o tipo de aplicativo. \n\n" + }, "config": { "abort": { "already_configured": "A conta j\u00e1 foi configurada", From 15aecbb6efd7afdc0e317ca81b02eb423953b826 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Thu, 9 Jun 2022 23:32:16 -0400 Subject: [PATCH 330/947] Bumps version of pyunifiprotect to 3.9.2 to fix compat with protect 2.1.1 (#73299) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index a27c0125da3..199298d76ca 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.9.1", "unifi-discovery==1.1.3"], + "requirements": ["pyunifiprotect==3.9.2", "unifi-discovery==1.1.3"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 369cedf3d05..4c749f75456 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,7 +1993,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.1 +pyunifiprotect==3.9.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f68ac122bd2..51df9de4d4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1322,7 +1322,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.1 +pyunifiprotect==3.9.2 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 2f106112dfd15c4f9b9adef9be7ea686a91807ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 17:48:07 -1000 Subject: [PATCH 331/947] Add async_remove_config_entry_device to synology_dsm (#73293) --- .../components/synology_dsm/__init__.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index d8d768d36e4..e6868491eae 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -1,9 +1,11 @@ """The Synology DSM component.""" from __future__ import annotations +from itertools import chain import logging from synology_dsm.api.surveillance_station import SynoSurveillanceStation +from synology_dsm.api.surveillance_station.camera import SynoCamera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_VERIFY_SSL @@ -137,3 +139,28 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" await hass.config_entries.async_reload(entry.entry_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove synology_dsm config entry from a device.""" + data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] + api = data.api + serial = api.information.serial + storage = api.storage + # get_all_cameras does not do I/O + all_cameras: list[SynoCamera] = api.surveillance_station.get_all_cameras() + device_ids = chain( + (camera.id for camera in all_cameras), + storage.volumes_ids, + storage.disks_ids, + storage.volumes_ids, + (SynoSurveillanceStation.INFO_API_KEY,), # Camera home/away + ) + return not device_entry.identifiers.intersection( + ( + (DOMAIN, serial), # Base device + *((DOMAIN, f"{serial}_{id}") for id in device_ids), # Storage and cameras + ) + ) From 211e5432ac304a28f3503c42908ced60db3f58f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 17:49:02 -1000 Subject: [PATCH 332/947] Add EVENT_USER_UPDATED (#71965) --- homeassistant/auth/__init__.py | 3 +++ tests/auth/test_init.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 23fdb775a8a..12511a7f4a5 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -20,6 +20,7 @@ from .mfa_modules import MultiFactorAuthModule, auth_mfa_module_from_config from .providers import AuthProvider, LoginFlow, auth_provider_from_config EVENT_USER_ADDED = "user_added" +EVENT_USER_UPDATED = "user_updated" EVENT_USER_REMOVED = "user_removed" _MfaModuleDict = dict[str, MultiFactorAuthModule] @@ -338,6 +339,8 @@ class AuthManager: else: await self.async_deactivate_user(user) + self.hass.bus.async_fire(EVENT_USER_UPDATED, {"user_id": user.id}) + async def async_activate_user(self, user: models.User) -> None: """Activate a user.""" await self._store.async_activate_user(user) diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 53c2a4261ae..22d720da587 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant import auth, data_entry_flow from homeassistant.auth import ( + EVENT_USER_UPDATED, InvalidAuthError, auth_store, const as auth_const, @@ -1097,3 +1098,20 @@ async def test_rename_does_not_change_refresh_token(mock_hass): token_after = list(user.refresh_tokens.values())[0] assert token_before == token_after + + +async def test_event_user_updated_fires(hass): + """Test the user updated event fires.""" + manager = await auth.auth_manager_from_config(hass, [], []) + user = MockUser().add_to_auth_manager(manager) + await manager.async_create_refresh_token(user, CLIENT_ID) + + assert len(list(user.refresh_tokens.values())) == 1 + + events = async_capture_events(hass, EVENT_USER_UPDATED) + + await manager.async_update_user(user, name="new name") + assert user.name == "new name" + + await hass.async_block_till_done() + assert len(events) == 1 From d3f01f7ea92e3a3866db4bc4308374fe131630f3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 17:49:37 -1000 Subject: [PATCH 333/947] Reduce memory pressure from history_stats with large data sets (#73289) --- .../components/history_stats/data.py | 49 ++++++++++++++----- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/history_stats/data.py b/homeassistant/components/history_stats/data.py index 3b17c715c97..33f32e72292 100644 --- a/homeassistant/components/history_stats/data.py +++ b/homeassistant/components/history_stats/data.py @@ -23,6 +23,14 @@ class HistoryStatsState: period: tuple[datetime.datetime, datetime.datetime] +@dataclass +class HistoryState: + """A minimal state to avoid holding on to State objects.""" + + state: str + last_changed: float + + class HistoryStats: """Manage history stats.""" @@ -40,7 +48,7 @@ class HistoryStats: self.entity_id = entity_id self._period = (MIN_TIME_UTC, MIN_TIME_UTC) self._state: HistoryStatsState = HistoryStatsState(None, None, self._period) - self._history_current_period: list[State] = [] + self._history_current_period: list[HistoryState] = [] self._previous_run_before_start = False self._entity_states = set(entity_states) self._duration = duration @@ -103,20 +111,18 @@ class HistoryStats: <= floored_timestamp(new_state.last_changed) <= current_period_end_timestamp ): - self._history_current_period.append(new_state) + self._history_current_period.append( + HistoryState( + new_state.state, new_state.last_changed.timestamp() + ) + ) new_data = True if not new_data and current_period_end_timestamp < now_timestamp: # If period has not changed and current time after the period end... # Don't compute anything as the value cannot have changed return self._state else: - self._history_current_period = await get_instance( - self.hass - ).async_add_executor_job( - self._update_from_database, - current_period_start, - current_period_end, - ) + await self._async_history_from_db(current_period_start, current_period_end) self._previous_run_before_start = False hours_matched, match_count = self._async_compute_hours_and_changes( @@ -127,7 +133,24 @@ class HistoryStats: self._state = HistoryStatsState(hours_matched, match_count, self._period) return self._state - def _update_from_database( + async def _async_history_from_db( + self, + current_period_start: datetime.datetime, + current_period_end: datetime.datetime, + ) -> None: + """Update history data for the current period from the database.""" + instance = get_instance(self.hass) + states = await instance.async_add_executor_job( + self._state_changes_during_period, + current_period_start, + current_period_end, + ) + self._history_current_period = [ + HistoryState(state.state, state.last_changed.timestamp()) + for state in states + ] + + def _state_changes_during_period( self, start: datetime.datetime, end: datetime.datetime ) -> list[State]: return history.state_changes_during_period( @@ -155,9 +178,9 @@ class HistoryStats: match_count = 1 if previous_state_matches else 0 # Make calculations - for item in self._history_current_period: - current_state_matches = item.state in self._entity_states - state_change_timestamp = item.last_changed.timestamp() + for history_state in self._history_current_period: + current_state_matches = history_state.state in self._entity_states + state_change_timestamp = history_state.last_changed if previous_state_matches: elapsed += state_change_timestamp - last_state_change_timestamp From 1dd7781acc50d4b0bf9b9a2fd36400c94a94ec05 Mon Sep 17 00:00:00 2001 From: Khole Date: Fri, 10 Jun 2022 04:54:24 +0100 Subject: [PATCH 334/947] Hive auth fix for users (#73247) --- homeassistant/components/hive/config_flow.py | 7 +++-- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hive/test_config_flow.py | 30 -------------------- 5 files changed, 8 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index c713a3011f4..90c78aefcbd 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -27,6 +27,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.data = {} self.tokens = {} self.entry = None + self.device_registration = False async def async_step_user(self, user_input=None): """Prompt user input. Create or edit entry.""" @@ -88,6 +89,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if not errors: try: + self.device_registration = True return await self.async_setup_hive_entry() except UnknownHiveError: errors["base"] = "unknown" @@ -102,9 +104,10 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): raise UnknownHiveError # Setup the config entry - await self.hive_auth.device_registration("Home Assistant") + if self.device_registration: + await self.hive_auth.device_registration("Home Assistant") + self.data["device_data"] = await self.hive_auth.getDeviceData() self.data["tokens"] = self.tokens - self.data["device_data"] = await self.hive_auth.getDeviceData() if self.context["source"] == config_entries.SOURCE_REAUTH: self.hass.config_entries.async_update_entry( self.entry, title=self.data["username"], data=self.data diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 6e76826d305..341273638bf 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -6,7 +6,7 @@ "models": ["HHKBridge*"] }, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.5.5"], + "requirements": ["pyhiveapi==0.5.9"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/requirements_all.txt b/requirements_all.txt index 4c749f75456..b71d68bdaea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1541,7 +1541,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.5.5 +pyhiveapi==0.5.9 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51df9de4d4f..c7ce45f3db3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1032,7 +1032,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.5 +pyhiveapi==0.5.9 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index 51ceec43ad2..35e20e8eee3 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -33,16 +33,6 @@ async def test_import_flow(hass): "AccessToken": "mock-access-token", }, }, - ), patch( - "homeassistant.components.hive.config_flow.Auth.device_registration", - return_value=True, - ), patch( - "homeassistant.components.hive.config_flow.Auth.getDeviceData", - return_value=[ - "mock-device-group-key", - "mock-device-key", - "mock-device-password", - ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -67,11 +57,6 @@ async def test_import_flow(hass): }, "ChallengeName": "SUCCESS", }, - "device_data": [ - "mock-device-group-key", - "mock-device-key", - "mock-device-password", - ], } assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 @@ -96,16 +81,6 @@ async def test_user_flow(hass): "AccessToken": "mock-access-token", }, }, - ), patch( - "homeassistant.components.hive.config_flow.Auth.device_registration", - return_value=True, - ), patch( - "homeassistant.components.hive.config_flow.Auth.getDeviceData", - return_value=[ - "mock-device-group-key", - "mock-device-key", - "mock-device-password", - ], ), patch( "homeassistant.components.hive.async_setup", return_value=True ) as mock_setup, patch( @@ -130,11 +105,6 @@ async def test_user_flow(hass): }, "ChallengeName": "SUCCESS", }, - "device_data": [ - "mock-device-group-key", - "mock-device-key", - "mock-device-password", - ], } assert len(mock_setup.mock_calls) == 1 From 5863d57e734fda4b9146bd6fe8b7d6ce88f7fe9b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 17:56:58 -1000 Subject: [PATCH 335/947] Add strict typing to homekit locks (#73264) --- .strict-typing | 1 + homeassistant/components/homekit/type_locks.py | 10 ++++++---- mypy.ini | 11 +++++++++++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.strict-typing b/.strict-typing index 264b28408f9..f81b6249fed 100644 --- a/.strict-typing +++ b/.strict-typing @@ -114,6 +114,7 @@ homeassistant.components.homekit.aidmanager homeassistant.components.homekit.config_flow homeassistant.components.homekit.diagnostics homeassistant.components.homekit.logbook +homeassistant.components.homekit.type_locks homeassistant.components.homekit.type_triggers homeassistant.components.homekit.util homeassistant.components.homekit_controller diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index af7501e1869..18dfe48b2bd 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -1,5 +1,6 @@ """Class to hold all lock accessories.""" import logging +from typing import Any from pyhap.const import CATEGORY_DOOR_LOCK @@ -12,7 +13,7 @@ from homeassistant.components.lock import ( STATE_UNLOCKING, ) from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN -from homeassistant.core import callback +from homeassistant.core import State, callback from .accessories import TYPES, HomeAccessory from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK @@ -59,11 +60,12 @@ class Lock(HomeAccessory): The lock entity must support: unlock and lock. """ - def __init__(self, *args): + def __init__(self, *args: Any) -> None: """Initialize a Lock accessory object.""" super().__init__(*args, category=CATEGORY_DOOR_LOCK) self._code = self.config.get(ATTR_CODE) state = self.hass.states.get(self.entity_id) + assert state is not None serv_lock_mechanism = self.add_preload_service(SERV_LOCK) self.char_current_state = serv_lock_mechanism.configure_char( @@ -76,7 +78,7 @@ class Lock(HomeAccessory): ) self.async_update_state(state) - def set_state(self, value): + def set_state(self, value: int) -> None: """Set lock state to value if call came from HomeKit.""" _LOGGER.debug("%s: Set state to %d", self.entity_id, value) @@ -89,7 +91,7 @@ class Lock(HomeAccessory): self.async_call_service(DOMAIN, service, params) @callback - def async_update_state(self, new_state): + def async_update_state(self, new_state: State) -> None: """Update lock after state changed.""" hass_state = new_state.state current_lock_state = HASS_TO_HOMEKIT_CURRENT.get( diff --git a/mypy.ini b/mypy.ini index 522cda67e79..11a2d83ec40 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1017,6 +1017,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.homekit.type_locks] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.homekit.type_triggers] check_untyped_defs = true disallow_incomplete_defs = true From a9ab98fb4519975636cb5aa29bab26d9b42c83d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 18:10:15 -1000 Subject: [PATCH 336/947] Add power sensor to WiZ (#73260) --- homeassistant/components/wiz/__init__.py | 10 ++++-- homeassistant/components/wiz/entity.py | 9 +++-- homeassistant/components/wiz/manifest.json | 2 +- homeassistant/components/wiz/sensor.py | 38 ++++++++++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/wiz/__init__.py | 13 ++++++++ tests/components/wiz/test_sensor.py | 30 +++++++++++++++++ 8 files changed, 95 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/wiz/__init__.py b/homeassistant/components/wiz/__init__.py index 104ecb6f0c5..b47db32d90f 100644 --- a/homeassistant/components/wiz/__init__.py +++ b/homeassistant/components/wiz/__init__.py @@ -1,4 +1,6 @@ """WiZ Platform integration.""" +from __future__ import annotations + import asyncio from datetime import timedelta import logging @@ -80,10 +82,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "Found bulb {bulb.mac} at {ip_address}, expected {entry.unique_id}" ) - async def _async_update() -> None: + async def _async_update() -> float | None: """Update the WiZ device.""" try: await bulb.updateState() + if bulb.power_monitoring is not False: + power: float | None = await bulb.get_power() + return power + return None except WIZ_EXCEPTIONS as ex: raise UpdateFailed(f"Failed to update device at {ip_address}: {ex}") from ex @@ -117,7 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def _async_push_update(state: PilotParser) -> None: """Receive a push update.""" _LOGGER.debug("%s: Got push update: %s", bulb.mac, state.pilotResult) - coordinator.async_set_updated_data(None) + coordinator.async_set_updated_data(coordinator.data) if state.get_source() == PIR_SOURCE: async_dispatcher_send(hass, SIGNAL_WIZ_PIR.format(bulb.mac)) diff --git a/homeassistant/components/wiz/entity.py b/homeassistant/components/wiz/entity.py index 9b22d35de7d..c78f3e3b37b 100644 --- a/homeassistant/components/wiz/entity.py +++ b/homeassistant/components/wiz/entity.py @@ -2,7 +2,7 @@ from __future__ import annotations from abc import abstractmethod -from typing import Any +from typing import Any, Optional from pywizlight.bulblibrary import BulbType @@ -10,12 +10,15 @@ from homeassistant.const import ATTR_HW_VERSION, ATTR_MODEL from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.entity import DeviceInfo, Entity, ToggleEntity -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .models import WizData -class WizEntity(CoordinatorEntity, Entity): +class WizEntity(CoordinatorEntity[DataUpdateCoordinator[Optional[float]]], Entity): """Representation of WiZ entity.""" def __init__(self, wiz_data: WizData, name: str) -> None: diff --git a/homeassistant/components/wiz/manifest.json b/homeassistant/components/wiz/manifest.json index a3537d3cbd9..c2eb6fe1b53 100644 --- a/homeassistant/components/wiz/manifest.json +++ b/homeassistant/components/wiz/manifest.json @@ -13,7 +13,7 @@ "dependencies": ["network"], "quality_scale": "platinum", "documentation": "https://www.home-assistant.io/integrations/wiz", - "requirements": ["pywizlight==0.5.13"], + "requirements": ["pywizlight==0.5.14"], "iot_class": "local_push", "codeowners": ["@sbidy"] } diff --git a/homeassistant/components/wiz/sensor.py b/homeassistant/components/wiz/sensor.py index d16130883d5..11f3933fd16 100644 --- a/homeassistant/components/wiz/sensor.py +++ b/homeassistant/components/wiz/sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import SIGNAL_STRENGTH_DECIBELS_MILLIWATT +from homeassistant.const import POWER_WATT, SIGNAL_STRENGTH_DECIBELS_MILLIWATT from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,6 +30,17 @@ SENSORS: tuple[SensorEntityDescription, ...] = ( ) +POWER_SENSORS: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="power", + name="Current Power", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + native_unit_of_measurement=POWER_WATT, + ), +) + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, @@ -37,9 +48,17 @@ async def async_setup_entry( ) -> None: """Set up the wiz sensor.""" wiz_data: WizData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( + entities = [ WizSensor(wiz_data, entry.title, description) for description in SENSORS - ) + ] + if wiz_data.coordinator.data is not None: + entities.extend( + [ + WizPowerSensor(wiz_data, entry.title, description) + for description in POWER_SENSORS + ] + ) + async_add_entities(entities) class WizSensor(WizEntity, SensorEntity): @@ -63,3 +82,16 @@ class WizSensor(WizEntity, SensorEntity): self._attr_native_value = self._device.state.pilotResult.get( self.entity_description.key ) + + +class WizPowerSensor(WizSensor): + """Defines a WiZ power sensor.""" + + @callback + def _async_update_attrs(self) -> None: + """Handle updating _attr values.""" + # Newer firmwares will have the power in their state + watts_push = self._device.state.get_power() + # Older firmwares will be polled and in the coordinator data + watts_poll = self.coordinator.data + self._attr_native_value = watts_poll if watts_push is None else watts_push diff --git a/requirements_all.txt b/requirements_all.txt index b71d68bdaea..5f27d8c524a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2029,7 +2029,7 @@ pywemo==0.9.1 pywilight==0.0.70 # homeassistant.components.wiz -pywizlight==0.5.13 +pywizlight==0.5.14 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7ce45f3db3..900b8fda255 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1349,7 +1349,7 @@ pywemo==0.9.1 pywilight==0.0.70 # homeassistant.components.wiz -pywizlight==0.5.13 +pywizlight==0.5.14 # homeassistant.components.ws66i pyws66i==1.1 diff --git a/tests/components/wiz/__init__.py b/tests/components/wiz/__init__.py index 62920662c6f..93033d984fa 100644 --- a/tests/components/wiz/__init__.py +++ b/tests/components/wiz/__init__.py @@ -150,6 +150,17 @@ FAKE_SOCKET = BulbType( white_channels=2, white_to_color_ratio=80, ) +FAKE_SOCKET_WITH_POWER_MONITORING = BulbType( + bulb_type=BulbClass.SOCKET, + name="ESP25_SOCKET_01", + features=Features( + color=False, color_tmp=False, effect=False, brightness=False, dual_head=False + ), + kelvin_range=KelvinRange(2700, 6500), + fw_version="1.26.2", + white_channels=2, + white_to_color_ratio=80, +) FAKE_OLD_FIRMWARE_DIMMABLE_BULB = BulbType( bulb_type=BulbClass.DW, name=None, @@ -197,7 +208,9 @@ def _mocked_wizlight(device, extended_white_range, bulb_type) -> wizlight: ) bulb.getMac = AsyncMock(return_value=FAKE_MAC) bulb.turn_on = AsyncMock() + bulb.get_power = AsyncMock(return_value=None) bulb.turn_off = AsyncMock() + bulb.power_monitoring = False bulb.updateState = AsyncMock(return_value=FAKE_STATE) bulb.getSupportedScenes = AsyncMock(return_value=list(SCENES.values())) bulb.start_push = AsyncMock(side_effect=_save_setup_callback) diff --git a/tests/components/wiz/test_sensor.py b/tests/components/wiz/test_sensor.py index 37a6b04dad3..a1eb6ded51d 100644 --- a/tests/components/wiz/test_sensor.py +++ b/tests/components/wiz/test_sensor.py @@ -1,11 +1,15 @@ """Tests for the sensor platform.""" +from unittest.mock import AsyncMock + from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import ( FAKE_DUAL_HEAD_RGBWW_BULB, FAKE_MAC, + FAKE_SOCKET_WITH_POWER_MONITORING, + _mocked_wizlight, _patch_discovery, _patch_wizlight, async_push_update, @@ -35,3 +39,29 @@ async def test_signal_strength(hass: HomeAssistant) -> None: await async_push_update(hass, bulb, {"mac": FAKE_MAC, "rssi": -50}) assert hass.states.get(entity_id).state == "-50" + + +async def test_power_monitoring(hass: HomeAssistant) -> None: + """Test power monitoring.""" + socket = _mocked_wizlight(None, None, FAKE_SOCKET_WITH_POWER_MONITORING) + socket.power_monitoring = None + socket.get_power = AsyncMock(return_value=5.123) + _, entry = await async_setup_integration( + hass, wizlight=socket, bulb_type=FAKE_SOCKET_WITH_POWER_MONITORING + ) + entity_id = "sensor.mock_title_current_power" + entity_registry = er.async_get(hass) + reg_entry = entity_registry.async_get(entity_id) + assert reg_entry.unique_id == f"{FAKE_MAC}_power" + updated_entity = entity_registry.async_update_entity( + entity_id=entity_id, disabled_by=None + ) + assert not updated_entity.disabled + + with _patch_discovery(), _patch_wizlight(device=socket): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id).state == "5.123" + await async_push_update(hass, socket, {"mac": FAKE_MAC, "pc": 800}) + assert hass.states.get(entity_id).state == "0.8" From 0505c596a563c92def54ea8108be09a338a0dd53 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 18:11:23 -1000 Subject: [PATCH 337/947] Fix dropouts in history_stats graphs on restart (#73110) --- .../components/history_stats/sensor.py | 5 + tests/components/history_stats/test_sensor.py | 477 +++++++++--------- 2 files changed, 241 insertions(+), 241 deletions(-) diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index b0ce1a8fca5..a42c516f12b 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( TIME_HOURS, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.reload import async_setup_reload_service @@ -101,6 +102,9 @@ async def async_setup_platform( history_stats = HistoryStats(hass, entity_id, entity_states, start, end, duration) coordinator = HistoryStatsUpdateCoordinator(hass, history_stats, name) + await coordinator.async_refresh() + if not coordinator.last_update_success: + raise PlatformNotReady from coordinator.last_exception async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name)]) @@ -152,6 +156,7 @@ class HistoryStatsSensor(HistoryStatsSensorBase): super().__init__(coordinator, name) self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type + self._process_update() @callback def _process_update(self) -> None: diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index f824ee552ca..8907f381a6c 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant import config as hass_config from homeassistant.components.history_stats import DOMAIN -from homeassistant.const import SERVICE_RELOAD, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component, setup_component @@ -50,7 +50,7 @@ class TestHistoryStatsSensor(unittest.TestCase): self.hass.block_till_done() state = self.hass.states.get("sensor.test") - assert state.state == STATE_UNKNOWN + assert state.state == "0.0" def test_setup_multiple_states(self): """Test the history statistics sensor setup for multiple states.""" @@ -71,7 +71,7 @@ class TestHistoryStatsSensor(unittest.TestCase): self.hass.block_till_done() state = self.hass.states.get("sensor.test") - assert state.state == STATE_UNKNOWN + assert state.state == "0.0" def test_wrong_duration(self): """Test when duration value is not a timedelta.""" @@ -153,12 +153,12 @@ async def test_invalid_date_for_start(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_invalid_date_for_end(hass, recorder_mock): @@ -178,12 +178,12 @@ async def test_invalid_date_for_end(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_invalid_entity_in_template(hass, recorder_mock): @@ -203,12 +203,12 @@ async def test_invalid_entity_in_template(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): @@ -228,12 +228,12 @@ async def test_invalid_entity_returning_none_in_template(hass, recorder_mock): }, ) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNKNOWN + assert hass.states.get("sensor.test") is None next_update_time = dt_util.utcnow() + timedelta(minutes=1) with freeze_time(next_update_time): async_fire_time_changed(hass, next_update_time) await hass.async_block_till_done() - assert hass.states.get("sensor.test").state == STATE_UNAVAILABLE + assert hass.states.get("sensor.test") is None async def test_reload(hass, recorder_mock): @@ -302,56 +302,55 @@ async def test_measure_multiple(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "input_select.test_id", - "name": "sensor1", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "unknown.test_id", - "name": "sensor2", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "input_select.test_id", - "name": "sensor3", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "input_select.test_id", - "name": "sensor4", - "state": ["orange", "blue"], - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "input_select.test_id", + "name": "sensor1", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "unknown.test_id", + "name": "sensor2", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "input_select.test_id", + "name": "sensor3", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "input_select.test_id", + "name": "sensor4", + "state": ["orange", "blue"], + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -382,56 +381,55 @@ async def test_measure(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor1", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor2", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor3", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor4", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -463,56 +461,55 @@ async def test_async_on_entire_period(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor1", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor2", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor3", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_on_id", - "name": "on_sensor4", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_on_id", + "name": "on_sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.on_sensor{i}") await hass.async_block_till_done() @@ -1235,56 +1232,55 @@ async def test_measure_from_end_going_backwards(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor1", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor2", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor3", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor4", - "state": "on", - "duration": {"hours": 1}, - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ), freeze_time(start_time): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor1", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor2", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor3", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor4", + "state": "on", + "duration": {"hours": 1}, + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() @@ -1329,56 +1325,55 @@ async def test_measure_cet(hass, recorder_mock): ] } - await async_setup_component( - hass, - "sensor", - { - "sensor": [ - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor1", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor2", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "time", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor3", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "count", - }, - { - "platform": "history_stats", - "entity_id": "binary_sensor.test_id", - "name": "sensor4", - "state": "on", - "start": "{{ as_timestamp(now()) - 3600 }}", - "end": "{{ now() }}", - "type": "ratio", - }, - ] - }, - ) - await hass.async_block_till_done() - with patch( "homeassistant.components.recorder.history.state_changes_during_period", _fake_states, ): + await async_setup_component( + hass, + "sensor", + { + "sensor": [ + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor1", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor2", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "time", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor3", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "count", + }, + { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "name": "sensor4", + "state": "on", + "start": "{{ as_timestamp(now()) - 3600 }}", + "end": "{{ now() }}", + "type": "ratio", + }, + ] + }, + ) + await hass.async_block_till_done() for i in range(1, 5): await async_update_entity(hass, f"sensor.sensor{i}") await hass.async_block_till_done() From 0f4080bca3960ce205b5906094519cd1f3bd97bb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 18:24:39 -1000 Subject: [PATCH 338/947] Fix synology_dsm coordinator typing (#73301) --- homeassistant/components/synology_dsm/coordinator.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index 332efb50bc8..e2f0f0741f4 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import timedelta import logging +from typing import Any import async_timeout from synology_dsm.api.surveillance_station.camera import SynoCamera @@ -65,7 +66,7 @@ class SynologyDSMSwitchUpdateCoordinator(SynologyDSMUpdateCoordinator): ) self.version = info["data"]["CMSMinVersion"] - async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]] | None: + async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Fetch all data from api.""" surveillance_station = self.api.surveillance_station return { @@ -96,7 +97,7 @@ class SynologyDSMCentralUpdateCoordinator(SynologyDSMUpdateCoordinator): ), ) - async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]] | None: + async def _async_update_data(self) -> None: """Fetch all data from api.""" try: await self.api.async_update() @@ -117,7 +118,7 @@ class SynologyDSMCameraUpdateCoordinator(SynologyDSMUpdateCoordinator): """Initialize DataUpdateCoordinator for cameras.""" super().__init__(hass, entry, api, timedelta(seconds=30)) - async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]] | None: + async def _async_update_data(self) -> dict[str, dict[str, SynoCamera]]: """Fetch all camera data from api.""" surveillance_station = self.api.surveillance_station current_data: dict[str, SynoCamera] = { From 15621bee3f304cd1c36a5712318c4ad240faba5b Mon Sep 17 00:00:00 2001 From: Adam Dullage Date: Fri, 10 Jun 2022 05:37:36 +0100 Subject: [PATCH 339/947] Fix polling frequency for Starling integration (#73282) --- homeassistant/components/starlingbank/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py index 40f1e0ff3fd..0069fe7a65f 100644 --- a/homeassistant/components/starlingbank/sensor.py +++ b/homeassistant/components/starlingbank/sensor.py @@ -1,6 +1,7 @@ """Support for balance data via the Starling Bank API.""" from __future__ import annotations +from datetime import timedelta import logging import requests @@ -26,6 +27,7 @@ DEFAULT_SANDBOX = False DEFAULT_ACCOUNT_NAME = "Starling" ICON = "mdi:currency-gbp" +SCAN_INTERVAL = timedelta(seconds=180) ACCOUNT_SCHEMA = vol.Schema( { From 7a5fa8eb58f49282e73f454826472ba54cd37a30 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Thu, 9 Jun 2022 22:14:43 -0700 Subject: [PATCH 340/947] Update more nest tests to use common fixtures (#73303) Update nest tests to use fixtures --- tests/components/nest/common.py | 28 -- tests/components/nest/test_device_trigger.py | 137 ++++---- tests/components/nest/test_events.py | 290 +++++++-------- tests/components/nest/test_media_source.py | 349 +++++++++---------- 4 files changed, 357 insertions(+), 447 deletions(-) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index bf8af8db127..988906606ad 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -5,7 +5,6 @@ import copy from dataclasses import dataclass import time from typing import Any, Generator, TypeVar -from unittest.mock import patch from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.device import Device @@ -16,7 +15,6 @@ from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import SDM_SCOPES -from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -198,29 +196,3 @@ class CreateDevice: data.update(raw_data if raw_data else {}) data["traits"].update(raw_traits if raw_traits else {}) self.device_manager.add_device(Device.MakeDevice(data, auth=self.auth)) - - -async def async_setup_sdm_platform( - hass, - platform, - devices={}, -): - """Set up the platform and prerequisites.""" - create_config_entry().add_to_hass(hass) - subscriber = FakeSubscriber() - device_manager = await subscriber.async_get_device_manager() - if devices: - for device in devices.values(): - device_manager.add_device(device) - platforms = [] - if platform: - platforms = [platform] - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", platforms), patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() - return subscriber diff --git a/tests/components/nest/test_device_trigger.py b/tests/components/nest/test_device_trigger.py index ee93323fcd8..3272c2a7c59 100644 --- a/tests/components/nest/test_device_trigger.py +++ b/tests/components/nest/test_device_trigger.py @@ -1,5 +1,4 @@ """The tests for Nest device triggers.""" -from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage import pytest @@ -10,11 +9,12 @@ from homeassistant.components.device_automation.exceptions import ( ) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.events import NEST_EVENT +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .common import async_setup_sdm_platform +from .common import DEVICE_ID, CreateDevice, FakeSubscriber, PlatformSetup from tests.common import ( assert_lists_same, @@ -22,11 +22,16 @@ from tests.common import ( async_mock_service, ) -DEVICE_ID = "some-device-id" DEVICE_NAME = "My Camera" DATA_MESSAGE = {"message": "service-called"} +@pytest.fixture +def platforms() -> list[str]: + """Fixture to setup the platforms to test.""" + return ["camera"] + + def make_camera(device_id, name=DEVICE_NAME, traits={}): """Create a nest camera.""" traits = traits.copy() @@ -45,21 +50,11 @@ def make_camera(device_id, name=DEVICE_NAME, traits={}): }, } ) - return Device.MakeDevice( - { - "name": device_id, - "type": "sdm.devices.types.CAMERA", - "traits": traits, - }, - auth=None, - ) - - -async def async_setup_camera(hass, devices=None): - """Set up the platform and prerequisites for testing available triggers.""" - if not devices: - devices = {DEVICE_ID: make_camera(device_id=DEVICE_ID)} - return await async_setup_sdm_platform(hass, "camera", devices) + return { + "name": device_id, + "type": "sdm.devices.types.CAMERA", + "traits": traits, + } async def setup_automation(hass, device_id, trigger_type): @@ -92,16 +87,20 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -async def test_get_triggers(hass): +async def test_get_triggers( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test we get the expected triggers from a nest.""" - camera = make_camera( - device_id=DEVICE_ID, - traits={ - "sdm.devices.traits.CameraMotion": {}, - "sdm.devices.traits.CameraPerson": {}, - }, + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) ) - await async_setup_camera(hass, {DEVICE_ID: camera}) + await setup_platform() device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) @@ -128,23 +127,29 @@ async def test_get_triggers(hass): assert_lists_same(triggers, expected_triggers) -async def test_multiple_devices(hass): +async def test_multiple_devices( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test we get the expected triggers from a nest.""" - camera1 = make_camera( - device_id="device-id-1", - name="Camera 1", - traits={ - "sdm.devices.traits.CameraSound": {}, - }, + create_device.create( + raw_data=make_camera( + device_id="device-id-1", + name="Camera 1", + traits={ + "sdm.devices.traits.CameraSound": {}, + }, + ) ) - camera2 = make_camera( - device_id="device-id-2", - name="Camera 2", - traits={ - "sdm.devices.traits.DoorbellChime": {}, - }, + create_device.create( + raw_data=make_camera( + device_id="device-id-2", + name="Camera 2", + traits={ + "sdm.devices.traits.DoorbellChime": {}, + }, + ) ) - await async_setup_camera(hass, {"device-id-1": camera1, "device-id-2": camera2}) + await setup_platform() registry = er.async_get(hass) entry1 = registry.async_get("camera.camera_1") @@ -177,16 +182,20 @@ async def test_multiple_devices(hass): } -async def test_triggers_for_invalid_device_id(hass): +async def test_triggers_for_invalid_device_id( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Get triggers for a device not found in the API.""" - camera = make_camera( - device_id=DEVICE_ID, - traits={ - "sdm.devices.traits.CameraMotion": {}, - "sdm.devices.traits.CameraPerson": {}, - }, + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + "sdm.devices.traits.CameraPerson": {}, + }, + ) ) - await async_setup_camera(hass, {DEVICE_ID: camera}) + await setup_platform() device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) @@ -207,14 +216,16 @@ async def test_triggers_for_invalid_device_id(hass): ) -async def test_no_triggers(hass): +async def test_no_triggers( + hass: HomeAssistant, create_device: CreateDevice, setup_platform: PlatformSetup +) -> None: """Test we get the expected triggers from a nest.""" - camera = make_camera(device_id=DEVICE_ID, traits={}) - await async_setup_camera(hass, {DEVICE_ID: camera}) + create_device.create(raw_data=make_camera(device_id=DEVICE_ID, traits={})) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.my_camera") - assert entry.unique_id == "some-device-id-camera" + assert entry.unique_id == f"{DEVICE_ID}-camera" triggers = await async_get_device_automations( hass, DeviceAutomationType.TRIGGER, entry.device_id @@ -294,15 +305,23 @@ async def test_trigger_for_wrong_event_type(hass, calls): assert len(calls) == 0 -async def test_subscriber_automation(hass, calls): +async def test_subscriber_automation( + hass: HomeAssistant, + calls: list, + create_device: CreateDevice, + setup_platform: PlatformSetup, + subscriber: FakeSubscriber, +) -> None: """Test end to end subscriber triggers automation.""" - camera = make_camera( - device_id=DEVICE_ID, - traits={ - "sdm.devices.traits.CameraMotion": {}, - }, + create_device.create( + raw_data=make_camera( + device_id=DEVICE_ID, + traits={ + "sdm.devices.traits.CameraMotion": {}, + }, + ) ) - subscriber = await async_setup_camera(hass, {DEVICE_ID: camera}) + await setup_platform() device_registry = dr.async_get(hass) device_entry = device_registry.async_get_device({("nest", DEVICE_ID)}) diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 0ab387a7dea..28550bd57b6 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -13,11 +13,12 @@ from unittest.mock import patch from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage +import pytest from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util.dt import utcnow -from .common import async_setup_sdm_platform +from .common import CreateDevice from tests.common import async_capture_events @@ -31,26 +32,43 @@ EVENT_ID = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." EVENT_KEYS = {"device_id", "type", "timestamp", "zones"} +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms to setup.""" + return [PLATFORM] + + +@pytest.fixture +def device_type() -> str: + """Fixture for the type of device under test.""" + return "sdm.devices.types.DOORBELL" + + +@pytest.fixture +def device_traits() -> list[str]: + """Fixture for the present traits of the device under test.""" + return ["sdm.devices.traits.DoorbellChime"] + + +@pytest.fixture(autouse=True) +def device( + device_type: str, device_traits: dict[str, Any], create_device: CreateDevice +) -> None: + """Fixture to create a device under test.""" + return create_device.create( + raw_data={ + "name": DEVICE_ID, + "type": device_type, + "traits": create_device_traits(device_traits), + } + ) + + def event_view(d: Mapping[str, Any]) -> Mapping[str, Any]: """View of an event with relevant keys for testing.""" return {key: value for key, value in d.items() if key in EVENT_KEYS} -async def async_setup_devices(hass, device_type, traits={}, auth=None): - """Set up the platform and prerequisites.""" - devices = { - DEVICE_ID: Device.MakeDevice( - { - "name": DEVICE_ID, - "type": device_type, - "traits": traits, - }, - auth=auth, - ), - } - return await async_setup_sdm_platform(hass, PLATFORM, devices=devices) - - def create_device_traits(event_traits=[]): """Create fake traits for a device.""" result = { @@ -98,15 +116,45 @@ def create_events(events, device_id=DEVICE_ID, timestamp=None): ) -async def test_doorbell_chime_event(hass, auth): +@pytest.mark.parametrize( + "device_type,device_traits,event_trait,expected_model,expected_type", + [ + ( + "sdm.devices.types.DOORBELL", + ["sdm.devices.traits.DoorbellChime"], + "sdm.devices.events.DoorbellChime.Chime", + "Doorbell", + "doorbell_chime", + ), + ( + "sdm.devices.types.CAMERA", + ["sdm.devices.traits.CameraMotion"], + "sdm.devices.events.CameraMotion.Motion", + "Camera", + "camera_motion", + ), + ( + "sdm.devices.types.CAMERA", + ["sdm.devices.traits.CameraPerson"], + "sdm.devices.events.CameraPerson.Person", + "Camera", + "camera_person", + ), + ( + "sdm.devices.types.CAMERA", + ["sdm.devices.traits.CameraSound"], + "sdm.devices.events.CameraSound.Sound", + "Camera", + "camera_sound", + ), + ], +) +async def test_event( + hass, auth, setup_platform, subscriber, event_trait, expected_model, expected_type +): """Test a pubsub message for a doorbell event.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - auth, - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") @@ -118,115 +166,32 @@ async def test_doorbell_chime_event(hass, auth): device_registry = dr.async_get(hass) device = device_registry.async_get(entry.device_id) assert device.name == "Front" - assert device.model == "Doorbell" + assert device.model == expected_model assert device.identifiers == {("nest", DEVICE_ID)} timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.DoorbellChime.Chime", timestamp=timestamp) - ) + await subscriber.async_receive_event(create_event(event_trait, timestamp=timestamp)) await hass.async_block_till_done() event_time = timestamp.replace(microsecond=0) assert len(events) == 1 assert event_view(events[0].data) == { "device_id": entry.device_id, - "type": "doorbell_chime", + "type": expected_type, "timestamp": event_time, } -async def test_camera_motion_event(hass): - """Test a pubsub message for a camera motion event.""" - events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.CAMERA", - create_device_traits(["sdm.devices.traits.CameraMotion"]), - ) - registry = er.async_get(hass) - entry = registry.async_get("camera.front") - assert entry is not None - - timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.CameraMotion.Motion", timestamp=timestamp) - ) - await hass.async_block_till_done() - - event_time = timestamp.replace(microsecond=0) - assert len(events) == 1 - assert event_view(events[0].data) == { - "device_id": entry.device_id, - "type": "camera_motion", - "timestamp": event_time, - } - - -async def test_camera_sound_event(hass): - """Test a pubsub message for a camera sound event.""" - events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.CAMERA", - create_device_traits(["sdm.devices.traits.CameraSound"]), - ) - registry = er.async_get(hass) - entry = registry.async_get("camera.front") - assert entry is not None - - timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.CameraSound.Sound", timestamp=timestamp) - ) - await hass.async_block_till_done() - - event_time = timestamp.replace(microsecond=0) - assert len(events) == 1 - assert event_view(events[0].data) == { - "device_id": entry.device_id, - "type": "camera_sound", - "timestamp": event_time, - } - - -async def test_camera_person_event(hass): +@pytest.mark.parametrize( + "device_traits", + [ + ["sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraPerson"], + ], +) +async def test_camera_multiple_event(hass, subscriber, setup_platform): """Test a pubsub message for a camera person event.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.CameraPerson"]), - ) - registry = er.async_get(hass) - entry = registry.async_get("camera.front") - assert entry is not None - - timestamp = utcnow() - await subscriber.async_receive_event( - create_event("sdm.devices.events.CameraPerson.Person", timestamp=timestamp) - ) - await hass.async_block_till_done() - - event_time = timestamp.replace(microsecond=0) - assert len(events) == 1 - assert event_view(events[0].data) == { - "device_id": entry.device_id, - "type": "camera_person", - "timestamp": event_time, - } - - -async def test_camera_multiple_event(hass): - """Test a pubsub message for a camera person event.""" - events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits( - ["sdm.devices.traits.CameraMotion", "sdm.devices.traits.CameraPerson"] - ), - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None @@ -260,28 +225,20 @@ async def test_camera_multiple_event(hass): } -async def test_unknown_event(hass): +async def test_unknown_event(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() await subscriber.async_receive_event(create_event("some-event-id")) await hass.async_block_till_done() assert len(events) == 0 -async def test_unknown_device_id(hass): +async def test_unknown_device_id(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() await subscriber.async_receive_event( create_event("sdm.devices.events.DoorbellChime.Chime", "invalid-device-id") ) @@ -290,14 +247,10 @@ async def test_unknown_device_id(hass): assert len(events) == 0 -async def test_event_message_without_device_event(hass): +async def test_event_message_without_device_event(hass, subscriber, setup_platform): """Test a pubsub message for an unknown event type.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() timestamp = utcnow() event = EventMessage( { @@ -312,20 +265,16 @@ async def test_event_message_without_device_event(hass): assert len(events) == 0 -async def test_doorbell_event_thread(hass, auth): +@pytest.mark.parametrize( + "device_traits", + [ + ["sdm.devices.traits.CameraClipPreview", "sdm.devices.traits.CameraPerson"], + ], +) +async def test_doorbell_event_thread(hass, subscriber, setup_platform): """Test a series of pubsub messages in the same thread.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits( - [ - "sdm.devices.traits.CameraClipPreview", - "sdm.devices.traits.CameraPerson", - ] - ), - auth, - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None @@ -381,21 +330,20 @@ async def test_doorbell_event_thread(hass, auth): } -async def test_doorbell_event_session_update(hass, auth): +@pytest.mark.parametrize( + "device_traits", + [ + [ + "sdm.devices.traits.CameraClipPreview", + "sdm.devices.traits.CameraPerson", + "sdm.devices.traits.CameraMotion", + ], + ], +) +async def test_doorbell_event_session_update(hass, subscriber, setup_platform): """Test a pubsub message with updates to an existing session.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits( - [ - "sdm.devices.traits.CameraClipPreview", - "sdm.devices.traits.CameraPerson", - "sdm.devices.traits.CameraMotion", - ] - ), - auth, - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None @@ -454,14 +402,10 @@ async def test_doorbell_event_session_update(hass, auth): } -async def test_structure_update_event(hass): +async def test_structure_update_event(hass, subscriber, setup_platform): """Test a pubsub message for a new device being added.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.DoorbellChime"]), - ) + await setup_platform() # Entity for first device is registered registry = er.async_get(hass) @@ -516,14 +460,16 @@ async def test_structure_update_event(hass): assert not registry.async_get("camera.back") -async def test_event_zones(hass): +@pytest.mark.parametrize( + "device_traits", + [ + ["sdm.devices.traits.CameraMotion"], + ], +) +async def test_event_zones(hass, subscriber, setup_platform): """Test events published with zone information.""" events = async_capture_events(hass, NEST_EVENT) - subscriber = await async_setup_devices( - hass, - "sdm.devices.types.DOORBELL", - create_device_traits(["sdm.devices.traits.CameraMotion"]), - ) + await setup_platform() registry = er.async_get(hass) entry = registry.async_get("camera.front") assert entry is not None diff --git a/tests/components/nest/test_media_source.py b/tests/components/nest/test_media_source.py index 09a3f9f625c..dff740c84f4 100644 --- a/tests/components/nest/test_media_source.py +++ b/tests/components/nest/test_media_source.py @@ -8,11 +8,11 @@ from collections.abc import Generator import datetime from http import HTTPStatus import io +from typing import Any from unittest.mock import patch import aiohttp import av -from google_nest_sdm.device import Device from google_nest_sdm.event import EventMessage import numpy as np import pytest @@ -27,17 +27,11 @@ from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import ( - CONFIG, - FakeSubscriber, - async_setup_sdm_platform, - create_config_entry, -) +from .common import DEVICE_ID, CreateDevice, FakeSubscriber from tests.common import async_capture_events DOMAIN = "nest" -DEVICE_ID = "example/api/device/id" DEVICE_NAME = "Front" PLATFORM = "camera" NEST_EVENT = "nest_event" @@ -90,10 +84,42 @@ def frame_image_data(frame_i, total_frames): return img +@pytest.fixture +def platforms() -> list[str]: + """Fixture for platforms to setup.""" + return [PLATFORM] + + @pytest.fixture(autouse=True) -async def setup_media_source(hass) -> None: - """Set up media source.""" - assert await async_setup_component(hass, "media_source", {}) +async def setup_components(hass) -> None: + """Fixture to initialize the integration.""" + await async_setup_component(hass, "media_source", {}) + + +@pytest.fixture +def device_type() -> str: + """Fixture for the type of device under test.""" + return CAMERA_DEVICE_TYPE + + +@pytest.fixture +def device_traits() -> dict[str, Any]: + """Fixture for the present traits of the device under test.""" + return CAMERA_TRAITS + + +@pytest.fixture(autouse=True) +def device( + device_type: str, device_traits: dict[str, Any], create_device: CreateDevice +) -> None: + """Fixture to create a device under test.""" + return create_device.create( + raw_data={ + "name": DEVICE_ID, + "type": device_type, + "traits": device_traits, + } + ) @pytest.fixture @@ -128,22 +154,23 @@ def mp4() -> io.BytesIO: return output -async def async_setup_devices(hass, auth, device_type, traits={}, events=[]): - """Set up the platform and prerequisites.""" - devices = { - DEVICE_ID: Device.MakeDevice( - { - "name": DEVICE_ID, - "type": device_type, - "traits": traits, - }, - auth=auth, - ), - } - subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) - # Enable feature for fetching media +@pytest.fixture(autouse=True) +def enable_prefetch(subscriber: FakeSubscriber) -> None: + """Fixture to enable media fetching for tests to exercise.""" subscriber.cache_policy.fetch = True - return subscriber + + +@pytest.fixture +def cache_size() -> int: + """Fixture for overrideing cache size.""" + return 100 + + +@pytest.fixture(autouse=True) +def apply_cache_size(cache_size): + """Fixture for patching the cache size.""" + with patch("homeassistant.components.nest.EVENT_MEDIA_CACHE_SIZE", new=cache_size): + yield def create_event( @@ -194,17 +221,20 @@ def create_battery_event_data( } -async def test_no_eligible_devices(hass, auth): +@pytest.mark.parametrize( + "device_type,device_traits", + [ + ( + "sdm.devices.types.THERMOSTAT", + { + "sdm.devices.traits.Temperature": {}, + }, + ) + ], +) +async def test_no_eligible_devices(hass, setup_platform): """Test a media source with no eligible camera devices.""" - await async_setup_devices( - hass, - auth, - "sdm.devices.types.THERMOSTAT", - { - "sdm.devices.traits.Temperature": {}, - }, - ) - + await setup_platform() browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN assert browse.identifier == "" @@ -212,10 +242,10 @@ async def test_no_eligible_devices(hass, auth): assert not browse.children -@pytest.mark.parametrize("traits", [CAMERA_TRAITS, BATTERY_CAMERA_TRAITS]) -async def test_supported_device(hass, auth, traits): +@pytest.mark.parametrize("device_traits", [CAMERA_TRAITS, BATTERY_CAMERA_TRAITS]) +async def test_supported_device(hass, setup_platform): """Test a media source with a supported camera.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, traits) + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -245,14 +275,9 @@ async def test_supported_device(hass, auth, traits): assert len(browse.children) == 0 -async def test_integration_unloaded(hass, auth): +async def test_integration_unloaded(hass, auth, setup_platform): """Test the media player loads, but has no devices, when config unloaded.""" - await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - ) + await setup_platform() browse = await media_source.async_browse_media(hass, f"{const.URI_SCHEME}{DOMAIN}") assert browse.domain == DOMAIN @@ -276,11 +301,9 @@ async def test_integration_unloaded(hass, auth): assert len(browse.children) == 0 -async def test_camera_event(hass, auth, hass_client): +async def test_camera_event(hass, hass_client, subscriber, auth, setup_platform): """Test a media source and image created for an event.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -380,11 +403,9 @@ async def test_camera_event(hass, auth, hass_client): assert media.mime_type == "image/jpeg" -async def test_event_order(hass, auth): +async def test_event_order(hass, auth, subscriber, setup_platform): """Test multiple events are in descending timestamp order.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) + await setup_platform() auth.responses = [ aiohttp.web.json_response(GENERATE_IMAGE_URL_RESPONSE), @@ -449,14 +470,15 @@ async def test_event_order(hass, auth): assert not browse.children[1].can_play -async def test_multiple_image_events_in_session(hass, auth, hass_client): +async def test_multiple_image_events_in_session( + hass, auth, hass_client, subscriber, setup_platform +): """Test multiple events published within the same event session.""" + await setup_platform() + event_session_id = "FWWVQVUdGNUlTU2V4MGV2aTNXV..." event_timestamp1 = dt_util.now() event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -560,13 +582,19 @@ async def test_multiple_image_events_in_session(hass, auth, hass_client): assert contents == IMAGE_BYTES_FROM_EVENT + b"-1" -async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_multiple_clip_preview_events_in_session( + hass, + auth, + hass_client, + subscriber, + setup_platform, +): """Test multiple events published within the same event session.""" + await setup_platform() + event_timestamp1 = dt_util.now() event_timestamp2 = event_timestamp1 + datetime.timedelta(seconds=5) - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -656,9 +684,9 @@ async def test_multiple_clip_preview_events_in_session(hass, auth, hass_client): assert contents == IMAGE_BYTES_FROM_EVENT -async def test_browse_invalid_device_id(hass, auth): +async def test_browse_invalid_device_id(hass, auth, setup_platform): """Test a media source request for an invalid device id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -676,9 +704,9 @@ async def test_browse_invalid_device_id(hass, auth): ) -async def test_browse_invalid_event_id(hass, auth): +async def test_browse_invalid_event_id(hass, auth, setup_platform): """Test a media source browsing for an invalid event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -699,9 +727,9 @@ async def test_browse_invalid_event_id(hass, auth): ) -async def test_resolve_missing_event_id(hass, auth): +async def test_resolve_missing_event_id(hass, auth, setup_platform): """Test a media source request missing an event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -716,10 +744,9 @@ async def test_resolve_missing_event_id(hass, auth): ) -async def test_resolve_invalid_device_id(hass, auth): +async def test_resolve_invalid_device_id(hass, auth, setup_platform): """Test resolving media for an invalid event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() with pytest.raises(Unresolvable): await media_source.async_resolve_media( hass, @@ -728,9 +755,9 @@ async def test_resolve_invalid_device_id(hass, auth): ) -async def test_resolve_invalid_event_id(hass, auth): +async def test_resolve_invalid_event_id(hass, auth, setup_platform): """Test resolving media for an invalid event id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -750,14 +777,14 @@ async def test_resolve_invalid_event_id(hass, auth): assert media.mime_type == "image/jpeg" -async def test_camera_event_clip_preview(hass, auth, hass_client, mp4): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_camera_event_clip_preview( + hass, auth, hass_client, mp4, subscriber, setup_platform +): """Test an event for a battery camera video clip.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) - # Capture any events published received_events = async_capture_events(hass, NEST_EVENT) + await setup_platform() auth.responses = [ aiohttp.web.Response(body=mp4.getvalue()), @@ -857,10 +884,11 @@ async def test_camera_event_clip_preview(hass, auth, hass_client, mp4): await response.read() # Animated gif format not tested -async def test_event_media_render_invalid_device_id(hass, auth, hass_client): +async def test_event_media_render_invalid_device_id( + hass, auth, hass_client, setup_platform +): """Test event media API called with an invalid device id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() client = await hass_client() response = await client.get("/api/nest/event_media/invalid-device-id") assert response.status == HTTPStatus.NOT_FOUND, ( @@ -868,10 +896,11 @@ async def test_event_media_render_invalid_device_id(hass, auth, hass_client): ) -async def test_event_media_render_invalid_event_id(hass, auth, hass_client): +async def test_event_media_render_invalid_event_id( + hass, auth, hass_client, setup_platform +): """Test event media API called with an invalid device id.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) assert device @@ -884,13 +913,11 @@ async def test_event_media_render_invalid_event_id(hass, auth, hass_client): ) -async def test_event_media_failure(hass, auth, hass_client): +async def test_event_media_failure(hass, auth, hass_client, subscriber, setup_platform): """Test event media fetch sees a failure from the server.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS - ) received_events = async_capture_events(hass, NEST_EVENT) + await setup_platform() # Failure from server when fetching media auth.responses = [ aiohttp.web.Response(status=HTTPStatus.INTERNAL_SERVER_ERROR), @@ -937,10 +964,11 @@ async def test_event_media_failure(hass, auth, hass_client): ) -async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin_user): +async def test_media_permission_unauthorized( + hass, auth, hass_client, hass_admin_user, setup_platform +): """Test case where user does not have permissions to view media.""" - await async_setup_devices(hass, auth, CAMERA_DEVICE_TYPE, CAMERA_TRAITS) - + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") assert camera is not None @@ -962,33 +990,22 @@ async def test_media_permission_unauthorized(hass, auth, hass_client, hass_admin ) -async def test_multiple_devices(hass, auth, hass_client): +async def test_multiple_devices( + hass, auth, hass_client, create_device, subscriber, setup_platform +): """Test events received for multiple devices.""" - device_id1 = f"{DEVICE_ID}-1" device_id2 = f"{DEVICE_ID}-2" - - devices = { - device_id1: Device.MakeDevice( - { - "name": device_id1, - "type": CAMERA_DEVICE_TYPE, - "traits": CAMERA_TRAITS, - }, - auth=auth, - ), - device_id2: Device.MakeDevice( - { - "name": device_id2, - "type": CAMERA_DEVICE_TYPE, - "traits": CAMERA_TRAITS, - }, - auth=auth, - ), - } - subscriber = await async_setup_sdm_platform(hass, PLATFORM, devices=devices) + create_device.create( + raw_data={ + "name": device_id2, + "type": CAMERA_DEVICE_TYPE, + "traits": CAMERA_TRAITS, + } + ) + await setup_platform() device_registry = dr.async_get(hass) - device1 = device_registry.async_get_device({(DOMAIN, device_id1)}) + device1 = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) assert device1 device2 = device_registry.async_get_device({(DOMAIN, device_id2)}) assert device2 @@ -1018,7 +1035,7 @@ async def test_multiple_devices(hass, auth, hass_client): f"event-session-id-{i}", f"event-id-{i}", PERSON_EVENT, - device_id=device_id1, + device_id=DEVICE_ID, ) ) await hass.async_block_till_done() @@ -1073,34 +1090,18 @@ def event_store() -> Generator[None, None, None]: yield -async def test_media_store_persistence(hass, auth, hass_client, event_store): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_media_store_persistence( + hass, + auth, + hass_client, + event_store, + subscriber, + setup_platform, + config_entry, +): """Test the disk backed media store persistence.""" - nest_device = Device.MakeDevice( - { - "name": DEVICE_ID, - "type": CAMERA_DEVICE_TYPE, - "traits": BATTERY_CAMERA_TRAITS, - }, - auth=auth, - ) - - subscriber = FakeSubscriber() - device_manager = await subscriber.async_get_device_manager() - device_manager.add_device(nest_device) - # Fetch media for events when published - subscriber.cache_policy.fetch = True - - config_entry = create_config_entry() - config_entry.add_to_hass(hass) - - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -1154,18 +1155,8 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): # Now rebuild the entire integration and verify that all persisted storage # can be re-loaded from disk. - subscriber = FakeSubscriber() - device_manager = await subscriber.async_get_device_manager() - device_manager.add_device(nest_device) - - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( - "homeassistant.components.nest.api.GoogleNestSubscriber", - return_value=subscriber, - ): - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() + await hass.config_entries.async_reload(config_entry.entry_id) + await hass.async_block_till_done() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -1197,11 +1188,12 @@ async def test_media_store_persistence(hass, auth, hass_client, event_store): assert contents == IMAGE_BYTES_FROM_EVENT -async def test_media_store_save_filesystem_error(hass, auth, hass_client): +@pytest.mark.parametrize("device_traits", [BATTERY_CAMERA_TRAITS]) +async def test_media_store_save_filesystem_error( + hass, auth, hass_client, subscriber, setup_platform +): """Test a filesystem error writing event media.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) + await setup_platform() auth.responses = [ aiohttp.web.Response(body=IMAGE_BYTES_FROM_EVENT), @@ -1250,11 +1242,11 @@ async def test_media_store_save_filesystem_error(hass, auth, hass_client): ) -async def test_media_store_load_filesystem_error(hass, auth, hass_client): +async def test_media_store_load_filesystem_error( + hass, auth, hass_client, subscriber, setup_platform +): """Test a filesystem error reading event media.""" - subscriber = await async_setup_devices( - hass, auth, CAMERA_DEVICE_TYPE, BATTERY_CAMERA_TRAITS - ) + await setup_platform() assert len(hass.states.async_all()) == 1 camera = hass.states.get("camera.front") @@ -1299,17 +1291,12 @@ async def test_media_store_load_filesystem_error(hass, auth, hass_client): ) -async def test_camera_event_media_eviction(hass, auth, hass_client): +@pytest.mark.parametrize("device_traits,cache_size", [(BATTERY_CAMERA_TRAITS, 5)]) +async def test_camera_event_media_eviction( + hass, auth, hass_client, subscriber, setup_platform +): """Test media files getting evicted from the cache.""" - - # Set small cache size for testing eviction - with patch("homeassistant.components.nest.EVENT_MEDIA_CACHE_SIZE", new=5): - subscriber = await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - BATTERY_CAMERA_TRAITS, - ) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) @@ -1384,23 +1371,9 @@ async def test_camera_event_media_eviction(hass, auth, hass_client): await hass.async_block_till_done() -async def test_camera_image_resize(hass, auth, hass_client): +async def test_camera_image_resize(hass, auth, hass_client, subscriber, setup_platform): """Test scaling a thumbnail for an event image.""" - event_timestamp = dt_util.now() - subscriber = await async_setup_devices( - hass, - auth, - CAMERA_DEVICE_TYPE, - CAMERA_TRAITS, - events=[ - create_event( - EVENT_SESSION_ID, - EVENT_ID, - PERSON_EVENT, - timestamp=event_timestamp, - ), - ], - ) + await setup_platform() device_registry = dr.async_get(hass) device = device_registry.async_get_device({(DOMAIN, DEVICE_ID)}) From f4d339119f1a59659901d08cf1f774caeaee39fc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 21:17:37 -1000 Subject: [PATCH 341/947] Cache which entities are exposed in emulated_hue (#73093) --- .../components/emulated_hue/config.py | 89 +++++-- .../components/emulated_hue/hue_api.py | 5 +- tests/components/emulated_hue/test_hue_api.py | 241 ++++++++++-------- 3 files changed, 209 insertions(+), 126 deletions(-) diff --git a/homeassistant/components/emulated_hue/config.py b/homeassistant/components/emulated_hue/config.py index c2cf67b43f4..1de6ec98520 100644 --- a/homeassistant/components/emulated_hue/config.py +++ b/homeassistant/components/emulated_hue/config.py @@ -1,14 +1,40 @@ """Support for local control of entities by emulating a Philips Hue bridge.""" from __future__ import annotations -from collections.abc import Iterable +from functools import cache import logging +from homeassistant.components import ( + climate, + cover, + fan, + humidifier, + light, + media_player, + scene, + script, +) from homeassistant.const import CONF_ENTITIES, CONF_TYPE -from homeassistant.core import HomeAssistant, State +from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id from homeassistant.helpers import storage +from homeassistant.helpers.event import ( + async_track_state_added_domain, + async_track_state_removed_domain, +) from homeassistant.helpers.typing import ConfigType +SUPPORTED_DOMAINS = { + climate.DOMAIN, + cover.DOMAIN, + fan.DOMAIN, + humidifier.DOMAIN, + light.DOMAIN, + media_player.DOMAIN, + scene.DOMAIN, + script.DOMAIN, +} + + TYPE_ALEXA = "alexa" TYPE_GOOGLE = "google_home" @@ -78,7 +104,7 @@ class Config: # Get whether or not UPNP binds to multicast address (239.255.255.250) # or to the unicast address (host_ip_addr) - self.upnp_bind_multicast = conf.get( + self.upnp_bind_multicast: bool = conf.get( CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST ) @@ -93,7 +119,7 @@ class Config: # Get whether or not entities should be exposed by default, or if only # explicitly marked ones will be exposed - self.expose_by_default = conf.get( + self.expose_by_default: bool = conf.get( CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT ) @@ -118,18 +144,31 @@ class Config: # Get whether all non-dimmable lights should be reported as dimmable # for compatibility with older installations. - self.lights_all_dimmable = conf.get(CONF_LIGHTS_ALL_DIMMABLE) + self.lights_all_dimmable: bool = conf.get(CONF_LIGHTS_ALL_DIMMABLE) or False + + if self.expose_by_default: + self.track_domains = set(self.exposed_domains) or SUPPORTED_DOMAINS + else: + self.track_domains = { + split_entity_id(entity_id)[0] for entity_id in self.entities + } async def async_setup(self) -> None: - """Set up and migrate to storage.""" - self.store = storage.Store(self.hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type] + """Set up tracking and migrate to storage.""" + hass = self.hass + self.store = storage.Store(hass, DATA_VERSION, DATA_KEY) # type: ignore[arg-type] + numbers_path = hass.config.path(NUMBERS_FILE) self.numbers = ( - await storage.async_migrator( - self.hass, self.hass.config.path(NUMBERS_FILE), self.store - ) - or {} + await storage.async_migrator(hass, numbers_path, self.store) or {} + ) + async_track_state_added_domain( + hass, self.track_domains, self._clear_exposed_cache + ) + async_track_state_removed_domain( + hass, self.track_domains, self._clear_exposed_cache ) + @cache # pylint: disable=method-cache-max-size-none def entity_id_to_number(self, entity_id: str) -> str: """Get a unique number for the entity id.""" if self.type == TYPE_ALEXA: @@ -166,6 +205,27 @@ class Config: return state.attributes.get(ATTR_EMULATED_HUE_NAME, state.name) + @cache # pylint: disable=method-cache-max-size-none + def get_exposed_states(self) -> list[State]: + """Return a list of exposed states.""" + state_machine = self.hass.states + if self.expose_by_default: + return [ + state + for state in state_machine.async_all() + if self.is_state_exposed(state) + ] + states: list[State] = [] + for entity_id in self.entities: + if (state := state_machine.get(entity_id)) and self.is_state_exposed(state): + states.append(state) + return states + + @callback + def _clear_exposed_cache(self, event: Event) -> None: + """Clear the cache of exposed states.""" + self.get_exposed_states.cache_clear() # pylint: disable=no-member + def is_state_exposed(self, state: State) -> bool: """Cache determine if an entity should be exposed on the emulated bridge.""" if (exposed := self._exposed_cache.get(state.entity_id)) is not None: @@ -174,13 +234,6 @@ class Config: self._exposed_cache[state.entity_id] = exposed return exposed - def filter_exposed_states(self, states: Iterable[State]) -> list[State]: - """Filter a list of all states down to exposed entities.""" - exposed: list[State] = [ - state for state in states if self.is_state_exposed(state) - ] - return exposed - def _is_state_exposed(self, state: State) -> bool: """Determine if an entity state should be exposed on the emulated bridge. diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 2a9022f909d..c5ff9654f90 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -844,10 +844,9 @@ def create_config_model(config: Config, request: web.Request) -> dict[str, Any]: def create_list_of_entities(config: Config, request: web.Request) -> dict[str, Any]: """Create a list of all entities.""" - hass: core.HomeAssistant = request.app["hass"] json_response: dict[str, Any] = { - config.entity_id_to_number(entity.entity_id): state_to_json(config, entity) - for entity in config.filter_exposed_states(hass.states.async_all()) + config.entity_id_to_number(state.entity_id): state_to_json(config, state) + for state in config.get_exposed_states() } return json_response diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 87893f66e1f..e36903983fe 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -49,7 +49,8 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util from tests.common import ( @@ -96,41 +97,58 @@ ENTITY_IDS_BY_NUMBER = { ENTITY_NUMBERS_BY_ID = {v: k for k, v in ENTITY_IDS_BY_NUMBER.items()} -@pytest.fixture -def hass_hue(loop, hass): - """Set up a Home Assistant instance for these tests.""" - # We need to do this to get access to homeassistant/turn_(on,off) - loop.run_until_complete(setup.async_setup_component(hass, "homeassistant", {})) +def patch_upnp(): + """Patch async_create_upnp_datagram_endpoint.""" + return patch( + "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" + ) - loop.run_until_complete( + +async def async_get_lights(client): + """Get lights with the hue client.""" + result = await client.get("/api/username/lights") + assert result.status == HTTPStatus.OK + assert CONTENT_TYPE_JSON in result.headers["content-type"] + return await result.json() + + +async def _async_setup_emulated_hue(hass: HomeAssistant, conf: ConfigType) -> None: + """Set up emulated_hue with a specific config.""" + with patch_upnp(): + await setup.async_setup_component( + hass, + emulated_hue.DOMAIN, + {emulated_hue.DOMAIN: conf}, + ), + await hass.async_block_till_done() + + +@pytest.fixture +async def base_setup(hass): + """Set up homeassistant and http.""" + await asyncio.gather( + setup.async_setup_component(hass, "homeassistant", {}), setup.async_setup_component( hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}} - ) + ), ) - with patch( - "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" - ): - loop.run_until_complete( - setup.async_setup_component( - hass, - emulated_hue.DOMAIN, - { - emulated_hue.DOMAIN: { - emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, - emulated_hue.CONF_EXPOSE_BY_DEFAULT: True, - } - }, - ) - ) - loop.run_until_complete( +@pytest.fixture +async def demo_setup(hass): + """Fixture to setup demo platforms.""" + # We need to do this to get access to homeassistant/turn_(on,off) + setups = [ + setup.async_setup_component(hass, "homeassistant", {}), setup.async_setup_component( - hass, light.DOMAIN, {"light": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( + hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}} + ), + *[ + setup.async_setup_component( + hass, comp.DOMAIN, {comp.DOMAIN: [{"platform": "demo"}]} + ) + for comp in (light, climate, humidifier, media_player, fan, cover) + ], setup.async_setup_component( hass, script.DOMAIN, @@ -149,39 +167,7 @@ def hass_hue(loop, hass): } } }, - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, climate.DOMAIN, {"climate": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, humidifier.DOMAIN, {"humidifier": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, media_player.DOMAIN, {"media_player": [{"platform": "demo"}]} - ) - ) - - loop.run_until_complete( - setup.async_setup_component(hass, fan.DOMAIN, {"fan": [{"platform": "demo"}]}) - ) - - loop.run_until_complete( - setup.async_setup_component( - hass, cover.DOMAIN, {"cover": [{"platform": "demo"}]} - ) - ) - - # setup a dummy scene - loop.run_until_complete( + ), setup.async_setup_component( hass, "scene", @@ -199,21 +185,49 @@ def hass_hue(loop, hass): }, ] }, - ) - ) + ), + ] - # create a lamp without brightness support - hass.states.async_set("light.no_brightness", "on", {}) - - return hass + await asyncio.gather(*setups) @pytest.fixture -def hue_client(loop, hass_hue, hass_client_no_auth): +async def hass_hue(hass, base_setup, demo_setup): + """Set up a Home Assistant instance for these tests.""" + await _async_setup_emulated_hue( + hass, + { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: True, + }, + ) + # create a lamp without brightness support + hass.states.async_set("light.no_brightness", "on", {}) + return hass + + +@callback +def _mock_hue_endpoints( + hass: HomeAssistant, conf: ConfigType, entity_numbers: dict[str, str] +) -> None: + """Override the hue config with specific entity numbers.""" + web_app = hass.http.app + config = Config(hass, conf, "127.0.0.1") + config.numbers = entity_numbers + HueUsernameView().register(web_app, web_app.router) + HueAllLightsStateView(config).register(web_app, web_app.router) + HueOneLightStateView(config).register(web_app, web_app.router) + HueOneLightChangeView(config).register(web_app, web_app.router) + HueAllGroupsStateView(config).register(web_app, web_app.router) + HueFullStateView(config).register(web_app, web_app.router) + HueConfigView(config).register(web_app, web_app.router) + + +@pytest.fixture +async def hue_client(hass_hue, hass_client_no_auth): """Create web client for emulated hue api.""" - web_app = hass_hue.http.app - config = Config( - None, + _mock_hue_endpoints( + hass_hue, { emulated_hue.CONF_ENTITIES: { "light.bed_light": {emulated_hue.CONF_ENTITY_HIDDEN: True}, @@ -244,22 +258,12 @@ def hue_client(loop, hass_hue, hass_client_no_auth): "scene.light_off": {emulated_hue.CONF_ENTITY_HIDDEN: False}, }, }, - "127.0.0.1", + ENTITY_IDS_BY_NUMBER, ) - config.numbers = ENTITY_IDS_BY_NUMBER - - HueUsernameView().register(web_app, web_app.router) - HueAllLightsStateView(config).register(web_app, web_app.router) - HueOneLightStateView(config).register(web_app, web_app.router) - HueOneLightChangeView(config).register(web_app, web_app.router) - HueAllGroupsStateView(config).register(web_app, web_app.router) - HueFullStateView(config).register(web_app, web_app.router) - HueConfigView(config).register(web_app, web_app.router) - - return loop.run_until_complete(hass_client_no_auth()) + return await hass_client_no_auth() -async def test_discover_lights(hue_client): +async def test_discover_lights(hass, hue_client): """Test the discovery of lights.""" result = await hue_client.get("/api/username/lights") @@ -292,6 +296,21 @@ async def test_discover_lights(hue_client): assert "00:62:5c:3e:df:58:40:01-43" in devices # scene.light_on assert "00:1c:72:08:ed:09:e7:89-77" in devices # scene.light_off + # Remove the state and ensure it disappears from devices + hass.states.async_remove("light.ceiling_lights") + await hass.async_block_till_done() + + result_json = await async_get_lights(hue_client) + devices = {val["uniqueid"] for val in result_json.values()} + assert "00:2f:d2:31:ce:c5:55:cc-ee" not in devices # light.ceiling_lights + + # Restore the state and ensure it reappears in devices + hass.states.async_set("light.ceiling_lights", STATE_ON) + await hass.async_block_till_done() + result_json = await async_get_lights(hue_client) + devices = {val["uniqueid"] for val in result_json.values()} + assert "00:2f:d2:31:ce:c5:55:cc-ee" in devices # light.ceiling_lights + async def test_light_without_brightness_supported(hass_hue, hue_client): """Test that light without brightness is supported.""" @@ -316,19 +335,8 @@ async def test_lights_all_dimmable(hass, hass_client_no_auth): emulated_hue.CONF_EXPOSE_BY_DEFAULT: True, emulated_hue.CONF_LIGHTS_ALL_DIMMABLE: True, } - with patch( - "homeassistant.components.emulated_hue.async_create_upnp_datagram_endpoint" - ): - await setup.async_setup_component( - hass, - emulated_hue.DOMAIN, - {emulated_hue.DOMAIN: hue_config}, - ) - await hass.async_block_till_done() - config = Config(None, hue_config, "127.0.0.1") - config.numbers = ENTITY_IDS_BY_NUMBER - web_app = hass.http.app - HueOneLightStateView(config).register(web_app, web_app.router) + await _async_setup_emulated_hue(hass, hue_config) + _mock_hue_endpoints(hass, hue_config, ENTITY_IDS_BY_NUMBER) client = await hass_client_no_auth() light_without_brightness_json = await perform_get_light_state( client, "light.no_brightness", HTTPStatus.OK @@ -568,13 +576,7 @@ async def test_get_light_state(hass_hue, hue_client): assert office_json["state"][HUE_API_STATE_SAT] == 217 # Check all lights view - result = await hue_client.get("/api/username/lights") - - assert result.status == HTTPStatus.OK - assert CONTENT_TYPE_JSON in result.headers["content-type"] - - result_json = await result.json() - + result_json = await async_get_lights(hue_client) assert ENTITY_NUMBERS_BY_ID["light.ceiling_lights"] in result_json assert ( result_json[ENTITY_NUMBERS_BY_ID["light.ceiling_lights"]]["state"][ @@ -1616,3 +1618,32 @@ async def test_only_change_hue_or_saturation(hass, hass_hue, hue_client): assert hass_hue.states.get("light.ceiling_lights").attributes[ light.ATTR_HS_COLOR ] == (0, 3) + + +async def test_specificly_exposed_entities(hass, base_setup, hass_client_no_auth): + """Test specific entities with expose by default off.""" + conf = { + emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT, + emulated_hue.CONF_EXPOSE_BY_DEFAULT: False, + emulated_hue.CONF_ENTITIES: { + "light.exposed": {emulated_hue.CONF_ENTITY_HIDDEN: False}, + }, + } + await _async_setup_emulated_hue(hass, conf) + _mock_hue_endpoints(hass, conf, {"1": "light.exposed"}) + hass.states.async_set("light.exposed", STATE_ON) + await hass.async_block_till_done() + client = await hass_client_no_auth() + result_json = await async_get_lights(client) + assert "1" in result_json + + hass.states.async_remove("light.exposed") + await hass.async_block_till_done() + result_json = await async_get_lights(client) + assert "1" not in result_json + + hass.states.async_set("light.exposed", STATE_ON) + await hass.async_block_till_done() + result_json = await async_get_lights(client) + + assert "1" in result_json From 06ebc1fa147cd401d21eff77cf897d36723f61e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 9 Jun 2022 21:53:42 -1000 Subject: [PATCH 342/947] Add support for async_remove_config_entry_device to august (#72627) --- homeassistant/components/august/__init__.py | 26 +++++++++--- tests/components/august/mocks.py | 15 +++++-- tests/components/august/test_init.py | 46 +++++++++++++++++++++ 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 570ec5983fe..e8df7e1072d 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -21,6 +21,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) +from homeassistant.helpers import device_registry as dr from .activity import ActivityStream from .const import DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS @@ -283,12 +284,15 @@ class AugustData(AugustSubscriberMixin): device.device_id, ) - def _get_device_name(self, device_id): + def get_device(self, device_id: str) -> Doorbell | Lock | None: + """Get a device by id.""" + return self._locks_by_id.get(device_id) or self._doorbells_by_id.get(device_id) + + def _get_device_name(self, device_id: str) -> str | None: """Return doorbell or lock name as August has it stored.""" - if device_id in self._locks_by_id: - return self._locks_by_id[device_id].device_name - if device_id in self._doorbells_by_id: - return self._doorbells_by_id[device_id].device_name + if device := self.get_device(device_id): + return device.device_name + return None async def async_lock(self, device_id): """Lock the device.""" @@ -403,3 +407,15 @@ def _restore_live_attrs(lock_detail, attrs): """Restore the non-cache attributes after a cached update.""" for attr, value in attrs.items(): setattr(lock_detail, attr, value) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove august config entry from a device if its no longer present.""" + data: AugustData = hass.data[DOMAIN][config_entry.entry_id] + return not any( + identifier + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN and data.get_device(identifier[1]) + ) diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index e419488becc..c93e6429f1c 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -1,7 +1,10 @@ """Mocks for the august component.""" +from __future__ import annotations + import json import os import time +from typing import Any, Iterable from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch from yalexs.activity import ( @@ -26,7 +29,9 @@ from yalexs.lock import Lock, LockDetail from yalexs.pubnub_async import AugustPubNub from homeassistant.components.august.const import CONF_LOGIN_METHOD, DOMAIN +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry, load_fixture @@ -76,9 +81,13 @@ async def _mock_setup_august( async def _create_august_with_devices( - hass, devices, api_call_side_effects=None, activities=None, pubnub=None -): - entry, api_instance = await _create_august_api_with_devices( + hass: HomeAssistant, + devices: Iterable[LockDetail | DoorbellDetail], + api_call_side_effects: dict[str, Any] | None = None, + activities: list[Any] | None = None, + pubnub: AugustPubNub | None = None, +) -> ConfigEntry: + entry, _ = await _create_august_api_with_devices( hass, devices, api_call_side_effects, activities, pubnub ) return entry diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 320461ca6e9..56113832d23 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -17,6 +17,9 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry from tests.components.august.mocks import ( @@ -318,3 +321,46 @@ async def test_load_unload(hass): await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + august_operative_lock = await _mock_operative_august_lock_detail(hass) + config_entry = await _create_august_with_devices(hass, [august_operative_lock]) + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] + + device_registry = dr.async_get(hass) + device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), device_entry.id, config_entry.entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "remove-device-id")}, + ) + assert ( + await remove_device( + await hass_ws_client(hass), dead_device_entry.id, config_entry.entry_id + ) + is True + ) From 3d78240ceecf3263e69eeb58484a86b30ddc23b9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Jun 2022 11:11:40 +0200 Subject: [PATCH 343/947] Fix initial tilt value of MQTT cover (#73308) --- homeassistant/components/mqtt/cover.py | 2 -- tests/components/mqtt/test_cover.py | 8 ++------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 325433817c0..8d4df0c301d 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -25,7 +25,6 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, - STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv @@ -470,7 +469,6 @@ class MqttCover(MqttEntity, CoverEntity): } if self._config.get(CONF_TILT_STATUS_TOPIC) is not None: - self._tilt_value = STATE_UNKNOWN topics["tilt_status_topic"] = { "topic": self._config.get(CONF_TILT_STATUS_TOPIC), "msg_callback": tilt_message_received, diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 5796c12f3cf..31e30ebf11a 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1256,12 +1256,8 @@ async def test_tilt_defaults(hass, mqtt_mock_entry_with_yaml_config): await mqtt_mock_entry_with_yaml_config() state_attributes_dict = hass.states.get("cover.test").attributes - assert ATTR_CURRENT_TILT_POSITION in state_attributes_dict - - current_cover_position = hass.states.get("cover.test").attributes[ - ATTR_CURRENT_TILT_POSITION - ] - assert current_cover_position == STATE_UNKNOWN + # Tilt position is not yet known + assert ATTR_CURRENT_TILT_POSITION not in state_attributes_dict async def test_tilt_via_invocation_defaults(hass, mqtt_mock_entry_with_yaml_config): From b4a6ccfbc8f9107e160cf88d3e8227d4f0324060 Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 10 Jun 2022 20:18:46 +0800 Subject: [PATCH 344/947] Add yolink thermostat support (#73243) * Add yolink thermostat support * suggest and bugs fix * fix suggest and bugs --- .coveragerc | 1 + homeassistant/components/yolink/__init__.py | 1 + homeassistant/components/yolink/climate.py | 135 ++++++++++++++++++++ homeassistant/components/yolink/const.py | 1 + 4 files changed, 138 insertions(+) create mode 100644 homeassistant/components/yolink/climate.py diff --git a/.coveragerc b/.coveragerc index ea2c41f7e46..ef1a2adb712 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1496,6 +1496,7 @@ omit = homeassistant/components/yolink/__init__.py homeassistant/components/yolink/api.py homeassistant/components/yolink/binary_sensor.py + homeassistant/components/yolink/climate.py homeassistant/components/yolink/const.py homeassistant/components/yolink/coordinator.py homeassistant/components/yolink/entity.py diff --git a/homeassistant/components/yolink/__init__.py b/homeassistant/components/yolink/__init__.py index 21d36d33a30..92068d1e26e 100644 --- a/homeassistant/components/yolink/__init__.py +++ b/homeassistant/components/yolink/__init__.py @@ -26,6 +26,7 @@ SCAN_INTERVAL = timedelta(minutes=5) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.CLIMATE, Platform.LOCK, Platform.SENSOR, Platform.SIREN, diff --git a/homeassistant/components/yolink/climate.py b/homeassistant/components/yolink/climate.py new file mode 100644 index 00000000000..1f877571d94 --- /dev/null +++ b/homeassistant/components/yolink/climate.py @@ -0,0 +1,135 @@ +"""YoLink Thermostat.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.climate import ClimateEntity, ClimateEntityFeature +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_ON, + PRESET_ECO, + PRESET_NONE, + HVACAction, + HVACMode, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import ATTR_COORDINATORS, ATTR_DEVICE_THERMOSTAT, DOMAIN +from .coordinator import YoLinkCoordinator +from .entity import YoLinkEntity + +YOLINK_MODEL_2_HA = { + "cool": HVACMode.COOL, + "heat": HVACMode.HEAT, + "auto": HVACMode.AUTO, + "off": HVACMode.OFF, +} + +HA_MODEL_2_YOLINK = {v: k for k, v in YOLINK_MODEL_2_HA.items()} + +YOLINK_ACTION_2_HA = { + "cool": HVACAction.COOLING, + "heat": HVACAction.HEATING, + "idle": HVACAction.IDLE, +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up YoLink Thermostat from a config entry.""" + device_coordinators = hass.data[DOMAIN][config_entry.entry_id][ATTR_COORDINATORS] + entities = [ + YoLinkClimateEntity(config_entry, device_coordinator) + for device_coordinator in device_coordinators.values() + if device_coordinator.device.device_type == ATTR_DEVICE_THERMOSTAT + ] + async_add_entities(entities) + + +class YoLinkClimateEntity(YoLinkEntity, ClimateEntity): + """YoLink Climate Entity.""" + + def __init__( + self, + config_entry: ConfigEntry, + coordinator: YoLinkCoordinator, + ) -> None: + """Init YoLink Thermostat.""" + super().__init__(config_entry, coordinator) + self._attr_unique_id = f"{coordinator.device.device_id}_climate" + self._attr_name = f"{coordinator.device.device_name} (Thermostat)" + self._attr_temperature_unit = TEMP_CELSIUS + self._attr_fan_modes = [FAN_ON, FAN_AUTO] + self._attr_min_temp = -10 + self._attr_max_temp = 50 + self._attr_hvac_modes = [ + HVACMode.COOL, + HVACMode.HEAT, + HVACMode.AUTO, + HVACMode.OFF, + ] + self._attr_preset_modes = [PRESET_NONE, PRESET_ECO] + self._attr_supported_features = ( + ClimateEntityFeature.FAN_MODE + | ClimateEntityFeature.PRESET_MODE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + + @callback + def update_entity_state(self, state: dict[str, Any]) -> None: + """Update HA Entity State.""" + normal_state = state.get("state") + if normal_state is not None: + self._attr_current_temperature = normal_state.get("temperature") + self._attr_current_humidity = normal_state.get("humidity") + self._attr_target_temperature_low = normal_state.get("lowTemp") + self._attr_target_temperature_high = normal_state.get("highTemp") + self._attr_fan_mode = normal_state.get("fan") + self._attr_hvac_mode = YOLINK_MODEL_2_HA.get(normal_state.get("mode")) + self._attr_hvac_action = YOLINK_ACTION_2_HA.get(normal_state.get("running")) + eco_setting = state.get("eco") + if eco_setting is not None: + self._attr_preset_mode = ( + PRESET_NONE if eco_setting.get("mode") == "on" else PRESET_ECO + ) + self.async_write_ha_state() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if (hvac_mode_id := HA_MODEL_2_YOLINK.get(hvac_mode)) is None: + raise ValueError(f"Received an invalid hvac mode: {hvac_mode}") + await self.call_device_api("setState", {"mode": hvac_mode_id}) + await self.coordinator.async_refresh() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + await self.call_device_api("setState", {"fan": fan_mode}) + self._attr_fan_mode = fan_mode + self.async_write_ha_state() + + async def async_set_temperature(self, **kwargs) -> None: + """Set temperature.""" + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if target_temp_low is not None: + await self.call_device_api("setState", {"lowTemp": target_temp_low}) + self._attr_target_temperature_low = target_temp_low + if target_temp_high is not None: + await self.call_device_api("setState", {"highTemp": target_temp_high}) + self._attr_target_temperature_high = target_temp_high + await self.coordinator.async_refresh() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set preset mode.""" + eco_params = "on" if preset_mode == PRESET_ECO else "off" + await self.call_device_api("setECO", {"mode": eco_params}) + self._attr_preset_mode = PRESET_ECO if eco_params == "on" else PRESET_NONE + self.async_write_ha_state() diff --git a/homeassistant/components/yolink/const.py b/homeassistant/components/yolink/const.py index dba0a0ee221..f6add984dc2 100644 --- a/homeassistant/components/yolink/const.py +++ b/homeassistant/components/yolink/const.py @@ -24,3 +24,4 @@ ATTR_DEVICE_LOCK = "Lock" ATTR_DEVICE_MANIPULATOR = "Manipulator" ATTR_DEVICE_CO_SMOKE_SENSOR = "COSmokeSensor" ATTR_DEVICE_SWITCH = "Switch" +ATTR_DEVICE_THERMOSTAT = "Thermostat" From de2fade8c68cac9aa72dd868e79ee67b81934f5f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 10 Jun 2022 14:23:08 +0200 Subject: [PATCH 345/947] Improve MQTT reload performance (#73313) * Improve MQTT reload performance * Update homeassistant/components/mqtt/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/mqtt/mixins.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/mqtt/__init__.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/mqtt/__init__.py | 30 +++++++++++++++++++++-- homeassistant/components/mqtt/const.py | 2 ++ homeassistant/components/mqtt/mixins.py | 21 ++++++---------- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e21885d2585..f9a6ebd025f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -28,7 +28,14 @@ from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.reload import ( + async_integration_yaml_config, + async_setup_reload_service, +) from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow @@ -60,12 +67,14 @@ from .const import ( # noqa: F401 DATA_MQTT, DATA_MQTT_CONFIG, DATA_MQTT_RELOAD_NEEDED, + DATA_MQTT_UPDATED_CONFIG, DEFAULT_ENCODING, DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, + MQTT_RELOADED, PLATFORMS, ) from .models import ( # noqa: F401 @@ -227,7 +236,9 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await _async_setup_discovery(hass, mqtt_client.conf, entry) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Load a config entry.""" # Merge basic configuration, and add missing defaults for basic options _merge_basic_config(hass, entry, hass.data.get(DATA_MQTT_CONFIG, {})) @@ -364,6 +375,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() hass.data[CONFIG_ENTRY_IS_SETUP] = set() + # Setup reload service. Once support for legacy config is removed in 2022.9, we + # should no longer call async_setup_reload_service but instead implement a custom + # service + await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + + async def _async_reload_platforms(_: Event | None) -> None: + """Discover entities for a platform.""" + config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + hass.data[DATA_MQTT_UPDATED_CONFIG] = config_yaml.get(DOMAIN, {}) + async_dispatcher_send(hass, MQTT_RELOADED) + async def async_forward_entry_setup(): """Forward the config entry setup to the platforms.""" async with hass.data[DATA_CONFIG_ENTRY_LOCK]: @@ -374,6 +396,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setup( entry, component ) + # Setup reload service after all platforms have loaded + entry.async_on_unload( + hass.bus.async_listen("event_mqtt_reloaded", _async_reload_platforms) + ) hass.async_create_task(async_forward_entry_setup()) diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 2f7e27e7252..b05fd867eeb 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -35,6 +35,7 @@ DATA_CONFIG_ENTRY_LOCK = "mqtt_config_entry_lock" DATA_MQTT = "mqtt" DATA_MQTT_CONFIG = "mqtt_config" DATA_MQTT_RELOAD_NEEDED = "mqtt_reload_needed" +DATA_MQTT_UPDATED_CONFIG = "mqtt_updated_config" DEFAULT_PREFIX = "homeassistant" DEFAULT_BIRTH_WILL_TOPIC = DEFAULT_PREFIX + "/status" @@ -63,6 +64,7 @@ DOMAIN = "mqtt" MQTT_CONNECTED = "mqtt_connected" MQTT_DISCONNECTED = "mqtt_disconnected" +MQTT_RELOADED = "mqtt_reloaded" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index b0f17cc335b..e768c2ff409 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -48,10 +48,6 @@ from homeassistant.helpers.entity import ( async_generate_entity_id, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import ( - async_integration_yaml_config, - async_setup_reload_service, -) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import debug_info, subscription @@ -67,13 +63,14 @@ from .const import ( DATA_MQTT, DATA_MQTT_CONFIG, DATA_MQTT_RELOAD_NEEDED, + DATA_MQTT_UPDATED_CONFIG, DEFAULT_ENCODING, DEFAULT_PAYLOAD_AVAILABLE, DEFAULT_PAYLOAD_NOT_AVAILABLE, DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, - PLATFORMS, + MQTT_RELOADED, ) from .debug_info import log_message, log_messages from .discovery import ( @@ -270,14 +267,11 @@ async def async_setup_platform_discovery( ) -> CALLBACK_TYPE: """Set up platform discovery for manual config.""" - async def _async_discover_entities(event: Event | None) -> None: + async def _async_discover_entities() -> None: """Discover entities for a platform.""" - if event: + if DATA_MQTT_UPDATED_CONFIG in hass.data: # The platform has been reloaded - config_yaml = await async_integration_yaml_config(hass, DOMAIN) - if not config_yaml: - return - config_yaml = config_yaml.get(DOMAIN, {}) + config_yaml = hass.data[DATA_MQTT_UPDATED_CONFIG] else: config_yaml = hass.data.get(DATA_MQTT_CONFIG, {}) if not config_yaml: @@ -293,8 +287,8 @@ async def async_setup_platform_discovery( ) ) - unsub = hass.bus.async_listen("event_mqtt_reloaded", _async_discover_entities) - await _async_discover_entities(None) + unsub = async_dispatcher_connect(hass, MQTT_RELOADED, _async_discover_entities) + await _async_discover_entities() return unsub @@ -359,7 +353,6 @@ async def async_setup_platform_helper( async_setup_entities: SetupEntity, ) -> None: """Return true if platform setup should be aborted.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) if not bool(hass.config_entries.async_entries(DOMAIN)): hass.data[DATA_MQTT_RELOAD_NEEDED] = None _LOGGER.warning( From a82a1bfd64708a044af7a716b5e9e057b1656f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Fri, 10 Jun 2022 15:41:42 +0200 Subject: [PATCH 346/947] Allow more addon image paths (#73322) --- homeassistant/components/hassio/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 497e246ea77..3f492114545 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -45,7 +45,7 @@ NO_TIMEOUT = re.compile( NO_AUTH_ONBOARDING = re.compile(r"^(?:" r"|supervisor/logs" r"|backups/[^/]+/.+" r")$") NO_AUTH = re.compile( - r"^(?:" r"|app/.*" r"|addons/[^/]+/logo" r"|addons/[^/]+/icon" r")$" + r"^(?:" r"|app/.*" r"|[store\/]*addons/[^/]+/(logo|dark_logo|icon|dark_icon)" r")$" ) NO_STORE = re.compile(r"^(?:" r"|app/entrypoint.js" r")$") From 8ffd4cf0f900a867c3135ec9018c868282d0ce3d Mon Sep 17 00:00:00 2001 From: hesselonline Date: Fri, 10 Jun 2022 20:55:55 +0200 Subject: [PATCH 347/947] Fix wallbox sensor rounding (#73310) --- homeassistant/components/wallbox/manifest.json | 4 ---- homeassistant/components/wallbox/sensor.py | 8 ++++++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/wallbox/manifest.json b/homeassistant/components/wallbox/manifest.json index 914adda980a..5c195b8bfce 100644 --- a/homeassistant/components/wallbox/manifest.json +++ b/homeassistant/components/wallbox/manifest.json @@ -4,10 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wallbox", "requirements": ["wallbox==0.4.9"], - "ssdp": [], - "zeroconf": [], - "homekit": {}, - "dependencies": [], "codeowners": ["@hesselonline"], "iot_class": "cloud_polling", "loggers": ["wallbox"] diff --git a/homeassistant/components/wallbox/sensor.py b/homeassistant/components/wallbox/sensor.py index c8ad3cb3a67..e3598ca7e07 100644 --- a/homeassistant/components/wallbox/sensor.py +++ b/homeassistant/components/wallbox/sensor.py @@ -167,8 +167,12 @@ class WallboxSensor(WallboxEntity, SensorEntity): @property def native_value(self) -> StateType: - """Return the state of the sensor.""" - if (sensor_round := self.entity_description.precision) is not None: + """Return the state of the sensor. Round the value when it, and the precision property are not None.""" + if ( + sensor_round := self.entity_description.precision + ) is not None and self.coordinator.data[ + self.entity_description.key + ] is not None: return cast( StateType, round(self.coordinator.data[self.entity_description.key], sensor_round), From 2b07082cf6b8521d0c7e2ddc922b4dbd77a1aea4 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Fri, 10 Jun 2022 20:19:02 +0100 Subject: [PATCH 348/947] Bump aurorapy version to 0.2.7 (#73327) Co-authored-by: Dave T --- homeassistant/components/aurora_abb_powerone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/manifest.json b/homeassistant/components/aurora_abb_powerone/manifest.json index 056f2dc98c2..1207932ae1a 100644 --- a/homeassistant/components/aurora_abb_powerone/manifest.json +++ b/homeassistant/components/aurora_abb_powerone/manifest.json @@ -3,7 +3,7 @@ "name": "Aurora ABB PowerOne Solar PV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone", - "requirements": ["aurorapy==0.2.6"], + "requirements": ["aurorapy==0.2.7"], "codeowners": ["@davet2001"], "iot_class": "local_polling", "loggers": ["aurorapy"] diff --git a/requirements_all.txt b/requirements_all.txt index 5f27d8c524a..b8d114cfe8d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -354,7 +354,7 @@ atenpdu==0.3.2 auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone -aurorapy==0.2.6 +aurorapy==0.2.7 # homeassistant.components.generic # homeassistant.components.stream diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 900b8fda255..e247bf42d82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -290,7 +290,7 @@ asyncsleepiq==1.2.3 auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone -aurorapy==0.2.6 +aurorapy==0.2.7 # homeassistant.components.generic # homeassistant.components.stream From 53b3d2ee87c35f08008f035414538e9858a2f6b1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 10 Jun 2022 12:49:58 -0700 Subject: [PATCH 349/947] Guard MySQL size calculation returning None (#73331) --- .../recorder/system_health/mysql.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/recorder/system_health/mysql.py b/homeassistant/components/recorder/system_health/mysql.py index 52ea06f61c3..747a806c227 100644 --- a/homeassistant/components/recorder/system_health/mysql.py +++ b/homeassistant/components/recorder/system_health/mysql.py @@ -5,15 +5,18 @@ from sqlalchemy import text from sqlalchemy.orm.session import Session -def db_size_bytes(session: Session, database_name: str) -> float: +def db_size_bytes(session: Session, database_name: str) -> float | None: """Get the mysql database size.""" - return float( - session.execute( - text( - "SELECT ROUND(SUM(DATA_LENGTH + INDEX_LENGTH), 2) " - "FROM information_schema.TABLES WHERE " - "TABLE_SCHEMA=:database_name" - ), - {"database_name": database_name}, - ).first()[0] - ) + size = session.execute( + text( + "SELECT ROUND(SUM(DATA_LENGTH + INDEX_LENGTH), 2) " + "FROM information_schema.TABLES WHERE " + "TABLE_SCHEMA=:database_name" + ), + {"database_name": database_name}, + ).first()[0] + + if size is None: + return None + + return float(size) From e4f354998d8523ead8090f78fce2f9b7b89f949b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 10 Jun 2022 11:04:43 -1000 Subject: [PATCH 350/947] Filter out forced updates in live logbook when the state has not changed (#73335) --- homeassistant/components/logbook/helpers.py | 20 +-- .../components/logbook/test_websocket_api.py | 114 ++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index ef322c44e05..221612e1e97 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -189,9 +189,10 @@ def async_subscribe_events( def _forward_state_events_filtered(event: Event) -> None: if event.data.get("old_state") is None or event.data.get("new_state") is None: return - state: State = event.data["new_state"] - if _is_state_filtered(ent_reg, state) or ( - entities_filter and not entities_filter(state.entity_id) + new_state: State = event.data["new_state"] + old_state: State = event.data["old_state"] + if _is_state_filtered(ent_reg, new_state, old_state) or ( + entities_filter and not entities_filter(new_state.entity_id) ): return target(event) @@ -229,17 +230,20 @@ def is_sensor_continuous(ent_reg: er.EntityRegistry, entity_id: str) -> bool: ) -def _is_state_filtered(ent_reg: er.EntityRegistry, state: State) -> bool: +def _is_state_filtered( + ent_reg: er.EntityRegistry, new_state: State, old_state: State +) -> bool: """Check if the logbook should filter a state. Used when we are in live mode to ensure we only get significant changes (state.last_changed != state.last_updated) """ return bool( - split_entity_id(state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS - or state.last_changed != state.last_updated - or ATTR_UNIT_OF_MEASUREMENT in state.attributes - or is_sensor_continuous(ent_reg, state.entity_id) + new_state.state == old_state.state + or split_entity_id(new_state.entity_id)[0] in ALWAYS_CONTINUOUS_DOMAINS + or new_state.last_changed != new_state.last_updated + or ATTR_UNIT_OF_MEASUREMENT in new_state.attributes + or is_sensor_continuous(ent_reg, new_state.entity_id) ) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 4df2f456eb6..ac6a31202e7 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2404,3 +2404,117 @@ async def test_subscribe_entities_some_have_uom_multiple( # Check our listener got unsubscribed assert sum(hass.bus.async_listeners().values()) == init_count + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_logbook_stream_ignores_forced_updates( + hass, recorder_mock, hass_ws_client +): + """Test logbook live stream ignores forced updates.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + + await hass.async_block_till_done() + init_count = sum(hass.bus.async_listeners().values()) + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + state: State = hass.states.get("binary_sensor.is_light") + await hass.async_block_till_done() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + {"id": 7, "type": "logbook/event_stream", "start_time": now.isoformat()} + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": "off", + "when": state.last_updated.timestamp(), + } + ] + assert msg["event"]["start_time"] == now.timestamp() + assert msg["event"]["end_time"] > msg["event"]["start_time"] + assert msg["event"]["partial"] is True + + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [] + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + hass.states.async_set("binary_sensor.is_light", STATE_OFF) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": STATE_ON, + "when": ANY, + }, + { + "entity_id": "binary_sensor.is_light", + "state": STATE_OFF, + "when": ANY, + }, + ] + + # Now we force an update to make sure we ignore + # forced updates when the state has not actually changed + + hass.states.async_set("binary_sensor.is_light", STATE_ON) + for _ in range(3): + hass.states.async_set("binary_sensor.is_light", STATE_OFF, force_update=True) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert "partial" not in msg["event"]["events"] + assert msg["event"]["events"] == [ + { + "entity_id": "binary_sensor.is_light", + "state": STATE_ON, + "when": ANY, + }, + # We should only get the first one and ignore + # the other forced updates since the state + # has not actually changed + { + "entity_id": "binary_sensor.is_light", + "state": STATE_OFF, + "when": ANY, + }, + ] + + await websocket_client.send_json( + {"id": 8, "type": "unsubscribe_events", "subscription": 7} + ) + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + + assert msg["id"] == 8 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count From 21cfbe875e9af7a92311374958961195f9fb7748 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Fri, 10 Jun 2022 15:16:47 -0600 Subject: [PATCH 351/947] Remove logic to mark litterrobot vacuum entity as unavailable (#73234) --- homeassistant/components/litterrobot/vacuum.py | 7 ------- tests/components/litterrobot/conftest.py | 10 ---------- tests/components/litterrobot/test_vacuum.py | 15 +-------------- 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/homeassistant/components/litterrobot/vacuum.py b/homeassistant/components/litterrobot/vacuum.py index dbe51270857..51be573b14e 100644 --- a/homeassistant/components/litterrobot/vacuum.py +++ b/homeassistant/components/litterrobot/vacuum.py @@ -1,7 +1,6 @@ """Support for Litter-Robot "Vacuum".""" from __future__ import annotations -from datetime import datetime, timedelta, timezone import logging from typing import Any @@ -46,7 +45,6 @@ LITTER_BOX_STATUS_STATE_MAP = { LitterBoxStatus.CAT_SENSOR_INTERRUPTED: STATE_PAUSED, LitterBoxStatus.OFF: STATE_OFF, } -UNAVAILABLE_AFTER = timedelta(minutes=30) async def async_setup_entry( @@ -96,11 +94,6 @@ class LitterRobotCleaner(LitterRobotControlEntity, StateVacuumEntity): | VacuumEntityFeature.TURN_ON ) - @property - def available(self) -> bool: - """Return True if the cleaner has been seen recently.""" - return self.robot.last_seen > datetime.now(timezone.utc) - UNAVAILABLE_AFTER - @property def state(self) -> str: """Return the state of the cleaner.""" diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index e8ec5324ae6..0e3d85dc828 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -1,7 +1,6 @@ """Configure pytest for Litter-Robot tests.""" from __future__ import annotations -from datetime import datetime from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -10,7 +9,6 @@ from pylitterbot.exceptions import InvalidCommandException import pytest from homeassistant.components import litterrobot -from homeassistant.components.litterrobot.vacuum import UNAVAILABLE_AFTER from homeassistant.core import HomeAssistant from .common import CONFIG, ROBOT_DATA @@ -73,14 +71,6 @@ def mock_account_with_sleep_disabled_robot() -> MagicMock: return create_mock_account({"sleepModeActive": "0"}) -@pytest.fixture -def mock_account_with_robot_not_recently_seen() -> MagicMock: - """Mock a Litter-Robot account with a sleeping robot.""" - return create_mock_account( - {"lastSeen": (datetime.now() - UNAVAILABLE_AFTER).isoformat()} - ) - - @pytest.fixture def mock_account_with_error() -> MagicMock: """Mock a Litter-Robot account with error.""" diff --git a/tests/components/litterrobot/test_vacuum.py b/tests/components/litterrobot/test_vacuum.py index 3adf820d6aa..89f8f077b55 100644 --- a/tests/components/litterrobot/test_vacuum.py +++ b/tests/components/litterrobot/test_vacuum.py @@ -24,7 +24,7 @@ from homeassistant.components.vacuum import ( STATE_DOCKED, STATE_ERROR, ) -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow @@ -62,19 +62,6 @@ async def test_vacuum_status_when_sleeping( assert vacuum.attributes.get(ATTR_STATUS) == "Ready (Sleeping)" -async def test_vacuum_state_when_not_recently_seen( - hass: HomeAssistant, mock_account_with_robot_not_recently_seen: MagicMock -) -> None: - """Tests the vacuum state when not seen recently.""" - await setup_integration( - hass, mock_account_with_robot_not_recently_seen, PLATFORM_DOMAIN - ) - - vacuum = hass.states.get(VACUUM_ENTITY_ID) - assert vacuum - assert vacuum.state == STATE_UNAVAILABLE - - async def test_no_robots( hass: HomeAssistant, mock_account_with_no_robots: MagicMock ) -> None: From b1f2e5f897540967ebef2ccf98026d70009b5c4f Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Sat, 11 Jun 2022 15:38:43 +1000 Subject: [PATCH 352/947] Use create_stream in generic camera config flow (#73237) * Use create_stream in generic camera config flow --- .../components/generic/config_flow.py | 59 ++--- homeassistant/components/generic/strings.json | 10 +- tests/components/generic/conftest.py | 22 +- tests/components/generic/test_camera.py | 218 ++++++++---------- tests/components/generic/test_config_flow.py | 202 +++++++--------- 5 files changed, 220 insertions(+), 291 deletions(-) diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 272c7a2d98e..93b34133c63 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -3,7 +3,6 @@ from __future__ import annotations import contextlib from errno import EHOSTUNREACH, EIO -from functools import partial import io import logging from types import MappingProxyType @@ -11,7 +10,6 @@ from typing import Any import PIL from async_timeout import timeout -import av from httpx import HTTPStatusError, RequestError, TimeoutException import voluptuous as vol import yarl @@ -19,9 +17,10 @@ import yarl from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, + HLS_PROVIDER, RTSP_TRANSPORTS, SOURCE_TIMEOUT, - convert_stream_options, + create_stream, ) from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( @@ -198,6 +197,11 @@ async def async_test_stream(hass, info) -> dict[str, str]: """Verify that the stream is valid before we create an entity.""" if not (stream_source := info.get(CONF_STREAM_SOURCE)): return {} + # Import from stream.worker as stream cannot reexport from worker + # without forcing the av dependency on default_config + # pylint: disable=import-outside-toplevel + from homeassistant.components.stream.worker import StreamWorkerError + if not isinstance(stream_source, template_helper.Template): stream_source = template_helper.Template(stream_source, hass) try: @@ -205,42 +209,21 @@ async def async_test_stream(hass, info) -> dict[str, str]: except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", stream_source, err) return {CONF_STREAM_SOURCE: "template_error"} + stream_options: dict[str, bool | str] = {} + if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): + stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport + if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True try: - # For RTSP streams, prefer TCP. This code is duplicated from - # homeassistant.components.stream.__init__.py:create_stream() - # It may be possible & better to call create_stream() directly. - stream_options: dict[str, bool | str] = {} - if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): - stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport - if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): - stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True - pyav_options = convert_stream_options(stream_options) - if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": - pyav_options = { - "rtsp_flags": "prefer_tcp", - "stimeout": "5000000", - **pyav_options, - } - _LOGGER.debug("Attempting to open stream %s", stream_source) - container = await hass.async_add_executor_job( - partial( - av.open, - stream_source, - options=pyav_options, - timeout=SOURCE_TIMEOUT, - ) - ) - _ = container.streams.video[0] - except (av.error.FileNotFoundError): # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "stream_file_not_found"} - except (av.error.HTTPNotFoundError): # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "stream_http_not_found"} - except (av.error.TimeoutError): # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "timeout"} - except av.error.HTTPUnauthorizedError: # pylint: disable=c-extension-no-member - return {CONF_STREAM_SOURCE: "stream_unauthorised"} - except (KeyError, IndexError): - return {CONF_STREAM_SOURCE: "stream_no_video"} + stream = create_stream(hass, stream_source, stream_options, "test_stream") + hls_provider = stream.add_provider(HLS_PROVIDER) + await stream.start() + if not await hls_provider.part_recv(timeout=SOURCE_TIMEOUT): + hass.async_create_task(stream.stop()) + return {CONF_STREAM_SOURCE: "timeout"} + await stream.stop() + except StreamWorkerError as err: + return {CONF_STREAM_SOURCE: str(err)} except PermissionError: return {CONF_STREAM_SOURCE: "stream_not_permitted"} except OSError as err: diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 6b73c70cf3d..7cb1135fe56 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -12,9 +12,7 @@ "timeout": "Timeout while loading URL", "stream_no_route_to_host": "Could not find host while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", - "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_no_video": "Stream has no video" + "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?" }, "abort": { "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", @@ -78,15 +76,11 @@ "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "no_still_image_or_stream_url": "[%key:component::generic::config::error::no_still_image_or_stream_url%]", "invalid_still_image": "[%key:component::generic::config::error::invalid_still_image%]", - "stream_file_not_found": "[%key:component::generic::config::error::stream_file_not_found%]", - "stream_http_not_found": "[%key:component::generic::config::error::stream_http_not_found%]", "template_error": "[%key:component::generic::config::error::template_error%]", "timeout": "[%key:component::generic::config::error::timeout%]", "stream_no_route_to_host": "[%key:component::generic::config::error::stream_no_route_to_host%]", "stream_io_error": "[%key:component::generic::config::error::stream_io_error%]", - "stream_unauthorised": "[%key:component::generic::config::error::stream_unauthorised%]", - "stream_not_permitted": "[%key:component::generic::config::error::stream_not_permitted%]", - "stream_no_video": "[%key:component::generic::config::error::stream_no_video%]" + "stream_not_permitted": "[%key:component::generic::config::error::stream_not_permitted%]" } } } diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index dc5c545869b..266b848ebe2 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for the generic component.""" from io import BytesIO -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from PIL import Image import pytest @@ -59,14 +59,20 @@ def fakeimg_gif(fakeimgbytes_gif): @pytest.fixture(scope="package") -def mock_av_open(): - """Fake container object with .streams.video[0] != None.""" - fake = Mock() - fake.streams.video = ["fakevid"] - return patch( - "homeassistant.components.generic.config_flow.av.open", - return_value=fake, +def mock_create_stream(): + """Mock create stream.""" + mock_stream = Mock() + mock_provider = Mock() + mock_provider.part_recv = AsyncMock() + mock_provider.part_recv.return_value = True + mock_stream.add_provider.return_value = mock_provider + mock_stream.start = AsyncMock() + mock_stream.stop = AsyncMock() + fake_create_stream = patch( + "homeassistant.components.generic.config_flow.create_stream", + return_value=mock_stream, ) + return fake_create_stream @pytest.fixture diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index 6e8b804f848..ec0d89eb0eb 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -17,26 +17,25 @@ from tests.common import AsyncMock, Mock @respx.mock -async def test_fetching_url(hass, hass_client, fakeimgbytes_png, mock_av_open): +async def test_fetching_url(hass, hass_client, fakeimgbytes_png): """Test that it fetches the given url.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) - with mock_av_open: - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "username": "user", - "password": "pass", - "authentication": "basic", - } - }, - ) - await hass.async_block_till_done() + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "username": "user", + "password": "pass", + "authentication": "basic", + } + }, + ) + await hass.async_block_till_done() client = await hass_client() @@ -179,30 +178,27 @@ async def test_limit_refetch(hass, hass_client, fakeimgbytes_png, fakeimgbytes_j @respx.mock -async def test_stream_source( - hass, hass_client, hass_ws_client, fakeimgbytes_png, mock_av_open -): +async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png): """Test that the stream source is rendered.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png) hass.states.async_set("sensor.temp", "0") - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - }, + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, }, - ) - assert await async_setup_component(hass, "stream", {}) - await hass.async_block_till_done() + }, + ) + assert await async_setup_component(hass, "stream", {}) + await hass.async_block_till_done() hass.states.async_set("sensor.temp", "5") @@ -227,29 +223,26 @@ async def test_stream_source( @respx.mock -async def test_stream_source_error( - hass, hass_client, hass_ws_client, fakeimgbytes_png, mock_av_open -): +async def test_stream_source_error(hass, hass_client, hass_ws_client, fakeimgbytes_png): """Test that the stream source has an error.""" respx.get("http://example.com").respond(stream=fakeimgbytes_png) - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - # Does not exist - "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - }, + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "http://example.com", + # Does not exist + "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', + "limit_refetch_to_url_change": True, }, - ) - assert await async_setup_component(hass, "stream", {}) - await hass.async_block_till_done() + }, + ) + assert await async_setup_component(hass, "stream", {}) + await hass.async_block_till_done() with patch( "homeassistant.components.camera.Stream.endpoint_url", @@ -275,30 +268,27 @@ async def test_stream_source_error( @respx.mock -async def test_setup_alternative_options( - hass, hass_ws_client, fakeimgbytes_png, mock_av_open -): +async def test_setup_alternative_options(hass, hass_ws_client, fakeimgbytes_png): """Test that the stream source is setup with different config options.""" respx.get("https://example.com").respond(stream=fakeimgbytes_png) - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "https://example.com", - "authentication": "digest", - "username": "user", - "password": "pass", - "stream_source": "rtsp://example.com:554/rtsp/", - "rtsp_transport": "udp", - }, + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "still_image_url": "https://example.com", + "authentication": "digest", + "username": "user", + "password": "pass", + "stream_source": "rtsp://example.com:554/rtsp/", + "rtsp_transport": "udp", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() assert hass.states.get("camera.config_test") @@ -346,7 +336,7 @@ async def test_no_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_ @respx.mock async def test_camera_content_type( - hass, hass_client, fakeimgbytes_svg, fakeimgbytes_jpg, mock_av_open + hass, hass_client, fakeimgbytes_svg, fakeimgbytes_jpg ): """Test generic camera with custom content_type.""" urlsvg = "https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg" @@ -372,20 +362,18 @@ async def test_camera_content_type( "verify_ssl": True, } - with mock_av_open: - result1 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_jpg, - context={"source": SOURCE_IMPORT, "unique_id": 12345}, - ) - await hass.async_block_till_done() - with mock_av_open: - result2 = await hass.config_entries.flow.async_init( - "generic", - data=cam_config_svg, - context={"source": SOURCE_IMPORT, "unique_id": 54321}, - ) - await hass.async_block_till_done() + result1 = await hass.config_entries.flow.async_init( + "generic", + data=cam_config_jpg, + context={"source": SOURCE_IMPORT, "unique_id": 12345}, + ) + await hass.async_block_till_done() + result2 = await hass.config_entries.flow.async_init( + "generic", + data=cam_config_svg, + context={"source": SOURCE_IMPORT, "unique_id": 54321}, + ) + await hass.async_block_till_done() assert result1["type"] == "create_entry" assert result2["type"] == "create_entry" @@ -457,21 +445,20 @@ async def test_timeout_cancelled(hass, hass_client, fakeimgbytes_png, fakeimgbyt assert await resp.read() == fakeimgbytes_png -async def test_no_still_image_url(hass, hass_client, mock_av_open): +async def test_no_still_image_url(hass, hass_client): """Test that the component can grab images from stream with no still_image_url.""" - with mock_av_open: - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - }, + assert await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() client = await hass_client() @@ -503,23 +490,22 @@ async def test_no_still_image_url(hass, hass_client, mock_av_open): assert await resp.read() == b"stream_keyframe_image" -async def test_frame_interval_property(hass, mock_av_open): +async def test_frame_interval_property(hass): """Test that the frame interval is calculated and returned correctly.""" - with mock_av_open: - await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "stream_source": "rtsp://example.com:554/rtsp/", - "framerate": 5, - }, + await async_setup_component( + hass, + "camera", + { + "camera": { + "name": "config_test", + "platform": "generic", + "stream_source": "rtsp://example.com:554/rtsp/", + "framerate": 5, }, - ) - await hass.async_block_till_done() + }, + ) + await hass.async_block_till_done() request = Mock() with patch( diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index a525619d962..ee12056b191 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -2,9 +2,8 @@ import errno import os.path -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -import av import httpx import pytest import respx @@ -23,6 +22,7 @@ from homeassistant.components.stream import ( CONF_RTSP_TRANSPORT, CONF_USE_WALLCLOCK_AS_TIMESTAMPS, ) +from homeassistant.components.stream.worker import StreamWorkerError from homeassistant.const import ( CONF_AUTHENTICATION, CONF_NAME, @@ -57,10 +57,10 @@ TESTDATA_YAML = { @respx.mock -async def test_form(hass, fakeimg_png, mock_av_open, user_flow): +async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): """Test the form with a normal set of settings.""" - with mock_av_open as mock_setup: + with mock_create_stream as mock_setup: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -211,12 +211,12 @@ async def test_still_template( @respx.mock -async def test_form_rtsp_mode(hass, fakeimg_png, mock_av_open, user_flow): +async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): """Test we complete ok if the user enters a stream url.""" - with mock_av_open as mock_setup: - data = TESTDATA.copy() - data[CONF_RTSP_TRANSPORT] = "tcp" - data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" + data = TESTDATA.copy() + data[CONF_RTSP_TRANSPORT] = "tcp" + data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" + with mock_create_stream as mock_setup: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data ) @@ -240,7 +240,7 @@ async def test_form_rtsp_mode(hass, fakeimg_png, mock_av_open, user_flow): assert len(mock_setup.mock_calls) == 1 -async def test_form_only_stream(hass, mock_av_open, fakeimgbytes_jpg): +async def test_form_only_stream(hass, fakeimgbytes_jpg, mock_create_stream): """Test we complete ok if the user wants stream only.""" await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( @@ -249,7 +249,7 @@ async def test_form_only_stream(hass, mock_av_open, fakeimgbytes_jpg): data = TESTDATA.copy() data.pop(CONF_STILL_IMAGE_URL) data[CONF_STREAM_SOURCE] = "rtsp://user:pass@127.0.0.1/testurl/2" - with mock_av_open as mock_setup: + with mock_create_stream as mock_setup: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], data, @@ -294,13 +294,13 @@ async def test_form_still_and_stream_not_provided(hass, user_flow): @respx.mock -async def test_form_image_timeout(hass, mock_av_open, user_flow): +async def test_form_image_timeout(hass, user_flow, mock_create_stream): """Test we handle invalid image timeout.""" respx.get("http://127.0.0.1/testurl/1").side_effect = [ httpx.TimeoutException, ] - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -312,10 +312,10 @@ async def test_form_image_timeout(hass, mock_av_open, user_flow): @respx.mock -async def test_form_stream_invalidimage(hass, mock_av_open, user_flow): +async def test_form_stream_invalidimage(hass, user_flow, mock_create_stream): """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=b"invalid") - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -327,10 +327,10 @@ async def test_form_stream_invalidimage(hass, mock_av_open, user_flow): @respx.mock -async def test_form_stream_invalidimage2(hass, mock_av_open, user_flow): +async def test_form_stream_invalidimage2(hass, user_flow, mock_create_stream): """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=None) - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -342,10 +342,10 @@ async def test_form_stream_invalidimage2(hass, mock_av_open, user_flow): @respx.mock -async def test_form_stream_invalidimage3(hass, mock_av_open, user_flow): +async def test_form_stream_invalidimage3(hass, user_flow, mock_create_stream): """Test we handle invalid image when a stream is specified.""" respx.get("http://127.0.0.1/testurl/1").respond(content=bytes([0xFF])) - with mock_av_open: + with mock_create_stream: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -356,43 +356,17 @@ async def test_form_stream_invalidimage3(hass, mock_av_open, user_flow): assert result2["errors"] == {"still_image_url": "invalid_still_image"} -@respx.mock -async def test_form_stream_file_not_found(hass, fakeimg_png, user_flow): - """Test we handle file not found.""" - with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.FileNotFoundError(0, 0), - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_file_not_found"} - - -@respx.mock -async def test_form_stream_http_not_found(hass, fakeimg_png, user_flow): - """Test we handle invalid auth.""" - with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.HTTPNotFoundError(0, 0), - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_http_not_found"} - - @respx.mock async def test_form_stream_timeout(hass, fakeimg_png, user_flow): """Test we handle invalid auth.""" with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.TimeoutError(0, 0), - ): + "homeassistant.components.generic.config_flow.create_stream" + ) as create_stream: + create_stream.return_value.start = AsyncMock() + create_stream.return_value.add_provider.return_value.part_recv = AsyncMock() + create_stream.return_value.add_provider.return_value.part_recv.return_value = ( + False + ) result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -402,32 +376,18 @@ async def test_form_stream_timeout(hass, fakeimg_png, user_flow): @respx.mock -async def test_form_stream_unauthorised(hass, fakeimg_png, user_flow): - """Test we handle invalid auth.""" +async def test_form_stream_worker_error(hass, fakeimg_png, user_flow): + """Test we handle a StreamWorkerError and pass the message through.""" with patch( - "homeassistant.components.generic.config_flow.av.open", - side_effect=av.error.HTTPUnauthorizedError(0, 0), + "homeassistant.components.generic.config_flow.create_stream", + side_effect=StreamWorkerError("Some message"), ): result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, ) assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_unauthorised"} - - -@respx.mock -async def test_form_stream_novideo(hass, fakeimg_png, user_flow): - """Test we handle invalid stream.""" - with patch( - "homeassistant.components.generic.config_flow.av.open", side_effect=KeyError() - ): - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - TESTDATA, - ) - assert result2["type"] == "form" - assert result2["errors"] == {"stream_source": "stream_no_video"} + assert result2["errors"] == {"stream_source": "Some message"} @respx.mock @@ -435,7 +395,7 @@ async def test_form_stream_permission_error(hass, fakeimgbytes_png, user_flow): """Test we handle permission error.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=PermissionError(), ): result2 = await hass.config_entries.flow.async_configure( @@ -450,7 +410,7 @@ async def test_form_stream_permission_error(hass, fakeimgbytes_png, user_flow): async def test_form_no_route_to_host(hass, fakeimg_png, user_flow): """Test we handle no route to host.""" with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError(errno.EHOSTUNREACH, "No route to host"), ): result2 = await hass.config_entries.flow.async_configure( @@ -465,7 +425,7 @@ async def test_form_no_route_to_host(hass, fakeimg_png, user_flow): async def test_form_stream_io_error(hass, fakeimg_png, user_flow): """Test we handle no io error when setting up stream.""" with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError(errno.EIO, "Input/output error"), ): result2 = await hass.config_entries.flow.async_configure( @@ -480,7 +440,7 @@ async def test_form_stream_io_error(hass, fakeimg_png, user_flow): async def test_form_oserror(hass, fakeimg_png, user_flow): """Test we handle OS error when setting up stream.""" with patch( - "homeassistant.components.generic.config_flow.av.open", + "homeassistant.components.generic.config_flow.create_stream", side_effect=OSError("Some other OSError"), ), pytest.raises(OSError): await hass.config_entries.flow.async_configure( @@ -490,7 +450,7 @@ async def test_form_oserror(hass, fakeimg_png, user_flow): @respx.mock -async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open): +async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream): """Test the options flow with a template error.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) @@ -503,18 +463,18 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open): options=TESTDATA, ) - with mock_av_open: - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" - # try updating the still image url - data = TESTDATA.copy() - data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" + # try updating the still image url + data = TESTDATA.copy() + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/2" + with mock_create_stream: result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=data, @@ -541,12 +501,12 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_av_open): result4["flow_id"], user_input=data, ) - assert result5.get("type") == data_entry_flow.RESULT_TYPE_FORM - assert result5["errors"] == {"stream_source": "template_error"} + assert result5.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result5["errors"] == {"stream_source": "template_error"} @respx.mock -async def test_options_only_stream(hass, fakeimgbytes_png, mock_av_open): +async def test_options_only_stream(hass, fakeimgbytes_png, mock_create_stream): """Test the options flow without a still_image_url.""" respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) data = TESTDATA.copy() @@ -558,36 +518,35 @@ async def test_options_only_stream(hass, fakeimgbytes_png, mock_av_open): data={}, options=data, ) - with mock_av_open: - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(mock_entry.entry_id) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + result = await hass.config_entries.options.async_init(mock_entry.entry_id) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" - # try updating the config options + # try updating the config options + with mock_create_stream: result3 = await hass.config_entries.options.async_configure( result["flow_id"], user_input=data, ) - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" + assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result3["data"][CONF_CONTENT_TYPE] == "image/jpeg" # These below can be deleted after deprecation period is finished. @respx.mock -async def test_import(hass, fakeimg_png, mock_av_open): +async def test_import(hass, fakeimg_png): """Test configuration.yaml import used during migration.""" - with mock_av_open: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) - # duplicate import should be aborted - result2 = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML - ) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML + ) + # duplicate import should be aborted + result2 = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=TESTDATA_YAML + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Yaml Defined Name" await hass.async_block_till_done() @@ -599,7 +558,7 @@ async def test_import(hass, fakeimg_png, mock_av_open): # These above can be deleted after deprecation period is finished. -async def test_unload_entry(hass, fakeimg_png, mock_av_open): +async def test_unload_entry(hass, fakeimg_png): """Test unloading the generic IP Camera entry.""" mock_entry = MockConfigEntry(domain=DOMAIN, options=TESTDATA) mock_entry.add_to_hass(hass) @@ -669,7 +628,9 @@ async def test_migrate_existing_ids(hass) -> None: @respx.mock -async def test_use_wallclock_as_timestamps_option(hass, fakeimg_png, mock_av_open): +async def test_use_wallclock_as_timestamps_option( + hass, fakeimg_png, mock_create_stream +): """Test the use_wallclock_as_timestamps option flow.""" mock_entry = MockConfigEntry( @@ -679,19 +640,18 @@ async def test_use_wallclock_as_timestamps_option(hass, fakeimg_png, mock_av_ope options=TESTDATA, ) - with mock_av_open: - mock_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init( - mock_entry.entry_id, context={"show_advanced_options": True} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) + await hass.async_block_till_done() + result = await hass.config_entries.options.async_init( + mock_entry.entry_id, context={"show_advanced_options": True} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + with mock_create_stream: result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, ) - assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY From 63b51f566da6a695c234a84f93763a9cac7f90ba Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 11 Jun 2022 02:13:50 -0400 Subject: [PATCH 353/947] Fix zwave_js add node schemas (#73343) * Fix zwave_js add node schemas * Code cleanup * Add test --- homeassistant/components/zwave_js/api.py | 32 +++++++++-- tests/components/zwave_js/test_api.py | 70 ++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 6c186e2f840..6a4aaf0264e 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -14,6 +14,7 @@ from zwave_js_server.const import ( InclusionStrategy, LogLevel, Protocols, + ProvisioningEntryStatus, QRCodeVersion, SecurityClass, ZwaveFeature, @@ -148,6 +149,8 @@ MAX_INCLUSION_REQUEST_INTERVAL = "max_inclusion_request_interval" UUID = "uuid" SUPPORTED_PROTOCOLS = "supported_protocols" ADDITIONAL_PROPERTIES = "additional_properties" +STATUS = "status" +REQUESTED_SECURITY_CLASSES = "requested_security_classes" FEATURE = "feature" UNPROVISION = "unprovision" @@ -160,19 +163,22 @@ def convert_planned_provisioning_entry(info: dict) -> ProvisioningEntry: """Handle provisioning entry dict to ProvisioningEntry.""" return ProvisioningEntry( dsk=info[DSK], - security_classes=[SecurityClass(sec_cls) for sec_cls in info[SECURITY_CLASSES]], + security_classes=info[SECURITY_CLASSES], + status=info[STATUS], + requested_security_classes=info.get(REQUESTED_SECURITY_CLASSES), additional_properties={ - k: v for k, v in info.items() if k not in (DSK, SECURITY_CLASSES) + k: v + for k, v in info.items() + if k not in (DSK, SECURITY_CLASSES, STATUS, REQUESTED_SECURITY_CLASSES) }, ) def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation: """Convert QR provisioning information dict to QRProvisioningInformation.""" - protocols = [Protocols(proto) for proto in info.get(SUPPORTED_PROTOCOLS, [])] return QRProvisioningInformation( - version=QRCodeVersion(info[VERSION]), - security_classes=[SecurityClass(sec_cls) for sec_cls in info[SECURITY_CLASSES]], + version=info[VERSION], + security_classes=info[SECURITY_CLASSES], dsk=info[DSK], generic_device_class=info[GENERIC_DEVICE_CLASS], specific_device_class=info[SPECIFIC_DEVICE_CLASS], @@ -183,7 +189,9 @@ def convert_qr_provisioning_information(info: dict) -> QRProvisioningInformation application_version=info[APPLICATION_VERSION], max_inclusion_request_interval=info.get(MAX_INCLUSION_REQUEST_INTERVAL), uuid=info.get(UUID), - supported_protocols=protocols if protocols else None, + supported_protocols=info.get(SUPPORTED_PROTOCOLS), + status=info[STATUS], + requested_security_classes=info.get(REQUESTED_SECURITY_CLASSES), additional_properties=info.get(ADDITIONAL_PROPERTIES, {}), ) @@ -197,6 +205,12 @@ PLANNED_PROVISIONING_ENTRY_SCHEMA = vol.All( cv.ensure_list, [vol.Coerce(SecurityClass)], ), + vol.Optional(STATUS, default=ProvisioningEntryStatus.ACTIVE): vol.Coerce( + ProvisioningEntryStatus + ), + vol.Optional(REQUESTED_SECURITY_CLASSES): vol.All( + cv.ensure_list, [vol.Coerce(SecurityClass)] + ), }, # Provisioning entries can have extra keys for SmartStart extra=vol.ALLOW_EXTRA, @@ -226,6 +240,12 @@ QR_PROVISIONING_INFORMATION_SCHEMA = vol.All( cv.ensure_list, [vol.Coerce(Protocols)], ), + vol.Optional(STATUS, default=ProvisioningEntryStatus.ACTIVE): vol.Coerce( + ProvisioningEntryStatus + ), + vol.Optional(REQUESTED_SECURITY_CLASSES): vol.All( + cv.ensure_list, [vol.Coerce(SecurityClass)] + ), vol.Optional(ADDITIONAL_PROPERTIES): dict, } ), diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index da8cfad9624..6c3dd796a7d 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -10,6 +10,7 @@ from zwave_js_server.const import ( InclusionStrategy, LogLevel, Protocols, + ProvisioningEntryStatus, QRCodeVersion, SecurityClass, ZwaveFeature, @@ -63,8 +64,10 @@ from homeassistant.components.zwave_js.api import ( PROPERTY_KEY, QR_CODE_STRING, QR_PROVISIONING_INFORMATION, + REQUESTED_SECURITY_CLASSES, SECURITY_CLASSES, SPECIFIC_DEVICE_CLASS, + STATUS, TYPE, UNPROVISION, VALUE, @@ -619,13 +622,68 @@ async def test_add_node( client.async_send_command.reset_mock() client.async_send_command.return_value = {"success": True} - # Test S2 QR code string + # Test S2 QR provisioning information await ws_client.send_json( { ID: 4, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, + QR_PROVISIONING_INFORMATION: { + VERSION: 0, + SECURITY_CLASSES: [0], + DSK: "test", + GENERIC_DEVICE_CLASS: 1, + SPECIFIC_DEVICE_CLASS: 1, + INSTALLER_ICON_TYPE: 1, + MANUFACTURER_ID: 1, + PRODUCT_TYPE: 1, + PRODUCT_ID: 1, + APPLICATION_VERSION: "test", + STATUS: 1, + REQUESTED_SECURITY_CLASSES: [0], + }, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + assert len(client.async_send_command.call_args_list) == 1 + assert client.async_send_command.call_args[0][0] == { + "command": "controller.begin_inclusion", + "options": { + "strategy": InclusionStrategy.SECURITY_S2, + "provisioning": QRProvisioningInformation( + version=QRCodeVersion.S2, + security_classes=[SecurityClass.S2_UNAUTHENTICATED], + dsk="test", + generic_device_class=1, + specific_device_class=1, + installer_icon_type=1, + manufacturer_id=1, + product_type=1, + product_id=1, + application_version="test", + max_inclusion_request_interval=None, + uuid=None, + supported_protocols=None, + status=ProvisioningEntryStatus.INACTIVE, + requested_security_classes=[SecurityClass.S2_UNAUTHENTICATED], + ).to_dict(), + }, + } + + client.async_send_command.reset_mock() + client.async_send_command.return_value = {"success": True} + + # Test S2 QR code string + await ws_client.send_json( + { + ID: 5, + TYPE: "zwave_js/add_node", + ENTRY_ID: entry.entry_id, + INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", } ) @@ -648,7 +706,7 @@ async def test_add_node( # Test Smart Start QR provisioning information with S2 inclusion strategy fails await ws_client.send_json( { - ID: 5, + ID: 6, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S2.value, @@ -678,7 +736,7 @@ async def test_add_node( # Test QR provisioning information with S0 inclusion strategy fails await ws_client.send_json( { - ID: 5, + ID: 7, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.SECURITY_S0, @@ -708,7 +766,7 @@ async def test_add_node( # Test ValueError is caught as failure await ws_client.send_json( { - ID: 6, + ID: 8, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, INCLUSION_STRATEGY: InclusionStrategy.DEFAULT.value, @@ -728,7 +786,7 @@ async def test_add_node( ): await ws_client.send_json( { - ID: 7, + ID: 9, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id, } @@ -744,7 +802,7 @@ async def test_add_node( await hass.async_block_till_done() await ws_client.send_json( - {ID: 8, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} + {ID: 10, TYPE: "zwave_js/add_node", ENTRY_ID: entry.entry_id} ) msg = await ws_client.receive_json() From dc48791864e62654b6441eeb3bbb5b9dfbd73683 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 11 Jun 2022 02:16:46 -0400 Subject: [PATCH 354/947] Add config flow to eight_sleep (#71095) * Add config flow to eight_sleep * simplify tests * Remove extra file * remove unused import * fix redundant code * Update homeassistant/components/eight_sleep/__init__.py Co-authored-by: J. Nick Koston * incorporate feedback * Review comments * remove typing from tests * Fix based on changes * Fix requirements * Remove stale comment * Fix tests * Reverse the flow and force the config entry to reconnect * Review comments * Abort if import flow fails * Split import and user logic * Fix error Co-authored-by: J. Nick Koston --- .coveragerc | 4 +- CODEOWNERS | 1 + .../components/eight_sleep/__init__.py | 181 +++++++++++------- .../components/eight_sleep/binary_sensor.py | 38 ++-- .../components/eight_sleep/config_flow.py | 90 +++++++++ homeassistant/components/eight_sleep/const.py | 7 +- .../components/eight_sleep/manifest.json | 3 +- .../components/eight_sleep/sensor.py | 73 ++++--- .../components/eight_sleep/services.yaml | 12 +- .../components/eight_sleep/strings.json | 19 ++ .../eight_sleep/translations/en.json | 18 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/eight_sleep/__init__.py | 1 + tests/components/eight_sleep/conftest.py | 29 +++ .../eight_sleep/test_config_flow.py | 85 ++++++++ 16 files changed, 432 insertions(+), 133 deletions(-) create mode 100644 homeassistant/components/eight_sleep/config_flow.py create mode 100644 homeassistant/components/eight_sleep/strings.json create mode 100644 homeassistant/components/eight_sleep/translations/en.json create mode 100644 tests/components/eight_sleep/__init__.py create mode 100644 tests/components/eight_sleep/conftest.py create mode 100644 tests/components/eight_sleep/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index ef1a2adb712..8d2cdd32336 100644 --- a/.coveragerc +++ b/.coveragerc @@ -262,7 +262,9 @@ omit = homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py homeassistant/components/egardia/* - homeassistant/components/eight_sleep/* + homeassistant/components/eight_sleep/__init__.py + homeassistant/components/eight_sleep/binary_sensor.py + homeassistant/components/eight_sleep/sensor.py homeassistant/components/eliqonline/sensor.py homeassistant/components/elkm1/__init__.py homeassistant/components/elkm1/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index 9d0ba851339..9a57f3e791a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -273,6 +273,7 @@ build.json @home-assistant/supervisor /tests/components/efergy/ @tkdrob /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eight_sleep/ @mezz64 @raman325 +/tests/components/eight_sleep/ @mezz64 @raman325 /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 1bf22defd74..5cd7bec9244 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -1,34 +1,38 @@ """Support for Eight smart mattress covers and mattresses.""" from __future__ import annotations +from dataclasses import dataclass from datetime import timedelta import logging from pyeight.eight import EightSleep +from pyeight.exceptions import RequestError from pyeight.user import EightUser import voluptuous as vol -from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import discovery +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import ( + ATTR_HW_VERSION, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SW_VERSION, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.device_registry import async_get +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.typing import UNDEFINED, ConfigType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, ) -from .const import ( - ATTR_HEAT_DURATION, - ATTR_TARGET_HEAT, - DATA_API, - DATA_HEAT, - DATA_USER, - DOMAIN, - NAME_MAP, - SERVICE_HEAT_SET, -) +from .const import DOMAIN, NAME_MAP _LOGGER = logging.getLogger(__name__) @@ -37,17 +41,6 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] HEAT_SCAN_INTERVAL = timedelta(seconds=60) USER_SCAN_INTERVAL = timedelta(seconds=300) -VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) -VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) - -SERVICE_EIGHT_SCHEMA = vol.Schema( - { - ATTR_ENTITY_ID: cv.entity_ids, - ATTR_TARGET_HEAT: VALID_TARGET_HEAT, - ATTR_HEAT_DURATION: VALID_DURATION, - } -) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -61,6 +54,15 @@ CONFIG_SCHEMA = vol.Schema( ) +@dataclass +class EightSleepConfigEntryData: + """Data used for all entities for a given config entry.""" + + api: EightSleep + heat_coordinator: DataUpdateCoordinator + user_coordinator: DataUpdateCoordinator + + def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) -> str: """Get the device's unique ID.""" unique_id = eight.device_id @@ -71,23 +73,36 @@ def _get_device_unique_id(eight: EightSleep, user_obj: EightUser | None = None) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Eight Sleep component.""" + """Old set up method for the Eight Sleep component.""" + if DOMAIN in config: + _LOGGER.warning( + "Your Eight Sleep configuration has been imported into the UI; " + "please remove it from configuration.yaml as support for it " + "will be removed in a future release" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) - if DOMAIN not in config: - return True + return True - conf = config[DOMAIN] - user = conf[CONF_USERNAME] - password = conf[CONF_PASSWORD] +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up the Eight Sleep config entry.""" eight = EightSleep( - user, password, hass.config.time_zone, async_get_clientsession(hass) + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + hass.config.time_zone, + async_get_clientsession(hass), ) - hass.data.setdefault(DOMAIN, {}) - # Authenticate, build sensors - success = await eight.start() + try: + success = await eight.start() + except RequestError as err: + raise ConfigEntryNotReady from err if not success: # Authentication failed, cannot continue return False @@ -113,47 +128,60 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # No users, cannot continue return False - hass.data[DOMAIN] = { - DATA_API: eight, - DATA_HEAT: heat_coordinator, - DATA_USER: user_coordinator, + dev_reg = async_get(hass) + assert eight.device_data + device_data = { + ATTR_MANUFACTURER: "Eight Sleep", + ATTR_MODEL: eight.device_data.get("modelString", UNDEFINED), + ATTR_HW_VERSION: eight.device_data.get("sensorInfo", {}).get( + "hwRevision", UNDEFINED + ), + ATTR_SW_VERSION: eight.device_data.get("firmwareVersion", UNDEFINED), } - - for platform in PLATFORMS: - hass.async_create_task( - discovery.async_load_platform(hass, platform, DOMAIN, {}, config) + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _get_device_unique_id(eight))}, + name=f"{entry.data[CONF_USERNAME]}'s Eight Sleep", + **device_data, + ) + for user in eight.users.values(): + assert user.user_profile + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, _get_device_unique_id(eight, user))}, + name=f"{user.user_profile['firstName']}'s Eight Sleep Side", + via_device=(DOMAIN, _get_device_unique_id(eight)), + **device_data, ) - async def async_service_handler(service: ServiceCall) -> None: - """Handle eight sleep service calls.""" - params = service.data.copy() - - sensor = params.pop(ATTR_ENTITY_ID, None) - target = params.pop(ATTR_TARGET_HEAT, None) - duration = params.pop(ATTR_HEAT_DURATION, 0) - - for sens in sensor: - side = sens.split("_")[1] - user_id = eight.fetch_user_id(side) - assert user_id - usr_obj = eight.users[user_id] - await usr_obj.set_heating_level(target, duration) - - await heat_coordinator.async_request_refresh() - - # Register services - hass.services.async_register( - DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = EightSleepConfigEntryData( + eight, heat_coordinator, user_coordinator ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + # stop the API before unloading everything + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + await config_entry_data.api.stop() + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.data.pop(DOMAIN) + + return unload_ok + + class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): """The base Eight Sleep entity class.""" def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str | None, @@ -161,6 +189,7 @@ class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): ) -> None: """Initialize the data object.""" super().__init__(coordinator) + self._config_entry = entry self._eight = eight self._user_id = user_id self._sensor = sensor @@ -170,9 +199,25 @@ class EightSleepBaseEntity(CoordinatorEntity[DataUpdateCoordinator]): mapped_name = NAME_MAP.get(sensor, sensor.replace("_", " ").title()) if self._user_obj is not None: - mapped_name = f"{self._user_obj.side.title()} {mapped_name}" + assert self._user_obj.user_profile + name = f"{self._user_obj.user_profile['firstName']}'s {mapped_name}" + self._attr_name = name + else: + self._attr_name = f"Eight Sleep {mapped_name}" + unique_id = f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" + self._attr_unique_id = unique_id + identifiers = {(DOMAIN, _get_device_unique_id(eight, self._user_obj))} + self._attr_device_info = DeviceInfo(identifiers=identifiers) - self._attr_name = f"Eight {mapped_name}" - self._attr_unique_id = ( - f"{_get_device_unique_id(eight, self._user_obj)}.{sensor}" - ) + async def async_heat_set(self, target: int, duration: int) -> None: + """Handle eight sleep service calls.""" + if self._user_obj is None: + raise HomeAssistantError( + "This entity does not support the heat set service." + ) + + await self._user_obj.set_heating_level(target, duration) + config_entry_data: EightSleepConfigEntryData = self.hass.data[DOMAIN][ + self._config_entry.entry_id + ] + await config_entry_data.heat_coordinator.async_request_refresh() diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index 94ec423390f..7ad1b882008 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -9,37 +9,30 @@ from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import EightSleepBaseEntity -from .const import DATA_API, DATA_HEAT, DOMAIN +from . import EightSleepBaseEntity, EightSleepConfigEntryData +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +BINARY_SENSORS = ["bed_presence"] -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the eight sleep binary sensor.""" - if discovery_info is None: - return - - eight: EightSleep = hass.data[DOMAIN][DATA_API] - heat_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_HEAT] - - entities = [] - for user in eight.users.values(): - entities.append( - EightHeatSensor(heat_coordinator, eight, user.user_id, "bed_presence") - ) - - async_add_entities(entities) + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + eight = config_entry_data.api + heat_coordinator = config_entry_data.heat_coordinator + async_add_entities( + EightHeatSensor(entry, heat_coordinator, eight, user.user_id, binary_sensor) + for user in eight.users.values() + for binary_sensor in BINARY_SENSORS + ) class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): @@ -49,13 +42,14 @@ class EightHeatSensor(EightSleepBaseEntity, BinarySensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str | None, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj _LOGGER.debug( "Presence Sensor: %s, Side: %s, User: %s", diff --git a/homeassistant/components/eight_sleep/config_flow.py b/homeassistant/components/eight_sleep/config_flow.py new file mode 100644 index 00000000000..504fbeb2817 --- /dev/null +++ b/homeassistant/components/eight_sleep/config_flow.py @@ -0,0 +1,90 @@ +"""Config flow for Eight Sleep integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyeight.eight import EightSleep +from pyeight.exceptions import RequestError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): TextSelector( + TextSelectorConfig(type=TextSelectorType.EMAIL) + ), + vol.Required(CONF_PASSWORD): TextSelector( + TextSelectorConfig(type=TextSelectorType.PASSWORD) + ), + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Eight Sleep.""" + + VERSION = 1 + + async def _validate_data(self, config: dict[str, str]) -> str | None: + """Validate input data and return any error.""" + await self.async_set_unique_id(config[CONF_USERNAME].lower()) + self._abort_if_unique_id_configured() + + eight = EightSleep( + config[CONF_USERNAME], + config[CONF_PASSWORD], + self.hass.config.time_zone, + client_session=async_get_clientsession(self.hass), + ) + + try: + await eight.fetch_token() + except RequestError as err: + return str(err) + + return None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + if (err := await self._validate_data(user_input)) is not None: + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors={"base": "cannot_connect"}, + description_placeholders={"error": err}, + ) + + return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) + + async def async_step_import(self, import_config: dict) -> FlowResult: + """Handle import.""" + if (err := await self._validate_data(import_config)) is not None: + _LOGGER.error("Unable to import configuration.yaml configuration: %s", err) + return self.async_abort( + reason="cannot_connect", description_placeholders={"error": err} + ) + + return self.async_create_entry( + title=import_config[CONF_USERNAME], data=import_config + ) diff --git a/homeassistant/components/eight_sleep/const.py b/homeassistant/components/eight_sleep/const.py index 42a9eea590e..23689066665 100644 --- a/homeassistant/components/eight_sleep/const.py +++ b/homeassistant/components/eight_sleep/const.py @@ -1,7 +1,4 @@ """Eight Sleep constants.""" -DATA_HEAT = "heat" -DATA_USER = "user" -DATA_API = "api" DOMAIN = "eight_sleep" HEAT_ENTITY = "heat" @@ -15,5 +12,5 @@ NAME_MAP = { SERVICE_HEAT_SET = "heat_set" -ATTR_TARGET_HEAT = "target" -ATTR_HEAT_DURATION = "duration" +ATTR_TARGET = "target" +ATTR_DURATION = "duration" diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index e83b2977b77..c1833b222df 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -5,5 +5,6 @@ "requirements": ["pyeight==0.3.0"], "codeowners": ["@mezz64", "@raman325"], "iot_class": "cloud_polling", - "loggers": ["pyeight"] + "loggers": ["pyeight"], + "config_flow": true } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index b2afa496149..b184cd2496f 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -5,16 +5,17 @@ import logging from typing import Any from pyeight.eight import EightSleep +import voluptuous as vol from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, TEMP_CELSIUS from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers import entity_platform as ep from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from . import EightSleepBaseEntity -from .const import DATA_API, DATA_HEAT, DATA_USER, DOMAIN +from . import EightSleepBaseEntity, EightSleepConfigEntryData +from .const import ATTR_DURATION, ATTR_TARGET, DOMAIN, SERVICE_HEAT_SET ATTR_ROOM_TEMP = "Room Temperature" ATTR_AVG_ROOM_TEMP = "Average Room Temperature" @@ -53,37 +54,50 @@ EIGHT_USER_SENSORS = [ EIGHT_HEAT_SENSORS = ["bed_state"] EIGHT_ROOM_SENSORS = ["room_temperature"] +VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=-100, max=100)) +VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800)) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +SERVICE_EIGHT_SCHEMA = { + ATTR_TARGET: VALID_TARGET_HEAT, + ATTR_DURATION: VALID_DURATION, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: ep.AddEntitiesCallback ) -> None: """Set up the eight sleep sensors.""" - if discovery_info is None: - return - - eight: EightSleep = hass.data[DOMAIN][DATA_API] - heat_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_HEAT] - user_coordinator: DataUpdateCoordinator = hass.data[DOMAIN][DATA_USER] + config_entry_data: EightSleepConfigEntryData = hass.data[DOMAIN][entry.entry_id] + eight = config_entry_data.api + heat_coordinator = config_entry_data.heat_coordinator + user_coordinator = config_entry_data.user_coordinator all_sensors: list[SensorEntity] = [] for obj in eight.users.values(): - for sensor in EIGHT_USER_SENSORS: - all_sensors.append( - EightUserSensor(user_coordinator, eight, obj.user_id, sensor) - ) - for sensor in EIGHT_HEAT_SENSORS: - all_sensors.append( - EightHeatSensor(heat_coordinator, eight, obj.user_id, sensor) - ) - for sensor in EIGHT_ROOM_SENSORS: - all_sensors.append(EightRoomSensor(user_coordinator, eight, sensor)) + all_sensors.extend( + EightUserSensor(entry, user_coordinator, eight, obj.user_id, sensor) + for sensor in EIGHT_USER_SENSORS + ) + all_sensors.extend( + EightHeatSensor(entry, heat_coordinator, eight, obj.user_id, sensor) + for sensor in EIGHT_HEAT_SENSORS + ) + + all_sensors.extend( + EightRoomSensor(entry, user_coordinator, eight, sensor) + for sensor in EIGHT_ROOM_SENSORS + ) async_add_entities(all_sensors) + platform = ep.async_get_current_platform() + platform.async_register_entity_service( + SERVICE_HEAT_SET, + SERVICE_EIGHT_SCHEMA, + "async_heat_set", + ) + class EightHeatSensor(EightSleepBaseEntity, SensorEntity): """Representation of an eight sleep heat-based sensor.""" @@ -92,13 +106,14 @@ class EightHeatSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj _LOGGER.debug( @@ -147,13 +162,14 @@ class EightUserSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry: ConfigEntry, coordinator: DataUpdateCoordinator, eight: EightSleep, user_id: str, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, user_id, sensor) + super().__init__(entry, coordinator, eight, user_id, sensor) assert self._user_obj if self._sensor == "bed_temperature": @@ -260,12 +276,13 @@ class EightRoomSensor(EightSleepBaseEntity, SensorEntity): def __init__( self, + entry, coordinator: DataUpdateCoordinator, eight: EightSleep, sensor: str, ) -> None: """Initialize the sensor.""" - super().__init__(coordinator, eight, None, sensor) + super().__init__(entry, coordinator, eight, None, sensor) @property def native_value(self) -> int | float | None: diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml index de864afc160..39b960a6f7c 100644 --- a/homeassistant/components/eight_sleep/services.yaml +++ b/homeassistant/components/eight_sleep/services.yaml @@ -1,6 +1,10 @@ heat_set: name: Heat set description: Set heating/cooling level for eight sleep. + target: + entity: + integration: eight_sleep + domain: sensor fields: duration: name: Duration @@ -11,14 +15,6 @@ heat_set: min: 0 max: 28800 unit_of_measurement: seconds - entity_id: - name: Entity - description: Entity id of the bed state to adjust. - required: true - selector: - entity: - integration: eight_sleep - domain: sensor target: name: Target description: Target cooling/heating level from -100 to 100. diff --git a/homeassistant/components/eight_sleep/strings.json b/homeassistant/components/eight_sleep/strings.json new file mode 100644 index 00000000000..21accc53a06 --- /dev/null +++ b/homeassistant/components/eight_sleep/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" + } + } +} diff --git a/homeassistant/components/eight_sleep/translations/en.json b/homeassistant/components/eight_sleep/translations/en.json new file mode 100644 index 00000000000..dfd604a6c08 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect: {error}" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1cf8ba743bc..a50fc85a9f0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -87,6 +87,7 @@ FLOWS = { "ecobee", "econet", "efergy", + "eight_sleep", "elgato", "elkm1", "elmax", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e247bf42d82..08185c36295 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -979,6 +979,9 @@ pyeconet==0.1.15 # homeassistant.components.efergy pyefergy==22.1.1 +# homeassistant.components.eight_sleep +pyeight==0.3.0 + # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/eight_sleep/__init__.py b/tests/components/eight_sleep/__init__.py new file mode 100644 index 00000000000..22348f774be --- /dev/null +++ b/tests/components/eight_sleep/__init__.py @@ -0,0 +1 @@ +"""Tests for the Eight Sleep integration.""" diff --git a/tests/components/eight_sleep/conftest.py b/tests/components/eight_sleep/conftest.py new file mode 100644 index 00000000000..753fe1e30d5 --- /dev/null +++ b/tests/components/eight_sleep/conftest.py @@ -0,0 +1,29 @@ +"""Fixtures for Eight Sleep.""" +from unittest.mock import patch + +from pyeight.exceptions import RequestError +import pytest + + +@pytest.fixture(name="bypass", autouse=True) +def bypass_fixture(): + """Bypasses things that slow te tests down or block them from testing the behavior.""" + with patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", + ), patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.at_exit", + ), patch( + "homeassistant.components.eight_sleep.async_setup_entry", + return_value=True, + ): + yield + + +@pytest.fixture(name="token_error") +def token_error_fixture(): + """Simulate error when fetching token.""" + with patch( + "homeassistant.components.eight_sleep.config_flow.EightSleep.fetch_token", + side_effect=RequestError, + ): + yield diff --git a/tests/components/eight_sleep/test_config_flow.py b/tests/components/eight_sleep/test_config_flow.py new file mode 100644 index 00000000000..8015fb6c69d --- /dev/null +++ b/tests/components/eight_sleep/test_config_flow.py @@ -0,0 +1,85 @@ +"""Test the Eight Sleep config flow.""" +from homeassistant import config_entries +from homeassistant.components.eight_sleep.const import DOMAIN +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + + +async def test_form(hass) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "test-username" + assert result2["data"] == { + "username": "test-username", + "password": "test-password", + } + + +async def test_form_invalid_auth(hass, token_error) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "bad-username", + "password": "bad-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_import(hass) -> None: + """Test import works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "test-username", + "password": "test-password", + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "test-username" + assert result["data"] == { + "username": "test-username", + "password": "test-password", + } + + +async def test_import_invalid_auth(hass, token_error) -> None: + """Test we handle invalid auth on import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + "username": "bad-username", + "password": "bad-password", + }, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" From b4e9a9b1ed33e790ebec1a42285fb83b36a12311 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sat, 11 Jun 2022 09:38:13 -0400 Subject: [PATCH 355/947] Bump zwave-js-server-python to 0.37.2 (#73345) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 1c7eabb4e86..40b74096f0c 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.37.1"], + "requirements": ["zwave-js-server-python==0.37.2"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index b8d114cfe8d..994c94a1859 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2525,7 +2525,7 @@ zigpy==0.46.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.1 +zwave-js-server-python==0.37.2 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08185c36295..619db24f390 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1671,7 +1671,7 @@ zigpy-znp==0.7.0 zigpy==0.46.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.1 +zwave-js-server-python==0.37.2 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 From cb1011156d505d0d5d4371ec662060b011d2322b Mon Sep 17 00:00:00 2001 From: jjlawren Date: Sat, 11 Jun 2022 13:39:43 -0500 Subject: [PATCH 356/947] Rely on core config entry error logging in Plex setup (#73368) Rely on core config entry error logging --- homeassistant/components/plex/__init__.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index dbfe55077d7..c8745213f90 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -14,7 +14,7 @@ from plexwebsocket import ( import requests.exceptions from homeassistant.components.media_player import DOMAIN as MP_DOMAIN, BrowseError -from homeassistant.config_entries import ConfigEntry, ConfigEntryState +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_URL, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady @@ -139,12 +139,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry, data={**entry.data, PLEX_SERVER_CONFIG: new_server_data} ) except requests.exceptions.ConnectionError as error: - if entry.state is not ConfigEntryState.SETUP_RETRY: - _LOGGER.error( - "Plex server (%s) could not be reached: [%s]", - server_config[CONF_URL], - error, - ) raise ConfigEntryNotReady from error except plexapi.exceptions.Unauthorized as ex: raise ConfigEntryAuthFailed( From 8c968451354fc089c3eb76104f2c4230cae1433b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jun 2022 09:31:25 -1000 Subject: [PATCH 357/947] Add missing exception handlers to radiotherm (#73349) --- homeassistant/components/radiotherm/__init__.py | 4 ++++ homeassistant/components/radiotherm/config_flow.py | 3 ++- homeassistant/components/radiotherm/coordinator.py | 4 ++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py index 091d2bb8005..865e75257ec 100644 --- a/homeassistant/components/radiotherm/__init__.py +++ b/homeassistant/components/radiotherm/__init__.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Coroutine from socket import timeout from typing import Any, TypeVar +from urllib.error import URLError from radiotherm.validate import RadiothermTstatError @@ -31,6 +32,9 @@ async def _async_call_or_raise_not_ready( except RadiothermTstatError as ex: msg = f"{host} was busy (invalid value returned): {ex}" raise ConfigEntryNotReady(msg) from ex + except (OSError, URLError) as ex: + msg = f"{host} connection error: {ex}" + raise ConfigEntryNotReady(msg) from ex except timeout as ex: msg = f"{host} timed out waiting for a response: {ex}" raise ConfigEntryNotReady(msg) from ex diff --git a/homeassistant/components/radiotherm/config_flow.py b/homeassistant/components/radiotherm/config_flow.py index 030a3e6c022..97ae2c1be0a 100644 --- a/homeassistant/components/radiotherm/config_flow.py +++ b/homeassistant/components/radiotherm/config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging from socket import timeout from typing import Any +from urllib.error import URLError from radiotherm.validate import RadiothermTstatError import voluptuous as vol @@ -29,7 +30,7 @@ async def validate_connection(hass: HomeAssistant, host: str) -> RadioThermInitD """Validate the connection.""" try: return await async_get_init_data(hass, host) - except (timeout, RadiothermTstatError) as ex: + except (timeout, RadiothermTstatError, URLError, OSError) as ex: raise CannotConnect(f"Failed to connect to {host}: {ex}") from ex diff --git a/homeassistant/components/radiotherm/coordinator.py b/homeassistant/components/radiotherm/coordinator.py index 4afa2c0662b..91acdee8710 100644 --- a/homeassistant/components/radiotherm/coordinator.py +++ b/homeassistant/components/radiotherm/coordinator.py @@ -4,6 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging from socket import timeout +from urllib.error import URLError from radiotherm.validate import RadiothermTstatError @@ -38,6 +39,9 @@ class RadioThermUpdateCoordinator(DataUpdateCoordinator[RadioThermUpdate]): except RadiothermTstatError as ex: msg = f"{self._description} was busy (invalid value returned): {ex}" raise UpdateFailed(msg) from ex + except (OSError, URLError) as ex: + msg = f"{self._description} connection error: {ex}" + raise UpdateFailed(msg) from ex except timeout as ex: msg = f"{self._description}) timed out waiting for a response: {ex}" raise UpdateFailed(msg) from ex From 4feb5977eff8f88fdad4711f994f681f942818c2 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sat, 11 Jun 2022 15:34:32 -0400 Subject: [PATCH 358/947] Bump aioskybell to 22.6.1 (#73364) --- homeassistant/components/skybell/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index 23b29a49247..6884fe07df2 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -3,7 +3,7 @@ "name": "SkyBell", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/skybell", - "requirements": ["aioskybell==22.6.0"], + "requirements": ["aioskybell==22.6.1"], "codeowners": ["@tkdrob"], "iot_class": "cloud_polling", "loggers": ["aioskybell"] diff --git a/requirements_all.txt b/requirements_all.txt index 994c94a1859..7f4f5dc95f6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -241,7 +241,7 @@ aiosenz==1.0.0 aioshelly==2.0.0 # homeassistant.components.skybell -aioskybell==22.6.0 +aioskybell==22.6.1 # homeassistant.components.slimproto aioslimproto==2.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 619db24f390..9f7ac440042 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -210,7 +210,7 @@ aiosenz==1.0.0 aioshelly==2.0.0 # homeassistant.components.skybell -aioskybell==22.6.0 +aioskybell==22.6.1 # homeassistant.components.slimproto aioslimproto==2.0.1 From dd923b2eed0c465559f515269fbef9ab1c87e240 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 11 Jun 2022 21:35:15 +0200 Subject: [PATCH 359/947] Minor fix scrape (#73369) --- homeassistant/components/scrape/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 4fc08cba571..1c447439820 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -114,7 +114,7 @@ async def async_setup_entry( if value_template is not None: val_template = Template(value_template, hass) - rest = hass.data.setdefault(DOMAIN, {})[entry.entry_id] + rest = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ From d70ec354683a01dcc6f475539d940dba97f1f91e Mon Sep 17 00:00:00 2001 From: Khole Date: Sat, 11 Jun 2022 20:43:57 +0100 Subject: [PATCH 360/947] Hive Bump pyhiveapi to 0.5.10 for credentials fix (#73365) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 341273638bf..45c2b468f23 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -6,7 +6,7 @@ "models": ["HHKBridge*"] }, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.5.9"], + "requirements": ["pyhiveapi==0.5.10"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/requirements_all.txt b/requirements_all.txt index 7f4f5dc95f6..47ae411433a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1541,7 +1541,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.5.9 +pyhiveapi==0.5.10 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9f7ac440042..74342f3c1f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.9 +pyhiveapi==0.5.10 # homeassistant.components.homematic pyhomematic==0.1.77 From 297072c1f647277a2efb18cf528c75618ad63550 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sat, 11 Jun 2022 21:25:07 +0100 Subject: [PATCH 361/947] Fix initial data load for System Bridge (#73339) * Update package to 3.1.5 * Fix initial loading of data * Remove additional log and make method --- .../components/system_bridge/__init__.py | 16 +++++++++------- .../components/system_bridge/coordinator.py | 10 ++++++++++ .../components/system_bridge/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 22 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index 68a017628b8..1bee974a4c4 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -98,19 +98,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - _LOGGER.debug("Data: %s", coordinator.data) - try: # Wait for initial data async with async_timeout.timeout(30): - while coordinator.data is None or all( - getattr(coordinator.data, module) is None for module in MODULES - ): + while not coordinator.is_ready(): _LOGGER.debug( - "Waiting for initial data from %s (%s): %s", + "Waiting for initial data from %s (%s)", entry.title, entry.data[CONF_HOST], - coordinator.data, ) await asyncio.sleep(1) except asyncio.TimeoutError as exception: @@ -118,6 +113,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Timed out waiting for {entry.title} ({entry.data[CONF_HOST]})." ) from exception + _LOGGER.debug( + "Initial coordinator data for %s (%s):\n%s", + entry.title, + entry.data[CONF_HOST], + coordinator.data.json(), + ) + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator diff --git a/homeassistant/components/system_bridge/coordinator.py b/homeassistant/components/system_bridge/coordinator.py index a7343116cde..6088967aa33 100644 --- a/homeassistant/components/system_bridge/coordinator.py +++ b/homeassistant/components/system_bridge/coordinator.py @@ -75,6 +75,16 @@ class SystemBridgeDataUpdateCoordinator( hass, LOGGER, name=DOMAIN, update_interval=timedelta(seconds=30) ) + def is_ready(self) -> bool: + """Return if the data is ready.""" + if self.data is None: + return False + for module in MODULES: + if getattr(self.data, module) is None: + self.logger.debug("%s - Module %s is None", self.title, module) + return False + return True + async def async_get_data( self, modules: list[str], diff --git a/homeassistant/components/system_bridge/manifest.json b/homeassistant/components/system_bridge/manifest.json index 76449e3f3ac..087613413d8 100644 --- a/homeassistant/components/system_bridge/manifest.json +++ b/homeassistant/components/system_bridge/manifest.json @@ -3,7 +3,7 @@ "name": "System Bridge", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/system_bridge", - "requirements": ["systembridgeconnector==3.1.3"], + "requirements": ["systembridgeconnector==3.1.5"], "codeowners": ["@timmo001"], "zeroconf": ["_system-bridge._tcp.local."], "after_dependencies": ["zeroconf"], diff --git a/requirements_all.txt b/requirements_all.txt index 47ae411433a..875ea35ad00 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2268,7 +2268,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.1.3 +systembridgeconnector==3.1.5 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74342f3c1f5..0caab7ebe70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1504,7 +1504,7 @@ sunwatcher==0.2.1 surepy==0.7.2 # homeassistant.components.system_bridge -systembridgeconnector==3.1.3 +systembridgeconnector==3.1.5 # homeassistant.components.tailscale tailscale==0.2.0 From 51f88d3dad0361214bbb21353ef4c2d6c9bbf0ed Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 11 Jun 2022 12:05:19 -1000 Subject: [PATCH 362/947] Use get_ffmpeg_manager instead of accessing hass.data directly in ring (#73374) Use get_ffmpeg_manager intead of accessing hass.data directly in ring --- homeassistant/components/ring/camera.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 168df4d62e1..da2e447869a 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -10,7 +10,6 @@ import requests from homeassistant.components import ffmpeg from homeassistant.components.camera import Camera -from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant, callback @@ -33,6 +32,7 @@ async def async_setup_entry( ) -> None: """Set up a Ring Door Bell and StickUp Camera.""" devices = hass.data[DOMAIN][config_entry.entry_id]["devices"] + ffmpeg_manager = ffmpeg.get_ffmpeg_manager(hass) cams = [] for camera in chain( @@ -41,7 +41,7 @@ async def async_setup_entry( if not camera.has_subscription: continue - cams.append(RingCam(config_entry.entry_id, hass.data[DATA_FFMPEG], camera)) + cams.append(RingCam(config_entry.entry_id, ffmpeg_manager, camera)) async_add_entities(cams) From a1637e4fce89f8078a8cff197d71fbe9c5b592e4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 12 Jun 2022 00:25:33 +0000 Subject: [PATCH 363/947] [ci skip] Translation update --- .../binary_sensor/translations/sv.json | 3 +- .../eight_sleep/translations/de.json | 19 ++++++++++++ .../eight_sleep/translations/en.json | 5 +-- .../eight_sleep/translations/es.json | 19 ++++++++++++ .../eight_sleep/translations/fr.json | 19 ++++++++++++ .../eight_sleep/translations/hu.json | 19 ++++++++++++ .../eight_sleep/translations/pt-BR.json | 19 ++++++++++++ .../components/google/translations/es.json | 3 ++ .../radiotherm/translations/es.json | 31 +++++++++++++++++++ .../components/scrape/translations/es.json | 15 +++++++++ .../sensibo/translations/sensor.es.json | 8 +++++ .../components/sensor/translations/sv.json | 5 +-- .../components/skybell/translations/es.json | 21 +++++++++++++ 13 files changed, 181 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/eight_sleep/translations/de.json create mode 100644 homeassistant/components/eight_sleep/translations/es.json create mode 100644 homeassistant/components/eight_sleep/translations/fr.json create mode 100644 homeassistant/components/eight_sleep/translations/hu.json create mode 100644 homeassistant/components/eight_sleep/translations/pt-BR.json create mode 100644 homeassistant/components/radiotherm/translations/es.json create mode 100644 homeassistant/components/scrape/translations/es.json create mode 100644 homeassistant/components/sensibo/translations/sensor.es.json create mode 100644 homeassistant/components/skybell/translations/es.json diff --git a/homeassistant/components/binary_sensor/translations/sv.json b/homeassistant/components/binary_sensor/translations/sv.json index c6685403e24..eb23c7f12c6 100644 --- a/homeassistant/components/binary_sensor/translations/sv.json +++ b/homeassistant/components/binary_sensor/translations/sv.json @@ -90,7 +90,8 @@ } }, "device_class": { - "motion": "r\u00f6relse" + "motion": "r\u00f6relse", + "power": "effekt" }, "state": { "_": { diff --git a/homeassistant/components/eight_sleep/translations/de.json b/homeassistant/components/eight_sleep/translations/de.json new file mode 100644 index 00000000000..0d2dbadfd51 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/de.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "cannot_connect": "Es kann keine Verbindung zur Eight Sleep Cloud hergestellt werden: {error}" + }, + "error": { + "cannot_connect": "Es kann keine Verbindung zur Eight Sleep Cloud hergestellt werden: {error}" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "Benutzername" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/en.json b/homeassistant/components/eight_sleep/translations/en.json index dfd604a6c08..29926915fbb 100644 --- a/homeassistant/components/eight_sleep/translations/en.json +++ b/homeassistant/components/eight_sleep/translations/en.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" }, "error": { - "cannot_connect": "Failed to connect: {error}" + "cannot_connect": "Cannot connect to Eight Sleep cloud: {error}" }, "step": { "user": { diff --git a/homeassistant/components/eight_sleep/translations/es.json b/homeassistant/components/eight_sleep/translations/es.json new file mode 100644 index 00000000000..5936b1046b7 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/es.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado", + "cannot_connect": "No se puede conectar a la nube de Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "No se puede conectar a la nube de Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Nombre de usuario" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/fr.json b/homeassistant/components/eight_sleep/translations/fr.json new file mode 100644 index 00000000000..ae9902a5d7d --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "Impossible de se connecter au cloud Eight Sleep\u00a0: {error}" + }, + "error": { + "cannot_connect": "Impossible de se connecter au cloud Eight Sleep\u00a0: {error}" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/hu.json b/homeassistant/components/eight_sleep/translations/hu.json new file mode 100644 index 00000000000..61c0ce0f92d --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/hu.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "cannot_connect": "Nem lehet csatlakozni az Eight Sleep felh\u0151h\u00f6z: {error}" + }, + "error": { + "cannot_connect": "Nem lehet csatlakozni az Eight Sleep felh\u0151h\u00f6z: {error}" + }, + "step": { + "user": { + "data": { + "password": "Jelsz\u00f3", + "username": "Felhaszn\u00e1l\u00f3n\u00e9v" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/pt-BR.json b/homeassistant/components/eight_sleep/translations/pt-BR.json new file mode 100644 index 00000000000..d7ddac41bbe --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/pt-BR.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar \u00e0 nuvem Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar \u00e0 nuvem Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Senha", + "username": "Nome de usu\u00e1rio" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/es.json b/homeassistant/components/google/translations/es.json index aed1ec5d9ad..9e777e6b377 100644 --- a/homeassistant/components/google/translations/es.json +++ b/homeassistant/components/google/translations/es.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Sigue las [instrucciones]({more_info_url}) para la [pantalla de consentimiento de OAuth]({oauth_consent_url}) para dar acceso a Home Assistant a tu Google Calendar. Tambi\u00e9n necesita crear credenciales de aplicaci\u00f3n vinculadas a su calendario:\n1. Vaya a [Credenciales]({oauth_creds_url}) y haga clic en **Crear credenciales**.\n1. En la lista desplegable, seleccione **ID de cliente de OAuth**.\n1. Seleccione **TV y dispositivos de entrada limitada** para el tipo de aplicaci\u00f3n." + }, "config": { "abort": { "already_configured": "La cuenta ya est\u00e1 configurada", diff --git a/homeassistant/components/radiotherm/translations/es.json b/homeassistant/components/radiotherm/translations/es.json new file mode 100644 index 00000000000..dbb84376ff9 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/es.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "Error al conectar", + "unknown": "Error inesperado" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u00bfQuieres configurar {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "Host" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Establezca una retenci\u00f3n permanente al ajustar la temperatura." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/es.json b/homeassistant/components/scrape/translations/es.json new file mode 100644 index 00000000000..660d687344c --- /dev/null +++ b/homeassistant/components/scrape/translations/es.json @@ -0,0 +1,15 @@ +{ + "options": { + "step": { + "init": { + "data_description": { + "resource": "La URL del sitio web que contiene el valor.", + "select": "Define qu\u00e9 etiqueta buscar. Consulte los selectores de CSS de Beautifulsoup para obtener m\u00e1s informaci\u00f3n.", + "state_class": "El state_class del sensor", + "value_template": "Define una plantilla para obtener el estado del sensor", + "verify_ssl": "Habilita/deshabilita la verificaci\u00f3n del certificado SSL/TLS, por ejemplo, si est\u00e1 autofirmado" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.es.json b/homeassistant/components/sensibo/translations/sensor.es.json new file mode 100644 index 00000000000..1b251c70f7d --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.es.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Sensible" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/sv.json b/homeassistant/components/sensor/translations/sv.json index 720fac84f59..eeec1090a90 100644 --- a/homeassistant/components/sensor/translations/sv.json +++ b/homeassistant/components/sensor/translations/sv.json @@ -4,7 +4,7 @@ "is_battery_level": "Aktuell {entity_name} batteriniv\u00e5", "is_humidity": "Aktuell {entity_name} fuktighet", "is_illuminance": "Aktuell {entity_name} belysning", - "is_power": "Aktuell {entity_name} str\u00f6m", + "is_power": "Aktuell {entity_name} effekt", "is_pressure": "Aktuellt {entity_name} tryck", "is_signal_strength": "Aktuell {entity_name} signalstyrka", "is_temperature": "Aktuell {entity_name} temperatur", @@ -14,7 +14,8 @@ "battery_level": "{entity_name} batteriniv\u00e5 \u00e4ndras", "humidity": "{entity_name} fuktighet \u00e4ndras", "illuminance": "{entity_name} belysning \u00e4ndras", - "power": "{entity_name} str\u00f6mf\u00f6r\u00e4ndringar", + "power": "{entity_name} effektf\u00f6r\u00e4ndringar", + "power_factor": "effektfaktorf\u00f6r\u00e4ndringar", "pressure": "{entity_name} tryckf\u00f6r\u00e4ndringar", "signal_strength": "{entity_name} signalstyrka \u00e4ndras", "temperature": "{entity_name} temperaturf\u00f6r\u00e4ndringar", diff --git a/homeassistant/components/skybell/translations/es.json b/homeassistant/components/skybell/translations/es.json new file mode 100644 index 00000000000..cc93b536c38 --- /dev/null +++ b/homeassistant/components/skybell/translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada", + "reauth_successful": "La reautenticaci\u00f3n fue exitosa" + }, + "error": { + "cannot_connect": "Error al conectar", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "email": "Correo electronico", + "password": "Contrase\u00f1a" + } + } + } + } +} \ No newline at end of file From 0bcc5d7a292b8176f0f3dc1be0baa03ddf81919e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 13:46:20 -1000 Subject: [PATCH 364/947] Add async_remove_config_entry_device support to lookin (#73381) --- homeassistant/components/lookin/__init__.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index 555b8b551be..9b0a5b05f1f 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -23,6 +23,7 @@ from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, PLATFORMS, TYPE_TO_PLATFORM @@ -182,3 +183,19 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: manager: LookinUDPManager = hass.data[DOMAIN][UDP_MANAGER] await manager.async_stop() return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove lookin config entry from a device.""" + data: LookinData = hass.data[DOMAIN][entry.entry_id] + all_identifiers: set[tuple[str, str]] = { + (DOMAIN, data.lookin_device.id), + *((DOMAIN, remote["UUID"]) for remote in data.devices), + } + return not any( + identifier + for identifier in device_entry.identifiers + if identifier in all_identifiers + ) From 02d18a2e1faacc3d7c5fd10f79a44c21fb2c8e65 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Jun 2022 02:16:15 +0200 Subject: [PATCH 365/947] Update whois to 0.9.16 (#73408) --- homeassistant/components/whois/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index 8cbb0f6f502..00a2821c8c4 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -2,7 +2,7 @@ "domain": "whois", "name": "Whois", "documentation": "https://www.home-assistant.io/integrations/whois", - "requirements": ["whois==0.9.13"], + "requirements": ["whois==0.9.16"], "config_flow": true, "codeowners": ["@frenck"], "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 875ea35ad00..aa93ba61889 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2433,7 +2433,7 @@ webexteamssdk==1.1.1 whirlpool-sixth-sense==0.15.1 # homeassistant.components.whois -whois==0.9.13 +whois==0.9.16 # homeassistant.components.wiffi wiffi==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0caab7ebe70..f2bc79db670 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1606,7 +1606,7 @@ watchdog==2.1.8 whirlpool-sixth-sense==0.15.1 # homeassistant.components.whois -whois==0.9.13 +whois==0.9.16 # homeassistant.components.wiffi wiffi==1.1.0 From 42d39d2c7e527f35f0194ff8cf4aa2d8c7d9daf2 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 13 Jun 2022 00:25:34 +0000 Subject: [PATCH 366/947] [ci skip] Translation update --- .../aladdin_connect/translations/pt-BR.json | 6 +++--- .../asuswrt/translations/pt-BR.json | 2 +- .../components/baf/translations/pt-BR.json | 4 ++-- .../eight_sleep/translations/el.json | 19 +++++++++++++++++++ .../eight_sleep/translations/ja.json | 19 +++++++++++++++++++ .../eight_sleep/translations/nl.json | 19 +++++++++++++++++++ .../eight_sleep/translations/pl.json | 19 +++++++++++++++++++ .../eight_sleep/translations/pt-BR.json | 4 ++-- .../components/generic/translations/nl.json | 2 ++ .../geocaching/translations/pt-BR.json | 6 +++--- .../here_travel_time/translations/pt-BR.json | 4 ++-- .../components/iss/translations/pt-BR.json | 2 +- .../laundrify/translations/pt-BR.json | 4 ++-- .../luftdaten/translations/pt-BR.json | 2 +- .../motion_blinds/translations/pt-BR.json | 2 +- .../netatmo/translations/pt-BR.json | 2 +- .../onewire/translations/pt-BR.json | 2 +- .../radiotherm/translations/nl.json | 9 +++++++++ .../radiotherm/translations/pt-BR.json | 6 +++--- .../components/scrape/translations/nl.json | 18 ++++++++++++++++-- .../components/scrape/translations/pt-BR.json | 14 +++++++------- .../skybell/translations/pt-BR.json | 6 +++--- .../components/slack/translations/pt-BR.json | 6 +++--- .../tankerkoenig/translations/pt-BR.json | 2 +- .../totalconnect/translations/nl.json | 4 ++++ .../ukraine_alarm/translations/pt-BR.json | 6 +++--- .../components/ws66i/translations/pt-BR.json | 4 ++-- .../components/yolink/translations/pt-BR.json | 8 ++++---- 28 files changed, 153 insertions(+), 48 deletions(-) create mode 100644 homeassistant/components/eight_sleep/translations/el.json create mode 100644 homeassistant/components/eight_sleep/translations/ja.json create mode 100644 homeassistant/components/eight_sleep/translations/nl.json create mode 100644 homeassistant/components/eight_sleep/translations/pl.json diff --git a/homeassistant/components/aladdin_connect/translations/pt-BR.json b/homeassistant/components/aladdin_connect/translations/pt-BR.json index 2d709bf1125..c1c6d0097cf 100644 --- a/homeassistant/components/aladdin_connect/translations/pt-BR.json +++ b/homeassistant/components/aladdin_connect/translations/pt-BR.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida" }, "step": { @@ -19,7 +19,7 @@ "user": { "data": { "password": "Senha", - "username": "Nome de usu\u00e1rio" + "username": "Usu\u00e1rio" } } } diff --git a/homeassistant/components/asuswrt/translations/pt-BR.json b/homeassistant/components/asuswrt/translations/pt-BR.json index a42cab6fe0d..e72feee12d8 100644 --- a/homeassistant/components/asuswrt/translations/pt-BR.json +++ b/homeassistant/components/asuswrt/translations/pt-BR.json @@ -2,7 +2,7 @@ "config": { "abort": { "invalid_unique_id": "Imposs\u00edvel determinar um ID exclusivo v\u00e1lido para o dispositivo", - "no_unique_id": "[%key:component::asuswrt::config::abort::not_unique_id_exist%]" + "no_unique_id": "Um dispositivo sem um ID exclusivo v\u00e1lido j\u00e1 est\u00e1 configurado. A configura\u00e7\u00e3o de v\u00e1rias inst\u00e2ncias n\u00e3o \u00e9 poss\u00edvel" }, "error": { "cannot_connect": "Falha ao conectar", diff --git a/homeassistant/components/baf/translations/pt-BR.json b/homeassistant/components/baf/translations/pt-BR.json index 72ce0dd06fc..5c55dcd80d8 100644 --- a/homeassistant/components/baf/translations/pt-BR.json +++ b/homeassistant/components/baf/translations/pt-BR.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "ipv6_not_supported": "IPv6 n\u00e3o \u00e9 suportado." }, "error": { - "cannot_connect": "Falhou ao se conectar", + "cannot_connect": "Falha ao conectar", "unknown": "Erro inesperado" }, "flow_title": "{name} - {model} ({ip_address})", diff --git a/homeassistant/components/eight_sleep/translations/el.json b/homeassistant/components/eight_sleep/translations/el.json new file mode 100644 index 00000000000..2bdf1e689ff --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/el.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "cannot_connect": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03c3\u03c4\u03bf \u03c3\u03cd\u03bd\u03bd\u03b5\u03c6\u03bf Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "\u0394\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae \u03b7 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03c3\u03c4\u03bf \u03c3\u03cd\u03bd\u03bd\u03b5\u03c6\u03bf Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", + "username": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c7\u03c1\u03ae\u03c3\u03c4\u03b7" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/ja.json b/homeassistant/components/eight_sleep/translations/ja.json new file mode 100644 index 00000000000..c91a9c9dd2c --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/ja.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "cannot_connect": "Eight Sleep\u30af\u30e9\u30a6\u30c9\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093: {error}" + }, + "error": { + "cannot_connect": "Eight Sleep\u30af\u30e9\u30a6\u30c9\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093: {error}" + }, + "step": { + "user": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", + "username": "\u30e6\u30fc\u30b6\u30fc\u540d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/nl.json b/homeassistant/components/eight_sleep/translations/nl.json new file mode 100644 index 00000000000..afd044ded29 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/nl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Apparaat is al geconfigureerd", + "cannot_connect": "Kan niet verbinden met Eight Sleep cloud: {error}" + }, + "error": { + "cannot_connect": "Kan niet verbinden met Eight Sleep cloud: {error}" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/pl.json b/homeassistant/components/eight_sleep/translations/pl.json new file mode 100644 index 00000000000..a0f0443cabb --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/pl.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z chmur\u0105 Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z chmur\u0105 Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/pt-BR.json b/homeassistant/components/eight_sleep/translations/pt-BR.json index d7ddac41bbe..acb63e80352 100644 --- a/homeassistant/components/eight_sleep/translations/pt-BR.json +++ b/homeassistant/components/eight_sleep/translations/pt-BR.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar \u00e0 nuvem Eight Sleep: {error}" }, "error": { @@ -11,7 +11,7 @@ "user": { "data": { "password": "Senha", - "username": "Nome de usu\u00e1rio" + "username": "Usu\u00e1rio" } } } diff --git a/homeassistant/components/generic/translations/nl.json b/homeassistant/components/generic/translations/nl.json index 354f944148f..b7727190810 100644 --- a/homeassistant/components/generic/translations/nl.json +++ b/homeassistant/components/generic/translations/nl.json @@ -15,6 +15,7 @@ "stream_no_video": "Stream heeft geen video", "stream_not_permitted": "Operatie niet toegestaan bij poging om verbinding te maken met stream. Verkeerd RTSP transport protocol?", "stream_unauthorised": "Autorisatie mislukt bij poging om verbinding te maken met stream", + "template_error": "Fout bij het weergeven van sjabloon. Bekijk het logboek voor meer informatie.", "timeout": "Time-out tijdens het laden van URL", "unable_still_load": "Kan geen geldige afbeelding laden van stilstaande afbeelding URL (b.v. ongeldige host, URL of authenticatie fout). Bekijk het log voor meer informatie.", "unknown": "Onverwachte fout" @@ -57,6 +58,7 @@ "stream_no_video": "Stream heeft geen video", "stream_not_permitted": "Operatie niet toegestaan bij poging om verbinding te maken met stream. Verkeerd RTSP transport protocol?", "stream_unauthorised": "Autorisatie mislukt bij poging om verbinding te maken met stream", + "template_error": "Fout bij het weergeven van sjabloon. Bekijk het logboek voor meer informatie.", "timeout": "Time-out tijdens het laden van URL", "unable_still_load": "Kan geen geldige afbeelding laden van stilstaande afbeelding URL (b.v. ongeldige host, URL of authenticatie fout). Bekijk het log voor meer informatie.", "unknown": "Onverwachte fout" diff --git a/homeassistant/components/geocaching/translations/pt-BR.json b/homeassistant/components/geocaching/translations/pt-BR.json index 4a6c20919d0..3767468530c 100644 --- a/homeassistant/components/geocaching/translations/pt-BR.json +++ b/homeassistant/components/geocaching/translations/pt-BR.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "A conta j\u00e1 est\u00e1 configurada", + "already_configured": "A conta j\u00e1 foi configurada", "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "Nenhuma URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre este erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", - "oauth_error": "Dados de token inv\u00e1lidos recebidos.", + "no_url_available": "N\u00e3o h\u00e1 URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", + "oauth_error": "Dados de token recebidos inv\u00e1lidos.", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "create_entry": { diff --git a/homeassistant/components/here_travel_time/translations/pt-BR.json b/homeassistant/components/here_travel_time/translations/pt-BR.json index 78996561564..34f862f0029 100644 --- a/homeassistant/components/here_travel_time/translations/pt-BR.json +++ b/homeassistant/components/here_travel_time/translations/pt-BR.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" }, "error": { "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", @@ -41,7 +41,7 @@ }, "user": { "data": { - "api_key": "Chave API", + "api_key": "Chave da API", "mode": "Modo de viagem", "name": "Nome" } diff --git a/homeassistant/components/iss/translations/pt-BR.json b/homeassistant/components/iss/translations/pt-BR.json index 6618abe68f4..af3f4d37147 100644 --- a/homeassistant/components/iss/translations/pt-BR.json +++ b/homeassistant/components/iss/translations/pt-BR.json @@ -14,7 +14,7 @@ "step": { "init": { "data": { - "show_on_map": "[%key:component::iss::config::step::user::data::show_on_map%]" + "show_on_map": "Mostrar no mapa" } } } diff --git a/homeassistant/components/laundrify/translations/pt-BR.json b/homeassistant/components/laundrify/translations/pt-BR.json index c053ae34fe0..1e69a1b3204 100644 --- a/homeassistant/components/laundrify/translations/pt-BR.json +++ b/homeassistant/components/laundrify/translations/pt-BR.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "single_instance_allowed": "J\u00e1 est\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel." + "single_instance_allowed": "J\u00e1 configurado. Apenas uma configura\u00e7\u00e3o \u00e9 poss\u00edvel." }, "error": { - "cannot_connect": "Falhou ao se conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "invalid_format": "Formato Inv\u00e1lido. Especifique como xxx-xxx.", "unknown": "Erro inesperado" diff --git a/homeassistant/components/luftdaten/translations/pt-BR.json b/homeassistant/components/luftdaten/translations/pt-BR.json index 877cc5d133c..d26faf40b3e 100644 --- a/homeassistant/components/luftdaten/translations/pt-BR.json +++ b/homeassistant/components/luftdaten/translations/pt-BR.json @@ -8,7 +8,7 @@ "step": { "user": { "data": { - "show_on_map": "[%key:component::iss::config::step::user::data::show_on_map%]", + "show_on_map": "Mostrar no mapa", "station_id": "ID do Sensor Luftdaten" } } diff --git a/homeassistant/components/motion_blinds/translations/pt-BR.json b/homeassistant/components/motion_blinds/translations/pt-BR.json index 0a3b68357ee..eb0464e24a9 100644 --- a/homeassistant/components/motion_blinds/translations/pt-BR.json +++ b/homeassistant/components/motion_blinds/translations/pt-BR.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado", + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "connection_error": "Falha ao conectar" }, diff --git a/homeassistant/components/netatmo/translations/pt-BR.json b/homeassistant/components/netatmo/translations/pt-BR.json index 9f438868d43..b47c0ea3646 100644 --- a/homeassistant/components/netatmo/translations/pt-BR.json +++ b/homeassistant/components/netatmo/translations/pt-BR.json @@ -52,7 +52,7 @@ "lon_ne": "Longitude nordeste", "lon_sw": "Longitude sudoeste", "mode": "C\u00e1lculo", - "show_on_map": "[%key:component::iss::config::step::user::data::show_on_map%]" + "show_on_map": "Mostrar no mapa" }, "description": "Configure um sensor meteorol\u00f3gico p\u00fablico para uma \u00e1rea.", "title": "Sensor meteorol\u00f3gico p\u00fablico Netatmo" diff --git a/homeassistant/components/onewire/translations/pt-BR.json b/homeassistant/components/onewire/translations/pt-BR.json index 303d36f3cb2..307ca800ba2 100644 --- a/homeassistant/components/onewire/translations/pt-BR.json +++ b/homeassistant/components/onewire/translations/pt-BR.json @@ -9,7 +9,7 @@ "step": { "user": { "data": { - "host": "Host", + "host": "Nome do host", "port": "Porta" }, "title": "Definir detalhes do servidor" diff --git a/homeassistant/components/radiotherm/translations/nl.json b/homeassistant/components/radiotherm/translations/nl.json index ec2d0fc6554..6e23671fe02 100644 --- a/homeassistant/components/radiotherm/translations/nl.json +++ b/homeassistant/components/radiotherm/translations/nl.json @@ -18,5 +18,14 @@ } } } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "Stel een permanente hold in bij het aanpassen van de temperatuur." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/pt-BR.json b/homeassistant/components/radiotherm/translations/pt-BR.json index 59b4b32d965..da10f6bd457 100644 --- a/homeassistant/components/radiotherm/translations/pt-BR.json +++ b/homeassistant/components/radiotherm/translations/pt-BR.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "unknown": "Erro inesperado" }, "flow_title": "{name} {model} ({host})", @@ -14,7 +14,7 @@ }, "user": { "data": { - "host": "Host" + "host": "Nome do host" } } } diff --git a/homeassistant/components/scrape/translations/nl.json b/homeassistant/components/scrape/translations/nl.json index 81d41d6ff26..90e85d34677 100644 --- a/homeassistant/components/scrape/translations/nl.json +++ b/homeassistant/components/scrape/translations/nl.json @@ -23,8 +23,15 @@ }, "data_description": { "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", + "authentication": "Type van de HTTP-authenticatie. Ofwel basic of digest", + "device_class": "Het type/klasse van de sensor om het pictogram in de frontend in te stellen", + "headers": "Headers om te gebruiken voor het webverzoek", + "index": "Definieert welke van de door de CSS-selector geretourneerde elementen moeten worden gebruikt", "resource": "De URL naar de website die de waarde bevat", - "state_class": "De state_class van de sensor" + "select": "Definieert naar welke tag moet worden gezocht. Controleer Beautifulsoup CSS-selectors voor details", + "state_class": "De state_class van de sensor", + "value_template": "Definieert een sjabloon om de status van de sensor te krijgen", + "verify_ssl": "Activeert/de-activeert verificatie van SSL/TLS certificaat, als voorbeeld of het is zelf-getekend" } } } @@ -50,8 +57,15 @@ }, "data_description": { "attribute": "Haal de waarde op van een attribuut op de geselecteerde tag", + "authentication": "Type van de HTTP-authenticatie. Ofwel basic of digest", + "device_class": "Het type/klasse van de sensor om het pictogram in de frontend in te stellen", + "headers": "Headers om te gebruiken voor het webverzoek", + "index": "Definieert welke van de door de CSS-selector geretourneerde elementen moeten worden gebruikt", "resource": "De URL naar de website die de waarde bevat", - "state_class": "De state_class van de sensor" + "select": "Definieert naar welke tag moet worden gezocht. Controleer Beautifulsoup CSS-selectors voor details", + "state_class": "De state_class van de sensor", + "value_template": "Definieert een sjabloon om de status van de sensor te krijgen", + "verify_ssl": "Activeert/de-activeert verificatie van SSL/TLS certificaat, als voorbeeld of het is zelf-getekend" } } } diff --git a/homeassistant/components/scrape/translations/pt-BR.json b/homeassistant/components/scrape/translations/pt-BR.json index 24bbfe1fece..9876157182e 100644 --- a/homeassistant/components/scrape/translations/pt-BR.json +++ b/homeassistant/components/scrape/translations/pt-BR.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "A conta j\u00e1 est\u00e1 configurada" + "already_configured": "A conta j\u00e1 foi configurada" }, "step": { "user": { @@ -17,9 +17,9 @@ "select": "Selecionar", "state_class": "Classe de estado", "unit_of_measurement": "Unidade de medida", - "username": "Nome de usu\u00e1rio", + "username": "Usu\u00e1rio", "value_template": "Modelo de valor", - "verify_ssl": "Verificar certificado SSL" + "verify_ssl": "Verifique o certificado SSL" }, "data_description": { "attribute": "Obter valor de um atributo na tag selecionada", @@ -51,18 +51,18 @@ "select": "Selecionar", "state_class": "Classe de estado", "unit_of_measurement": "Unidade de medida", - "username": "Nome de usu\u00e1rio", + "username": "Usu\u00e1rio", "value_template": "Modelo de valor", "verify_ssl": "Verificar SSL" }, "data_description": { "attribute": "Obter valor de um atributo na tag selecionada", - "authentication": "Tipo de autentica\u00e7\u00e3o HTTP. Ou b\u00e1sico ou digerido", - "device_class": "O tipo/classe do sensor para definir o \u00edcone no frontend", + "authentication": "Tipo de autentica\u00e7\u00e3o HTTP. b\u00e1sica ou digerida", + "device_class": "O tipo/classe do sensor para definir o \u00edcone na frontend", "headers": "Cabe\u00e7alhos a serem usados para a solicita\u00e7\u00e3o da web", "index": "Define qual dos elementos retornados pelo seletor CSS usar", "resource": "A URL para o site que cont\u00e9m o valor", - "select": "Define qual tag pesquisar. Verifique os seletores CSS do Beautifulsoup para obter detalhes", + "select": "Define qual tag pesquisar. Verifique os seletores CSS da Beautiful Soup para obter detalhes", "state_class": "O classe de estado do sensor", "value_template": "Define um modelo para obter o estado do sensor", "verify_ssl": "Ativa/desativa a verifica\u00e7\u00e3o do certificado SSL/TLS, por exemplo, se for autoassinado" diff --git a/homeassistant/components/skybell/translations/pt-BR.json b/homeassistant/components/skybell/translations/pt-BR.json index 7e7af1a011a..9fed8c0da02 100644 --- a/homeassistant/components/skybell/translations/pt-BR.json +++ b/homeassistant/components/skybell/translations/pt-BR.json @@ -1,18 +1,18 @@ { "config": { "abort": { - "already_configured": "A conta j\u00e1 est\u00e1 configurada", + "already_configured": "A conta j\u00e1 foi configurada", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "unknown": "Erro inesperado" }, "step": { "user": { "data": { - "email": "E-mail", + "email": "Email", "password": "Senha" } } diff --git a/homeassistant/components/slack/translations/pt-BR.json b/homeassistant/components/slack/translations/pt-BR.json index a6299848bfc..834dea1bf0a 100644 --- a/homeassistant/components/slack/translations/pt-BR.json +++ b/homeassistant/components/slack/translations/pt-BR.json @@ -4,17 +4,17 @@ "already_configured": "O servi\u00e7o j\u00e1 est\u00e1 configurado" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida", "unknown": "Erro inesperado" }, "step": { "user": { "data": { - "api_key": "Chave de API", + "api_key": "Chave da API", "default_channel": "Canal padr\u00e3o", "icon": "\u00cdcone", - "username": "Nome de usu\u00e1rio" + "username": "Usu\u00e1rio" }, "data_description": { "api_key": "O token da API do Slack a ser usado para enviar mensagens do Slack.", diff --git a/homeassistant/components/tankerkoenig/translations/pt-BR.json b/homeassistant/components/tankerkoenig/translations/pt-BR.json index cad98a4283f..af26b6167b3 100644 --- a/homeassistant/components/tankerkoenig/translations/pt-BR.json +++ b/homeassistant/components/tankerkoenig/translations/pt-BR.json @@ -11,7 +11,7 @@ "step": { "reauth_confirm": { "data": { - "api_key": "Chave API" + "api_key": "Chave da API" } }, "select_station": { diff --git a/homeassistant/components/totalconnect/translations/nl.json b/homeassistant/components/totalconnect/translations/nl.json index aaf06b0b70f..cec03dc035b 100644 --- a/homeassistant/components/totalconnect/translations/nl.json +++ b/homeassistant/components/totalconnect/translations/nl.json @@ -32,6 +32,10 @@ "options": { "step": { "init": { + "data": { + "auto_bypass_low_battery": "Automatische bypass van batterij bijna leeg" + }, + "description": "Automatisch zones omzeilen op het moment dat ze een bijna lege batterij melden.", "title": "TotalConnect-opties" } } diff --git a/homeassistant/components/ukraine_alarm/translations/pt-BR.json b/homeassistant/components/ukraine_alarm/translations/pt-BR.json index 64b371196e3..128bd85492d 100644 --- a/homeassistant/components/ukraine_alarm/translations/pt-BR.json +++ b/homeassistant/components/ukraine_alarm/translations/pt-BR.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "O local j\u00e1 est\u00e1 configurado", - "cannot_connect": "Falhou ao conectar", + "already_configured": "Localiza\u00e7\u00e3o j\u00e1 est\u00e1 configurada", + "cannot_connect": "Falha ao conectar", "max_regions": "M\u00e1ximo de 5 regi\u00f5es podem ser configuradas", "rate_limit": "Excesso de pedidos", - "timeout": "Tempo limite estabelecendo conex\u00e3o", + "timeout": "Tempo limite para estabelecer conex\u00e3o atingido", "unknown": "Erro inesperado" }, "step": { diff --git a/homeassistant/components/ws66i/translations/pt-BR.json b/homeassistant/components/ws66i/translations/pt-BR.json index d440aab3aa4..68bc805d08c 100644 --- a/homeassistant/components/ws66i/translations/pt-BR.json +++ b/homeassistant/components/ws66i/translations/pt-BR.json @@ -1,10 +1,10 @@ { "config": { "abort": { - "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" }, "error": { - "cannot_connect": "Falhou ao conectar", + "cannot_connect": "Falha ao conectar", "unknown": "Erro inesperado" }, "step": { diff --git a/homeassistant/components/yolink/translations/pt-BR.json b/homeassistant/components/yolink/translations/pt-BR.json index 31a69f7ed3f..bbd59ef845f 100644 --- a/homeassistant/components/yolink/translations/pt-BR.json +++ b/homeassistant/components/yolink/translations/pt-BR.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "already_configured": "A conta j\u00e1 est\u00e1 configurada", - "already_in_progress": "A configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "already_configured": "A conta j\u00e1 foi configurada", + "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "Nenhuma URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre este erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", - "oauth_error": "Dados de token inv\u00e1lidos recebidos.", + "no_url_available": "N\u00e3o h\u00e1 URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", + "oauth_error": "Dados de token recebidos inv\u00e1lidos.", "reauth_successful": "A reautentica\u00e7\u00e3o foi bem-sucedida" }, "create_entry": { From 5854dfa84f7d575d0b96640a0b3cbacef7a2b869 Mon Sep 17 00:00:00 2001 From: Marcio Granzotto Rodrigues Date: Sun, 12 Jun 2022 22:27:18 -0300 Subject: [PATCH 367/947] Fix smart by bond detection with v3 firmware (#73414) --- homeassistant/components/bond/manifest.json | 2 +- homeassistant/components/bond/utils.py | 5 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/bond/common.py | 2 +- tests/components/bond/test_config_flow.py | 24 ++++++------- tests/components/bond/test_diagnostics.py | 2 +- tests/components/bond/test_init.py | 38 ++++++++++++++------- tests/components/bond/test_light.py | 2 +- 9 files changed, 46 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/bond/manifest.json b/homeassistant/components/bond/manifest.json index 52e9dd1763f..a5625d7b642 100644 --- a/homeassistant/components/bond/manifest.json +++ b/homeassistant/components/bond/manifest.json @@ -3,7 +3,7 @@ "name": "Bond", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/bond", - "requirements": ["bond-async==0.1.20"], + "requirements": ["bond-async==0.1.22"], "zeroconf": ["_bond._tcp.local."], "codeowners": ["@bdraco", "@prystupa", "@joshs85", "@marciogranzotto"], "quality_scale": "platinum", diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index cba213d9450..c426bf64577 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast from aiohttp import ClientResponseError -from bond_async import Action, Bond +from bond_async import Action, Bond, BondType from homeassistant.util.async_ import gather_with_concurrency @@ -224,4 +224,5 @@ class BondHub: @property def is_bridge(self) -> bool: """Return if the Bond is a Bond Bridge.""" - return bool(self._bridge) + bondid = self._version["bondid"] + return bool(BondType.is_bridge_from_serial(bondid)) diff --git a/requirements_all.txt b/requirements_all.txt index aa93ba61889..843680f5227 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -420,7 +420,7 @@ blockchain==1.4.4 # bluepy==1.3.0 # homeassistant.components.bond -bond-async==0.1.20 +bond-async==0.1.22 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f2bc79db670..151a427470b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -321,7 +321,7 @@ blebox_uniapi==1.3.3 blinkpy==0.19.0 # homeassistant.components.bond -bond-async==0.1.20 +bond-async==0.1.22 # homeassistant.components.bosch_shc boschshcpy==0.2.30 diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py index c5a649ab30a..f14efcdf172 100644 --- a/tests/components/bond/common.py +++ b/tests/components/bond/common.py @@ -127,7 +127,7 @@ def patch_bond_version( return nullcontext() if return_value is None: - return_value = {"bondid": "test-bond-id"} + return_value = {"bondid": "ZXXX12345"} return patch( "homeassistant.components.bond.Bond.version", diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 67910af7b6c..4f1e313a34a 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -35,7 +35,7 @@ async def test_user_form(hass: core.HomeAssistant): assert result["errors"] == {} with patch_bond_version( - return_value={"bondid": "test-bond-id"} + return_value={"bondid": "ZXXX12345"} ), patch_bond_device_ids( return_value=["f6776c11", "f6776c12"] ), patch_bond_bridge(), patch_bond_device_properties(), patch_bond_device(), _patch_async_setup_entry() as mock_setup_entry: @@ -64,7 +64,7 @@ async def test_user_form_with_non_bridge(hass: core.HomeAssistant): assert result["errors"] == {} with patch_bond_version( - return_value={"bondid": "test-bond-id"} + return_value={"bondid": "KXXX12345"} ), patch_bond_device_ids( return_value=["f6776c11"] ), patch_bond_device_properties(), patch_bond_device( @@ -96,7 +96,7 @@ async def test_user_form_invalid_auth(hass: core.HomeAssistant): ) with patch_bond_version( - return_value={"bond_id": "test-bond-id"} + return_value={"bond_id": "ZXXX12345"} ), patch_bond_bridge(), patch_bond_device_ids( side_effect=ClientResponseError(Mock(), Mock(), status=401), ): @@ -203,7 +203,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -213,7 +213,7 @@ async def test_zeroconf_form(hass: core.HomeAssistant): assert result["errors"] == {} with patch_bond_version( - return_value={"bondid": "test-bond-id"} + return_value={"bondid": "ZXXX12345"} ), patch_bond_bridge(), patch_bond_device_ids(), _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -241,7 +241,7 @@ async def test_zeroconf_form_token_unavailable(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -270,7 +270,7 @@ async def test_zeroconf_form_token_unavailable(hass: core.HomeAssistant): async def test_zeroconf_form_with_token_available(hass: core.HomeAssistant): """Test we get the discovery form when we can get the token.""" - with patch_bond_version(return_value={"bondid": "test-bond-id"}), patch_bond_token( + with patch_bond_version(return_value={"bondid": "ZXXX12345"}), patch_bond_token( return_value={"token": "discovered-token"} ), patch_bond_bridge( return_value={"name": "discovered-name"} @@ -282,7 +282,7 @@ async def test_zeroconf_form_with_token_available(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -323,7 +323,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -341,7 +341,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( await hass.async_block_till_done() assert result2["type"] == "create_entry" - assert result2["title"] == "test-bond-id" + assert result2["title"] == "ZXXX12345" assert result2["data"] == { CONF_HOST: "test-host", CONF_ACCESS_TOKEN: "discovered-token", @@ -511,7 +511,7 @@ async def test_zeroconf_form_unexpected_error(hass: core.HomeAssistant): host="test-host", addresses=["test-host"], hostname="mock_hostname", - name="test-bond-id.some-other-tail-info", + name="ZXXX12345.some-other-tail-info", port=None, properties={}, type="mock_type", @@ -536,7 +536,7 @@ async def _help_test_form_unexpected_error( ) with patch_bond_version( - return_value={"bond_id": "test-bond-id"} + return_value={"bond_id": "ZXXX12345"} ), patch_bond_device_ids(side_effect=error): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input diff --git a/tests/components/bond/test_diagnostics.py b/tests/components/bond/test_diagnostics.py index 88d33ff2cc0..b738c72ee8c 100644 --- a/tests/components/bond/test_diagnostics.py +++ b/tests/components/bond/test_diagnostics.py @@ -39,5 +39,5 @@ async def test_diagnostics(hass, hass_client): "data": {"access_token": "**REDACTED**", "host": "some host"}, "title": "Mock Title", }, - "hub": {"version": {"bondid": "test-bond-id"}}, + "hub": {"version": {"bondid": "ZXXX12345"}}, } diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py index 5db5d8e65bf..d02e2bed4ec 100644 --- a/tests/components/bond/test_init.py +++ b/tests/components/bond/test_init.py @@ -9,7 +9,7 @@ import pytest from homeassistant.components.bond.const import DOMAIN from homeassistant.components.fan import DOMAIN as FAN_DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.const import ATTR_ASSUMED_STATE, CONF_ACCESS_TOKEN, CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry @@ -86,7 +86,7 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss with patch_bond_bridge(), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "ZXXX12345", "target": "test-model", "fw_ver": "test-version", "mcu_ver": "test-hw-version", @@ -104,11 +104,11 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "ZXXX12345" # verify hub device is registered correctly device_registry = dr.async_get(hass) - hub = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + hub = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert hub.name == "bond-name" assert hub.manufacturer == "Olibra" assert hub.model == "test-model" @@ -156,7 +156,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant): ) old_identifers = (DOMAIN, "device_id") - new_identifiers = (DOMAIN, "test-bond-id", "device_id") + new_identifiers = (DOMAIN, "ZXXX12345", "device_id") device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, @@ -169,7 +169,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant): with patch_bond_bridge(), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "ZXXX12345", "target": "test-model", "fw_ver": "test-version", } @@ -190,7 +190,7 @@ async def test_old_identifiers_are_removed(hass: HomeAssistant): assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "ZXXX12345" # verify the device info is cleaned up assert device_registry.async_get_device(identifiers={old_identifers}) is None @@ -210,7 +210,7 @@ async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant): side_effect=ClientResponseError(Mock(), Mock(), status=404) ), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "KXXX12345", "target": "test-model", "fw_ver": "test-version", } @@ -232,10 +232,10 @@ async def test_smart_by_bond_device_suggested_area(hass: HomeAssistant): assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "KXXX12345" device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "KXXX12345")}) assert device is not None assert device.suggested_area == "Den" @@ -256,7 +256,7 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant): } ), patch_bond_version( return_value={ - "bondid": "test-bond-id", + "bondid": "ZXXX12345", "target": "test-model", "fw_ver": "test-version", } @@ -278,10 +278,10 @@ async def test_bridge_device_suggested_area(hass: HomeAssistant): assert config_entry.entry_id in hass.data[DOMAIN] assert config_entry.state is ConfigEntryState.LOADED - assert config_entry.unique_id == "test-bond-id" + assert config_entry.unique_id == "ZXXX12345" device_registry = dr.async_get(hass) - device = device_registry.async_get_device(identifiers={(DOMAIN, "test-bond-id")}) + device = device_registry.async_get_device(identifiers={(DOMAIN, "ZXXX12345")}) assert device is not None assert device.suggested_area == "Office" @@ -343,3 +343,15 @@ async def test_device_remove_devices(hass, hass_ws_client): ) is False ) + + +async def test_smart_by_bond_v3_firmware(hass: HomeAssistant) -> None: + """Test we can detect smart by bond with the v3 firmware.""" + await setup_platform( + hass, + FAN_DOMAIN, + ceiling_fan("name-1"), + bond_version={"bondid": "KXXXX12345", "target": "breck-northstar"}, + bond_device_id="test-device-id", + ) + assert ATTR_ASSUMED_STATE not in hass.states.get("fan.name_1").attributes diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py index c7d8f195423..7577b1d70ab 100644 --- a/tests/components/bond/test_light.py +++ b/tests/components/bond/test_light.py @@ -249,7 +249,7 @@ async def test_sbb_trust_state(hass: core.HomeAssistant): """Assumed state should be False if device is a Smart by Bond.""" version = { "model": "MR123A", - "bondid": "test-bond-id", + "bondid": "KXXX12345", } await setup_platform( hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_version=version, bridge={} From 9ae713f1288f936ebdb86e4aff304e86f91ff59f Mon Sep 17 00:00:00 2001 From: kingy444 Date: Mon, 13 Jun 2022 12:26:38 +1000 Subject: [PATCH 368/947] Improve error handling of powerview hub maintenance, remove invalid device classes (#73395) --- .../components/hunterdouglas_powerview/button.py | 8 +------- .../components/hunterdouglas_powerview/coordinator.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index 131ef279a20..b13d0217a20 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -7,11 +7,7 @@ from typing import Any, Final from aiopvapi.resources.shade import BaseShade, factory as PvShade -from homeassistant.components.button import ( - ButtonDeviceClass, - ButtonEntity, - ButtonEntityDescription, -) +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory @@ -50,7 +46,6 @@ BUTTONS: Final = [ key="calibrate", name="Calibrate", icon="mdi:swap-vertical-circle-outline", - device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.calibrate(), ), @@ -58,7 +53,6 @@ BUTTONS: Final = [ key="identify", name="Identify", icon="mdi:crosshairs-question", - device_class=ButtonDeviceClass.UPDATE, entity_category=EntityCategory.DIAGNOSTIC, press_action=lambda shade: shade.jog(), ), diff --git a/homeassistant/components/hunterdouglas_powerview/coordinator.py b/homeassistant/components/hunterdouglas_powerview/coordinator.py index bf3d6eb7a54..7c45feba491 100644 --- a/homeassistant/components/hunterdouglas_powerview/coordinator.py +++ b/homeassistant/components/hunterdouglas_powerview/coordinator.py @@ -36,9 +36,19 @@ class PowerviewShadeUpdateCoordinator(DataUpdateCoordinator[PowerviewShadeData]) async def _async_update_data(self) -> PowerviewShadeData: """Fetch data from shade endpoint.""" + async with async_timeout.timeout(10): shade_entries = await self.shades.get_resources() + + if isinstance(shade_entries, bool): + # hub returns boolean on a 204/423 empty response (maintenance) + # continual polling results in inevitable error + raise UpdateFailed("Powerview Hub is undergoing maintenance") + if not shade_entries: raise UpdateFailed("Failed to fetch new shade data") + + # only update if shade_entries is valid self.data.store_group_data(shade_entries[SHADE_DATA]) + return self.data From 9159db4b4ab83736eed6763138684e8caff550cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 17:04:17 -1000 Subject: [PATCH 369/947] Only update unifiprotect ips from discovery when the console is offline (#73411) --- .../components/unifiprotect/config_flow.py | 32 ++++++++++++++- homeassistant/components/unifiprotect/data.py | 10 +++++ .../components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../unifiprotect/test_config_flow.py | 40 ++++++++++++++++++- 6 files changed, 83 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 23e2541e6d8..e183edf4259 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -8,6 +8,7 @@ from typing import Any from aiohttp import CookieJar from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import NVR +from unifi_discovery import async_console_is_alive import voluptuous as vol from homeassistant import config_entries @@ -22,7 +23,10 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.aiohttp_client import ( + async_create_clientsession, + async_get_clientsession, +) from homeassistant.helpers.typing import DiscoveryInfoType from homeassistant.loader import async_get_integration from homeassistant.util.network import is_ip_address @@ -37,11 +41,17 @@ from .const import ( MIN_REQUIRED_PROTECT_V, OUTDATED_LOG_MESSAGE, ) +from .data import async_last_update_was_successful from .discovery import async_start_discovery from .utils import _async_resolve, _async_short_mac, _async_unifi_mac_from_hass _LOGGER = logging.getLogger(__name__) +ENTRY_FAILURE_STATES = ( + config_entries.ConfigEntryState.SETUP_ERROR, + config_entries.ConfigEntryState.SETUP_RETRY, +) + async def async_local_user_documentation_url(hass: HomeAssistant) -> str: """Get the documentation url for creating a local user.""" @@ -54,6 +64,25 @@ def _host_is_direct_connect(host: str) -> bool: return host.endswith(".ui.direct") +async def _async_console_is_offline( + hass: HomeAssistant, + entry: config_entries.ConfigEntry, +) -> bool: + """Check if a console is offline. + + We define offline by the config entry + is in a failure/retry state or the updates + are failing and the console is unreachable + since protect may be updating. + """ + return bool( + entry.state in ENTRY_FAILURE_STATES + or not async_last_update_was_successful(hass, entry) + ) and not await async_console_is_alive( + async_get_clientsession(hass, verify_ssl=False), entry.data[CONF_HOST] + ) + + class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a UniFi Protect config flow.""" @@ -111,6 +140,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): not entry_has_direct_connect and is_ip_address(entry_host) and entry_host != source_ip + and await _async_console_is_offline(self.hass, entry) ): new_host = source_ip if new_host: diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 68c8873c17e..bcc1e561e99 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -26,6 +26,16 @@ from .utils import async_get_adoptable_devices_by_type, async_get_devices _LOGGER = logging.getLogger(__name__) +@callback +def async_last_update_was_successful(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Check if the last update was successful for a config entry.""" + return bool( + DOMAIN in hass.data + and entry.entry_id in hass.data[DOMAIN] + and hass.data[DOMAIN][entry.entry_id].last_update_success + ) + + class ProtectData: """Coordinate updates.""" diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 199298d76ca..2554d12c866 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.9.2", "unifi-discovery==1.1.3"], + "requirements": ["pyunifiprotect==3.9.2", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 843680f5227..f0418e6596f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2355,7 +2355,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.unifiprotect -unifi-discovery==1.1.3 +unifi-discovery==1.1.4 # homeassistant.components.unifiled unifiled==0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 151a427470b..cdb907b731e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1552,7 +1552,7 @@ twitchAPI==2.5.2 uasiren==0.0.1 # homeassistant.components.unifiprotect -unifi-discovery==1.1.3 +unifi-discovery==1.1.4 # homeassistant.components.upb upb_lib==0.4.12 diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 80e845591b1..75f08acb37c 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -402,7 +402,10 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin ) mock_config.add_to_hass(hass) - with _patch_discovery(): + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.config_flow.async_console_is_alive", + return_value=False, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, @@ -415,6 +418,41 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin assert mock_config.data[CONF_HOST] == "127.0.0.1" +async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_still_online( + hass: HomeAssistant, mock_nvr: NVR +) -> None: + """Test a discovery from unifi-discovery does not update the ip unless the console at the old ip is offline.""" + mock_config = MockConfigEntry( + domain=DOMAIN, + data={ + "host": "1.2.2.2", + "username": "test-username", + "password": "test-password", + "id": "UnifiProtect", + "port": 443, + "verify_ssl": False, + }, + version=2, + unique_id=DEVICE_MAC_ADDRESS.replace(":", "").upper(), + ) + mock_config.add_to_hass(hass) + + with _patch_discovery(), patch( + "homeassistant.components.unifiprotect.config_flow.async_console_is_alive", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_INTEGRATION_DISCOVERY}, + data=UNIFI_DISCOVERY_DICT, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert mock_config.data[CONF_HOST] == "1.2.2.2" + + async def test_discovered_host_not_updated_if_existing_is_a_hostname( hass: HomeAssistant, mock_nvr: NVR ) -> None: From a7f72931ad3d83c8974714e956cb9b729f199293 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 17:12:49 -1000 Subject: [PATCH 370/947] Simplify esphome state updates (#73409) --- homeassistant/components/esphome/__init__.py | 19 +++---------------- .../components/esphome/entry_data.py | 17 ++++++----------- 2 files changed, 9 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 8aa2dba4d07..2e88a883dc1 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -568,6 +568,7 @@ async def platform_async_setup_entry( @callback def async_list_entities(infos: list[EntityInfo]) -> None: """Update entities of this platform when entities are listed.""" + key_to_component = entry_data.key_to_component old_infos = entry_data.info[component_key] new_infos: dict[int, EntityInfo] = {} add_entities = [] @@ -586,10 +587,12 @@ async def platform_async_setup_entry( entity = entity_type(entry_data, component_key, info.key) add_entities.append(entity) new_infos[info.key] = info + key_to_component[info.key] = component_key # Remove old entities for info in old_infos.values(): entry_data.async_remove_entity(hass, component_key, info.key) + key_to_component.pop(info.key, None) # First copy the now-old info into the backup object entry_data.old_info[component_key] = entry_data.info[component_key] @@ -604,22 +607,6 @@ async def platform_async_setup_entry( async_dispatcher_connect(hass, signal, async_list_entities) ) - @callback - def async_entity_state(state: EntityState) -> None: - """Notify the appropriate entity of an updated state.""" - if not isinstance(state, state_type): - return - # cast back to upper type, otherwise mypy gets confused - state = cast(EntityState, state) - - entry_data.state[component_key][state.key] = state - entry_data.async_update_entity(hass, component_key, state.key) - - signal = f"esphome_{entry.entry_id}_on_state" - entry_data.cleanup_callbacks.append( - async_dispatcher_connect(hass, signal, async_entity_state) - ) - _PropT = TypeVar("_PropT", bound=Callable[..., Any]) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 4c5a94afe0f..7980d1a6a17 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -65,6 +65,7 @@ class RuntimeEntryData: store: Store state: dict[str, dict[int, EntityState]] = field(default_factory=dict) info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) + key_to_component: dict[int, str] = field(default_factory=dict) # A second list of EntityInfo objects # This is necessary for when an entity is being removed. HA requires @@ -82,14 +83,6 @@ class RuntimeEntryData: platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _storage_contents: dict[str, Any] | None = None - @callback - def async_update_entity( - self, hass: HomeAssistant, component_key: str, key: int - ) -> None: - """Schedule the update of an entity.""" - signal = f"esphome_{self.entry_id}_update_{component_key}_{key}" - async_dispatcher_send(hass, signal) - @callback def async_remove_entity( self, hass: HomeAssistant, component_key: str, key: int @@ -131,9 +124,11 @@ class RuntimeEntryData: @callback def async_update_state(self, hass: HomeAssistant, state: EntityState) -> None: - """Distribute an update of state information to all platforms.""" - signal = f"esphome_{self.entry_id}_on_state" - async_dispatcher_send(hass, signal, state) + """Distribute an update of state information to the target.""" + component_key = self.key_to_component[state.key] + self.state[component_key][state.key] = state + signal = f"esphome_{self.entry_id}_update_{component_key}_{state.key}" + async_dispatcher_send(hass, signal) @callback def async_update_device_state(self, hass: HomeAssistant) -> None: From c6a6d7039ef7e46251ce8134589fb32e22cb813a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 17:14:19 -1000 Subject: [PATCH 371/947] Add unique ids to lutron_caseta scenes (#73383) --- .coveragerc | 1 + .../components/lutron_caseta/scene.py | 25 ++++++++++++++----- .../components/lutron_caseta/util.py | 7 ++++++ 3 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/lutron_caseta/util.py diff --git a/.coveragerc b/.coveragerc index 8d2cdd32336..455bc7fe8a8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -679,6 +679,7 @@ omit = homeassistant/components/lutron_caseta/light.py homeassistant/components/lutron_caseta/scene.py homeassistant/components/lutron_caseta/switch.py + homeassistant/components/lutron_caseta/util.py homeassistant/components/lw12wifi/light.py homeassistant/components/lyric/__init__.py homeassistant/components/lyric/api.py diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index d73d8011481..1bbd69615e1 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -1,12 +1,17 @@ """Support for Lutron Caseta scenes.""" from typing import Any +from pylutron_caseta.smartbridge import Smartbridge + from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from . import _area_and_name_from_name +from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .util import serial_to_unique_id async def async_setup_entry( @@ -20,19 +25,27 @@ async def async_setup_entry( scene entities. """ data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] + bridge: Smartbridge = data[BRIDGE_LEAP] + bridge_device = data[BRIDGE_DEVICE] scenes = bridge.get_scenes() - async_add_entities(LutronCasetaScene(scenes[scene], bridge) for scene in scenes) + async_add_entities( + LutronCasetaScene(scenes[scene], bridge, bridge_device) for scene in scenes + ) class LutronCasetaScene(Scene): """Representation of a Lutron Caseta scene.""" - def __init__(self, scene, bridge): + def __init__(self, scene, bridge, bridge_device): """Initialize the Lutron Caseta scene.""" - self._attr_name = scene["name"] self._scene_id = scene["scene_id"] - self._bridge = bridge + self._bridge: Smartbridge = bridge + bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) + self._attr_device_info = DeviceInfo( + identifiers={(CASETA_DOMAIN, bridge_device["serial"])}, + ) + self._attr_name = _area_and_name_from_name(scene["name"])[1] + self._attr_unique_id = f"scene_{bridge_unique_id}_{self._scene_id}" async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" diff --git a/homeassistant/components/lutron_caseta/util.py b/homeassistant/components/lutron_caseta/util.py new file mode 100644 index 00000000000..dfcf7a32228 --- /dev/null +++ b/homeassistant/components/lutron_caseta/util.py @@ -0,0 +1,7 @@ +"""Support for Lutron Caseta.""" +from __future__ import annotations + + +def serial_to_unique_id(serial: int) -> str: + """Convert a lutron serial number to a unique id.""" + return hex(serial)[2:].zfill(8) From 7756ddbe80b5bf6e18c1ca1f858b83e3cdc1e09a Mon Sep 17 00:00:00 2001 From: Corbeno Date: Sun, 12 Jun 2022 22:15:01 -0500 Subject: [PATCH 372/947] Bump proxmoxer to 1.3.1 (#73418) bump proxmoxer --- homeassistant/components/proxmoxve/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proxmoxve/manifest.json b/homeassistant/components/proxmoxve/manifest.json index 4b600abc930..aa76aa60118 100644 --- a/homeassistant/components/proxmoxve/manifest.json +++ b/homeassistant/components/proxmoxve/manifest.json @@ -3,7 +3,7 @@ "name": "Proxmox VE", "documentation": "https://www.home-assistant.io/integrations/proxmoxve", "codeowners": ["@jhollowe", "@Corbeno"], - "requirements": ["proxmoxer==1.1.1"], + "requirements": ["proxmoxer==1.3.1"], "iot_class": "local_polling", "loggers": ["proxmoxer"] } diff --git a/requirements_all.txt b/requirements_all.txt index f0418e6596f..ee66e93b7e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1275,7 +1275,7 @@ proliphix==0.4.1 prometheus_client==0.7.1 # homeassistant.components.proxmoxve -proxmoxer==1.1.1 +proxmoxer==1.3.1 # homeassistant.components.systemmonitor psutil==5.9.0 From 1d5290b03f4d3027b3b502de1022cdc1b6eb02d3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Jun 2022 05:17:51 +0200 Subject: [PATCH 373/947] Update watchdog to 2.1.9 (#73407) --- homeassistant/components/folder_watcher/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json index f7562633ba0..64bfbb3df37 100644 --- a/homeassistant/components/folder_watcher/manifest.json +++ b/homeassistant/components/folder_watcher/manifest.json @@ -2,7 +2,7 @@ "domain": "folder_watcher", "name": "Folder Watcher", "documentation": "https://www.home-assistant.io/integrations/folder_watcher", - "requirements": ["watchdog==2.1.8"], + "requirements": ["watchdog==2.1.9"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_polling", diff --git a/requirements_all.txt b/requirements_all.txt index ee66e93b7e8..8e32ca35d62 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2421,7 +2421,7 @@ wallbox==0.4.9 waqiasync==1.0.0 # homeassistant.components.folder_watcher -watchdog==2.1.8 +watchdog==2.1.9 # homeassistant.components.waterfurnace waterfurnace==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cdb907b731e..541e36cf252 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1600,7 +1600,7 @@ wakeonlan==2.0.1 wallbox==0.4.9 # homeassistant.components.folder_watcher -watchdog==2.1.8 +watchdog==2.1.9 # homeassistant.components.whirlpool whirlpool-sixth-sense==0.15.1 From 23e17c5b47ae20ad85c8c15baf2ab3521457b77d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Jun 2022 05:17:58 +0200 Subject: [PATCH 374/947] Update coverage to 6.4.1 (#73405) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 7c03d2e51c2..5d366a3c350 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt codecov==2.1.12 -coverage==6.4 +coverage==6.4.1 freezegun==1.2.1 mock-open==1.4.0 mypy==0.961 From f85409b2ea326d5f470d6bfcbaf02a21e92cca27 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Sun, 12 Jun 2022 23:18:48 -0400 Subject: [PATCH 375/947] Remove deprecated services from Mazda integration (#73403) --- homeassistant/components/mazda/__init__.py | 55 +++++----------- homeassistant/components/mazda/const.py | 10 --- homeassistant/components/mazda/services.yaml | 66 -------------------- tests/components/mazda/test_init.py | 48 ++++++++++---- 4 files changed, 51 insertions(+), 128 deletions(-) diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index 2af4e46bb1a..85b9700a624 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -33,7 +33,7 @@ from homeassistant.helpers.update_coordinator import ( UpdateFailed, ) -from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN, SERVICES +from .const import DATA_CLIENT, DATA_COORDINATOR, DATA_VEHICLES, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -109,34 +109,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if vehicle_id == 0 or api_client is None: raise HomeAssistantError("Vehicle ID not found") - if service_call.service in ( - "start_engine", - "stop_engine", - "turn_on_hazard_lights", - "turn_off_hazard_lights", - ): - _LOGGER.warning( - "The mazda.%s service is deprecated and has been replaced by a button entity; " - "Please use the button entity instead", - service_call.service, - ) - - if service_call.service in ("start_charging", "stop_charging"): - _LOGGER.warning( - "The mazda.%s service is deprecated and has been replaced by a switch entity; " - "Please use the charging switch entity instead", - service_call.service, - ) - api_method = getattr(api_client, service_call.service) try: - if service_call.service == "send_poi": - latitude = service_call.data["latitude"] - longitude = service_call.data["longitude"] - poi_name = service_call.data["poi_name"] - await api_method(vehicle_id, latitude, longitude, poi_name) - else: - await api_method(vehicle_id) + latitude = service_call.data["latitude"] + longitude = service_call.data["longitude"] + poi_name = service_call.data["poi_name"] + await api_method(vehicle_id, latitude, longitude, poi_name) except Exception as ex: raise HomeAssistantError(ex) from ex @@ -157,12 +135,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return device_id - service_schema = vol.Schema( - {vol.Required("device_id"): vol.All(cv.string, validate_mazda_device_id)} - ) - - service_schema_send_poi = service_schema.extend( + service_schema_send_poi = vol.Schema( { + vol.Required("device_id"): vol.All(cv.string, validate_mazda_device_id), vol.Required("latitude"): cv.latitude, vol.Required("longitude"): cv.longitude, vol.Required("poi_name"): cv.string, @@ -220,13 +195,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.config_entries.async_setup_platforms(entry, PLATFORMS) # Register services - for service in SERVICES: - hass.services.async_register( - DOMAIN, - service, - async_handle_service_call, - schema=service_schema_send_poi if service == "send_poi" else service_schema, - ) + hass.services.async_register( + DOMAIN, + "send_poi", + async_handle_service_call, + schema=service_schema_send_poi, + ) return True @@ -237,8 +211,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Only remove services if it is the last config entry if len(hass.data[DOMAIN]) == 1: - for service in SERVICES: - hass.services.async_remove(DOMAIN, service) + hass.services.async_remove(DOMAIN, "send_poi") if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) diff --git a/homeassistant/components/mazda/const.py b/homeassistant/components/mazda/const.py index 5baeef3102d..58ca2183a56 100644 --- a/homeassistant/components/mazda/const.py +++ b/homeassistant/components/mazda/const.py @@ -7,13 +7,3 @@ DATA_COORDINATOR = "coordinator" DATA_VEHICLES = "vehicles" MAZDA_REGIONS = {"MNAO": "North America", "MME": "Europe", "MJO": "Japan"} - -SERVICES = [ - "send_poi", - "start_charging", - "start_engine", - "stop_charging", - "stop_engine", - "turn_off_hazard_lights", - "turn_on_hazard_lights", -] diff --git a/homeassistant/components/mazda/services.yaml b/homeassistant/components/mazda/services.yaml index 80d8c2f64f6..1abf8bd5dea 100644 --- a/homeassistant/components/mazda/services.yaml +++ b/homeassistant/components/mazda/services.yaml @@ -1,47 +1,3 @@ -start_engine: - name: Start engine - description: Start the vehicle engine. - fields: - device_id: - name: Vehicle - description: The vehicle to start - required: true - selector: - device: - integration: mazda -stop_engine: - name: Stop engine - description: Stop the vehicle engine. - fields: - device_id: - name: Vehicle - description: The vehicle to stop - required: true - selector: - device: - integration: mazda -turn_on_hazard_lights: - name: Turn on hazard lights - description: Turn on the vehicle hazard lights. The lights will flash briefly and then turn off. - fields: - device_id: - name: Vehicle - description: The vehicle to turn hazard lights on - required: true - selector: - device: - integration: mazda -turn_off_hazard_lights: - name: Turn off hazard lights - description: Turn off the vehicle hazard lights if they have been manually turned on from inside the vehicle. - fields: - device_id: - name: Vehicle - description: The vehicle to turn hazard lights off - required: true - selector: - device: - integration: mazda send_poi: name: Send POI description: Send a GPS location to the vehicle's navigation system as a POI (Point of Interest). Requires a navigation SD card installed in the vehicle. @@ -82,25 +38,3 @@ send_poi: required: true selector: text: -start_charging: - name: Start charging - description: Start charging the vehicle. For electric vehicles only. - fields: - device_id: - name: Vehicle - description: The vehicle to start charging - required: true - selector: - device: - integration: mazda -stop_charging: - name: Stop charging - description: Stop charging the vehicle. For electric vehicles only. - fields: - device_id: - name: Vehicle - description: The vehicle to stop charging - required: true - selector: - device: - integration: mazda diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 9d221bbfe88..bd443bb17f3 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -203,12 +203,6 @@ async def test_device_no_nickname(hass): @pytest.mark.parametrize( "service, service_data, expected_args", [ - ("start_charging", {}, [12345]), - ("start_engine", {}, [12345]), - ("stop_charging", {}, [12345]), - ("stop_engine", {}, [12345]), - ("turn_off_hazard_lights", {}, [12345]), - ("turn_on_hazard_lights", {}, [12345]), ( "send_poi", {"latitude": 1.2345, "longitude": 2.3456, "poi_name": "Work"}, @@ -241,7 +235,15 @@ async def test_service_invalid_device_id(hass): with pytest.raises(vol.error.MultipleInvalid) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": "invalid"}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": "invalid", + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() @@ -262,7 +264,15 @@ async def test_service_device_id_not_mazda_vehicle(hass): with pytest.raises(vol.error.MultipleInvalid) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": other_device.id}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": other_device.id, + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() @@ -287,7 +297,15 @@ async def test_service_vehicle_id_not_found(hass): with pytest.raises(HomeAssistantError) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": device_id}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": device_id, + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() @@ -324,11 +342,19 @@ async def test_service_mazda_api_error(hass): device_id = reg_device.id with patch( - "homeassistant.components.mazda.MazdaAPI.start_engine", + "homeassistant.components.mazda.MazdaAPI.send_poi", side_effect=MazdaException("Test error"), ), pytest.raises(HomeAssistantError) as err: await hass.services.async_call( - DOMAIN, "start_engine", {"device_id": device_id}, blocking=True + DOMAIN, + "send_poi", + { + "device_id": device_id, + "latitude": 1.2345, + "longitude": 6.7890, + "poi_name": "poi_name", + }, + blocking=True, ) await hass.async_block_till_done() From f732c516009994c3ce74665f467eebaa2de85c50 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Sun, 12 Jun 2022 23:27:19 -0400 Subject: [PATCH 376/947] Add support for playing latest activity video for Skybell (#73373) * Add support for playing latest activity video * ffmpeg * uno mas * uno mas --- homeassistant/components/skybell/camera.py | 39 ++++++++++++++++--- .../components/skybell/manifest.json | 1 + 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/skybell/camera.py b/homeassistant/components/skybell/camera.py index f531e67f2d0..499f1f3bfca 100644 --- a/homeassistant/components/skybell/camera.py +++ b/homeassistant/components/skybell/camera.py @@ -1,6 +1,8 @@ """Camera support for the Skybell HD Doorbell.""" from __future__ import annotations +from aiohttp import web +from haffmpeg.camera import CameraMjpeg import voluptuous as vol from homeassistant.components.camera import ( @@ -8,9 +10,11 @@ from homeassistant.components.camera import ( Camera, CameraEntityDescription, ) +from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -46,11 +50,14 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up Skybell switch.""" - async_add_entities( - SkybellCamera(coordinator, description) - for description in CAMERA_TYPES - for coordinator in hass.data[DOMAIN][entry.entry_id] - ) + entities = [] + for description in CAMERA_TYPES: + for coordinator in hass.data[DOMAIN][entry.entry_id]: + if description.key == "avatar": + entities.append(SkybellCamera(coordinator, description)) + else: + entities.append(SkybellActivityCamera(coordinator, description)) + async_add_entities(entities) class SkybellCamera(SkybellEntity, Camera): @@ -70,3 +77,25 @@ class SkybellCamera(SkybellEntity, Camera): ) -> bytes | None: """Get the latest camera image.""" return self._device.images[self.entity_description.key] + + +class SkybellActivityCamera(SkybellCamera): + """A camera implementation for latest Skybell activity.""" + + async def handle_async_mjpeg_stream( + self, request: web.Request + ) -> web.StreamResponse: + """Generate an HTTP MJPEG stream from the latest recorded activity.""" + stream = CameraMjpeg(get_ffmpeg_manager(self.hass).binary) + url = await self.coordinator.device.async_get_activity_video_url() + await stream.open_camera(url, extra_cmd="-r 210") + + try: + return await async_aiohttp_proxy_stream( + self.hass, + request, + await stream.get_reader(), + get_ffmpeg_manager(self.hass).ffmpeg_stream_content_type, + ) + finally: + await stream.close() diff --git a/homeassistant/components/skybell/manifest.json b/homeassistant/components/skybell/manifest.json index 6884fe07df2..c0e66aa5462 100644 --- a/homeassistant/components/skybell/manifest.json +++ b/homeassistant/components/skybell/manifest.json @@ -4,6 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/skybell", "requirements": ["aioskybell==22.6.1"], + "dependencies": ["ffmpeg"], "codeowners": ["@tkdrob"], "iot_class": "cloud_polling", "loggers": ["aioskybell"] From a05c539abed0f0c4bafb4ea7c0eb04fd1e347cdb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 17:28:53 -1000 Subject: [PATCH 377/947] Add support for async_remove_config_entry_device to lutron_caseta (#73382) --- .../components/lutron_caseta/__init__.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index dd64ed4ec6f..d915c2a45cb 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import contextlib +from itertools import chain import logging import ssl @@ -48,6 +49,7 @@ from .device_trigger import ( DEVICE_TYPE_SUBTYPE_MAP_TO_LIP, LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP, ) +from .util import serial_to_unique_id _LOGGER = logging.getLogger(__name__) @@ -358,3 +360,41 @@ class LutronCasetaDeviceUpdatableEntity(LutronCasetaDevice): """Update when forcing a refresh of the device.""" self._device = self._smartbridge.get_device_by_id(self.device_id) _LOGGER.debug(self._device) + + +def _id_to_identifier(lutron_id: str) -> None: + """Convert a lutron caseta identifier to a device identifier.""" + return (DOMAIN, lutron_id) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, entry: config_entries.ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove lutron_caseta config entry from a device.""" + bridge: Smartbridge = hass.data[DOMAIN][entry.entry_id][BRIDGE_LEAP] + devices = bridge.get_devices() + buttons = bridge.buttons + occupancy_groups = bridge.occupancy_groups + bridge_device = devices[BRIDGE_DEVICE_ID] + bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) + all_identifiers: set[tuple[str, str]] = { + # Base bridge + _id_to_identifier(bridge_unique_id), + # Motion sensors and occupancy groups + *( + _id_to_identifier( + f"occupancygroup_{bridge_unique_id}_{device['occupancy_group_id']}" + ) + for device in occupancy_groups.values() + ), + # Button devices such as pico remotes and all other devices + *( + _id_to_identifier(device["serial"]) + for device in chain(devices.values(), buttons.values()) + ), + } + return not any( + identifier + for identifier in device_entry.identifiers + if identifier in all_identifiers + ) From ad9e1fe16629299dc5c0c1131509aaacf8109e91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 17:29:44 -1000 Subject: [PATCH 378/947] Fix reload race in yeelight when updating the ip address (#73390) --- .../components/yeelight/config_flow.py | 5 ++- tests/components/yeelight/test_config_flow.py | 31 ++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 8a3a5b41320..440b717fd8c 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -96,7 +96,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.hass.config_entries.async_update_entry( entry, data={**entry.data, CONF_HOST: self._discovered_ip} ) - reload = True + reload = entry.state in ( + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.LOADED, + ) if reload: self.hass.async_create_task( self.hass.config_entries.async_reload(entry.entry_id) diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 80acaa6f10e..1c19a5e7dfd 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -739,7 +739,7 @@ async def test_discovered_zeroconf(hass): async def test_discovery_updates_ip(hass: HomeAssistant): - """Test discovery updtes ip.""" + """Test discovery updates ip.""" config_entry = MockConfigEntry( domain=DOMAIN, data={CONF_HOST: "1.2.2.3"}, unique_id=ID ) @@ -761,6 +761,35 @@ async def test_discovery_updates_ip(hass: HomeAssistant): assert config_entry.data[CONF_HOST] == IP_ADDRESS +async def test_discovery_updates_ip_no_reload_setup_in_progress(hass: HomeAssistant): + """Test discovery updates ip does not reload if setup is an an error state.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "1.2.2.3"}, + unique_id=ID, + state=config_entries.ConfigEntryState.SETUP_ERROR, + ) + config_entry.add_to_hass(hass) + + mocked_bulb = _mocked_bulb() + with patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_setup_entry, _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=ZEROCONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + assert config_entry.data[CONF_HOST] == IP_ADDRESS + assert len(mock_setup_entry.mock_calls) == 0 + + async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant): """Test discovery adds missing ip.""" config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_ID: ID}) From f0a5dbacf88574102fb4f31d2602f9b0ff962369 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 13 Jun 2022 05:48:17 +0200 Subject: [PATCH 379/947] Update pytest to 7.1.2 (#73417) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 5d366a3c350..a3986b8a754 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -24,7 +24,7 @@ pytest-test-groups==1.0.3 pytest-sugar==0.9.4 pytest-timeout==2.1.0 pytest-xdist==2.5.0 -pytest==7.1.1 +pytest==7.1.2 requests_mock==1.9.2 respx==0.19.0 stdlib-list==0.7.0 From 11870092804194af6addce17c8404a0734a30f7b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 12 Jun 2022 22:33:45 -0700 Subject: [PATCH 380/947] Bump aiohue to 4.4.2 (#73420) --- homeassistant/components/hue/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json index d3b492f3b9e..b3dbe4df50a 100644 --- a/homeassistant/components/hue/manifest.json +++ b/homeassistant/components/hue/manifest.json @@ -3,7 +3,7 @@ "name": "Philips Hue", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/hue", - "requirements": ["aiohue==4.4.1"], + "requirements": ["aiohue==4.4.2"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 8e32ca35d62..424a15040a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -169,7 +169,7 @@ aiohomekit==0.7.17 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.4.1 +aiohue==4.4.2 # homeassistant.components.imap aioimaplib==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 541e36cf252..4f1028446c4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -153,7 +153,7 @@ aiohomekit==0.7.17 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==4.4.1 +aiohue==4.4.2 # homeassistant.components.apache_kafka aiokafka==0.6.0 From 7a422774b6c439fbc79c07e459bb3738f04c4baa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 12 Jun 2022 20:05:08 -1000 Subject: [PATCH 381/947] Prevent config entries from being reloaded while they are setting up (#73387) --- homeassistant/config_entries.py | 8 ++++++-- tests/test_config_entries.py | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 14900153ae4..a8b2752d2aa 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -90,7 +90,7 @@ class ConfigEntryState(Enum): """The config entry has not been loaded""" FAILED_UNLOAD = "failed_unload", False """An error occurred when trying to unload the entry""" - SETUP_IN_PROGRESS = "setup_in_progress", True + SETUP_IN_PROGRESS = "setup_in_progress", False """The config entry is setting up.""" _recoverable: bool @@ -104,7 +104,11 @@ class ConfigEntryState(Enum): @property def recoverable(self) -> bool: - """Get if the state is recoverable.""" + """Get if the state is recoverable. + + If the entry is state is recoverable, unloads + and reloads are allowed. + """ return self._recoverable diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index dbbf542d36c..9372a906f71 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -3260,3 +3260,15 @@ async def test_unique_id_update_while_setup_in_progress( assert len(async_reload.mock_calls) == 0 await hass.async_block_till_done() assert entry.state is config_entries.ConfigEntryState.LOADED + + +async def test_disallow_entry_reload_with_setup_in_progresss(hass, manager): + """Test we do not allow reload while the config entry is still setting up.""" + entry = MockConfigEntry( + domain="comp", state=config_entries.ConfigEntryState.SETUP_IN_PROGRESS + ) + entry.add_to_hass(hass) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_reload(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS From a0974e0c7297537149985f93544dd6f8ed8cfded Mon Sep 17 00:00:00 2001 From: Avi Miller Date: Mon, 13 Jun 2022 16:05:41 +1000 Subject: [PATCH 382/947] Refactor LIFX discovery to prevent duplicate discovery response handling (#72213) * Partially revert #70458 and allow duplicate LIFX discoveries Signed-off-by: Avi Miller * Only process one discovery at a time * Revert all LIFX duplicate/inflight discovery checks Also remember LIFX Switches and do as little processing for them as possible. Signed-off-by: Avi Miller * Bump aiolifx version to support the latest LIFX devices LIFX added 22 new product definitions to their public product list at the end of January and those new products are defined in aiolifx v0.8.1, so bump the dependency version. Also switched to testing for relays instead of maintaining a seperate list of switch product IDs. Fixes #72894. Signed-off-by: Avi Miller * Refactor LIFX discovery to better handle duplicate responses Signed-off-by: Avi Miller * Update clear_inflight_discovery with review suggestion Signed-off-by: Avi Miller * Move the existing entity check to before the asyncio lock Signed-off-by: Avi Miller * Bail out of discovery early and if an entity was created Also ensure that the entity always has a unique ID even if the bulb was not successfully discovered. Signed-off-by: Avi Miller Co-authored-by: J. Nick Koston --- homeassistant/components/lifx/light.py | 195 +++++++++++++++++-------- 1 file changed, 137 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index ea9bbeb91a2..28390e5c02a 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from dataclasses import dataclass from datetime import timedelta from functools import partial from ipaddress import IPv4Address @@ -9,6 +10,7 @@ import logging import math import aiolifx as aiolifx_module +from aiolifx.aiolifx import LifxDiscovery, Light import aiolifx_effects as aiolifx_effects_module from awesomeversion import AwesomeVersion import voluptuous as vol @@ -49,7 +51,7 @@ from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_platform import AddEntitiesCallback, EntityPlatform import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -67,9 +69,9 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=10) -DISCOVERY_INTERVAL = 60 +DISCOVERY_INTERVAL = 10 MESSAGE_TIMEOUT = 1 -MESSAGE_RETRIES = 3 +MESSAGE_RETRIES = 8 UNAVAILABLE_GRACE = 90 FIX_MAC_FW = AwesomeVersion("3.70") @@ -252,19 +254,34 @@ def merge_hsbk(base, change): return [b if c is None else c for b, c in zip(base, change)] +@dataclass +class InFlightDiscovery: + """Represent a LIFX device that is being discovered.""" + + device: Light + lock: asyncio.Lock + + class LIFXManager: """Representation of all known LIFX entities.""" - def __init__(self, hass, platform, config_entry, async_add_entities): + def __init__( + self, + hass: HomeAssistant, + platform: EntityPlatform, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + ) -> None: """Initialize the light.""" - self.entities = {} - self.discoveries_inflight = {} + self.entities: dict[str, LIFXLight] = {} + self.switch_devices: list[str] = [] self.hass = hass self.platform = platform self.config_entry = config_entry self.async_add_entities = async_add_entities self.effects_conductor = aiolifx_effects().Conductor(hass.loop) - self.discoveries = [] + self.discoveries: list[LifxDiscovery] = [] + self.discoveries_inflight: dict[str, InFlightDiscovery] = {} self.cleanup_unsub = self.hass.bus.async_listen( EVENT_HOMEASSISTANT_STOP, self.cleanup ) @@ -376,72 +393,128 @@ class LIFXManager: elif service == SERVICE_EFFECT_STOP: await self.effects_conductor.stop(bulbs) - @callback - def register(self, bulb): - """Allow a single in-flight discovery per bulb.""" - if bulb.mac_addr not in self.discoveries_inflight: - self.discoveries_inflight[bulb.mac_addr] = bulb.ip_addr - _LOGGER.debug("Discovered %s (%s)", bulb.ip_addr, bulb.mac_addr) - self.hass.async_create_task(self.register_bulb(bulb)) - else: - _LOGGER.warning("Duplicate LIFX discovery response ignored") + def clear_inflight_discovery(self, inflight: InFlightDiscovery) -> None: + """Clear in-flight discovery.""" + self.discoveries_inflight.pop(inflight.device.mac_addr, None) - async def register_bulb(self, bulb): - """Handle LIFX bulb registration lifecycle.""" + @callback + def register(self, bulb: Light) -> None: + """Allow a single in-flight discovery per bulb.""" + if bulb.mac_addr in self.switch_devices: + _LOGGER.debug( + "Skipping discovered LIFX Switch at %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + return + + # Try to bail out of discovery as early as possible if bulb.mac_addr in self.entities: entity = self.entities[bulb.mac_addr] entity.registered = True _LOGGER.debug("Reconnected to %s", entity.who) - await entity.update_hass() - else: - _LOGGER.debug("Connecting to %s (%s)", bulb.ip_addr, bulb.mac_addr) + return - # Read initial state + if bulb.mac_addr not in self.discoveries_inflight: + inflight = InFlightDiscovery(bulb, asyncio.Lock()) + self.discoveries_inflight[bulb.mac_addr] = inflight + _LOGGER.debug( + "First discovery response received from %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + else: + _LOGGER.debug( + "Duplicate discovery response received from %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + + self.hass.async_create_task( + self._async_handle_discovery(self.discoveries_inflight[bulb.mac_addr]) + ) + + async def _async_handle_discovery(self, inflight: InFlightDiscovery) -> None: + """Handle LIFX bulb registration lifecycle.""" + + # only allow a single discovery process per discovered device + async with inflight.lock: + + # Bail out if an entity was created by a previous discovery while + # this discovery was waiting for the asyncio lock to release. + if inflight.device.mac_addr in self.entities: + self.clear_inflight_discovery(inflight) + entity: LIFXLight = self.entities[inflight.device.mac_addr] + entity.registered = True + _LOGGER.debug("Reconnected to %s", entity.who) + return + + # Determine the product info so that LIFX Switches + # can be skipped. ack = AwaitAioLIFX().wait - # Get the product info first so that LIFX Switches - # can be ignored. - version_resp = await ack(bulb.get_version) - if version_resp and lifx_features(bulb)["relays"]: + if inflight.device.product is None: + if await ack(inflight.device.get_version) is None: + _LOGGER.debug( + "Failed to discover product information for %s (%s)", + inflight.device.ip_addr, + inflight.device.mac_addr, + ) + self.clear_inflight_discovery(inflight) + return + + if lifx_features(inflight.device)["relays"] is True: _LOGGER.debug( - "Not connecting to LIFX Switch %s (%s)", - str(bulb.mac_addr).replace(":", ""), - bulb.ip_addr, + "Skipping discovered LIFX Switch at %s (%s)", + inflight.device.ip_addr, + inflight.device.mac_addr, ) - return False + self.switch_devices.append(inflight.device.mac_addr) + self.clear_inflight_discovery(inflight) + return - color_resp = await ack(bulb.get_color) + await self._async_process_discovery(inflight=inflight) - if color_resp is None or version_resp is None: - _LOGGER.error("Failed to connect to %s", bulb.ip_addr) - bulb.registered = False - if bulb.mac_addr in self.discoveries_inflight: - self.discoveries_inflight.pop(bulb.mac_addr) - else: - bulb.timeout = MESSAGE_TIMEOUT - bulb.retry_count = MESSAGE_RETRIES - bulb.unregister_timeout = UNAVAILABLE_GRACE + async def _async_process_discovery(self, inflight: InFlightDiscovery) -> None: + """Process discovery of a device.""" + bulb = inflight.device + ack = AwaitAioLIFX().wait - if lifx_features(bulb)["multizone"]: - entity = LIFXStrip(bulb, self.effects_conductor) - elif lifx_features(bulb)["color"]: - entity = LIFXColor(bulb, self.effects_conductor) - else: - entity = LIFXWhite(bulb, self.effects_conductor) + bulb.timeout = MESSAGE_TIMEOUT + bulb.retry_count = MESSAGE_RETRIES + bulb.unregister_timeout = UNAVAILABLE_GRACE - _LOGGER.debug("Connected to %s", entity.who) - self.entities[bulb.mac_addr] = entity - self.discoveries_inflight.pop(bulb.mac_addr, None) - self.async_add_entities([entity], True) + # Read initial state + if bulb.color is None: + if await ack(bulb.get_color) is None: + _LOGGER.debug( + "Failed to determine current state of %s (%s)", + bulb.ip_addr, + bulb.mac_addr, + ) + self.clear_inflight_discovery(inflight) + return + + if lifx_features(bulb)["multizone"]: + entity: LIFXLight = LIFXStrip(bulb.mac_addr, bulb, self.effects_conductor) + elif lifx_features(bulb)["color"]: + entity = LIFXColor(bulb.mac_addr, bulb, self.effects_conductor) + else: + entity = LIFXWhite(bulb.mac_addr, bulb, self.effects_conductor) + + self.entities[bulb.mac_addr] = entity + self.async_add_entities([entity], True) + _LOGGER.debug("Entity created for %s", entity.who) + self.clear_inflight_discovery(inflight) @callback - def unregister(self, bulb): - """Disconnect and unregister non-responsive bulbs.""" + def unregister(self, bulb: Light) -> None: + """Mark unresponsive bulbs as unavailable in Home Assistant.""" if bulb.mac_addr in self.entities: entity = self.entities[bulb.mac_addr] - _LOGGER.debug("Disconnected from %s", entity.who) entity.registered = False entity.async_write_ha_state() + _LOGGER.debug("Disconnected from %s", entity.who) @callback def entity_registry_updated(self, event): @@ -506,8 +579,14 @@ class LIFXLight(LightEntity): _attr_supported_features = LightEntityFeature.TRANSITION | LightEntityFeature.EFFECT - def __init__(self, bulb, effects_conductor): + def __init__( + self, + mac_addr: str, + bulb: Light, + effects_conductor: aiolifx_effects_module.Conductor, + ) -> None: """Initialize the light.""" + self.mac_addr = mac_addr self.bulb = bulb self.effects_conductor = effects_conductor self.registered = True @@ -520,10 +599,10 @@ class LIFXLight(LightEntity): self.bulb.host_firmware_version and AwesomeVersion(self.bulb.host_firmware_version) >= FIX_MAC_FW ): - octets = [int(octet, 16) for octet in self.bulb.mac_addr.split(":")] + octets = [int(octet, 16) for octet in self.mac_addr.split(":")] octets[5] = (octets[5] + 1) % 256 return ":".join(f"{octet:02x}" for octet in octets) - return self.bulb.mac_addr + return self.mac_addr @property def device_info(self) -> DeviceInfo: @@ -552,7 +631,7 @@ class LIFXLight(LightEntity): @property def unique_id(self): """Return a unique ID.""" - return self.bulb.mac_addr + return self.mac_addr @property def name(self): @@ -562,7 +641,7 @@ class LIFXLight(LightEntity): @property def who(self): """Return a string identifying the bulb by name and mac.""" - return f"{self.name} ({self.bulb.mac_addr})" + return f"{self.name} ({self.mac_addr})" @property def min_mireds(self): From b261f0fb41f7612c2cd20e688b05ae6f2e047054 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 13 Jun 2022 08:36:46 +0100 Subject: [PATCH 383/947] Use more specific exception and simplify aurora_abb_powerone (#73338) * Use more specific exception for comms timeout * Remove defered uniqueid assigner now yaml has gone Co-authored-by: Dave T --- .../aurora_abb_powerone/__init__.py | 42 +------------------ .../components/aurora_abb_powerone/sensor.py | 20 ++++----- .../aurora_abb_powerone/test_config_flow.py | 4 +- .../aurora_abb_powerone/test_sensor.py | 6 +-- 4 files changed, 14 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 585f3720144..c988121b6bd 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -10,15 +10,13 @@ import logging -from aurorapy.client import AuroraError, AuroraSerialClient +from aurorapy.client import AuroraSerialClient from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, CONF_PORT, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady -from .config_flow import validate_and_connect -from .const import ATTR_SERIAL_NUMBER, DOMAIN +from .const import DOMAIN PLATFORMS = [Platform.SENSOR] @@ -31,42 +29,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: comport = entry.data[CONF_PORT] address = entry.data[CONF_ADDRESS] ser_client = AuroraSerialClient(address, comport, parity="N", timeout=1) - # To handle yaml import attempts in darkness, (re)try connecting only if - # unique_id not yet assigned. - if entry.unique_id is None: - try: - res = await hass.async_add_executor_job( - validate_and_connect, hass, entry.data - ) - except AuroraError as error: - if "No response after" in str(error): - raise ConfigEntryNotReady("No response (could be dark)") from error - _LOGGER.error("Failed to connect to inverter: %s", error) - return False - except OSError as error: - if error.errno == 19: # No such device. - _LOGGER.error("Failed to connect to inverter: no such COM port") - return False - _LOGGER.error("Failed to connect to inverter: %s", error) - return False - else: - # If we got here, the device is now communicating (maybe after - # being in darkness). But there's a small risk that the user has - # configured via the UI since we last attempted the yaml setup, - # which means we'd get a duplicate unique ID. - new_id = res[ATTR_SERIAL_NUMBER] - # Check if this unique_id has already been used - for existing_entry in hass.config_entries.async_entries(DOMAIN): - if existing_entry.unique_id == new_id: - _LOGGER.debug( - "Remove already configured config entry for id %s", new_id - ) - hass.async_create_task( - hass.config_entries.async_remove(entry.entry_id) - ) - return False - hass.config_entries.async_update_entry(entry, unique_id=new_id) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = ser_client hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index c06cd7bc5a7..188f1c789a2 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -5,7 +5,7 @@ from collections.abc import Mapping import logging from typing import Any -from aurorapy.client import AuroraError, AuroraSerialClient +from aurorapy.client import AuroraError, AuroraSerialClient, AuroraTimeoutError from homeassistant.components.sensor import ( SensorDeviceClass, @@ -102,22 +102,16 @@ class AuroraSensor(AuroraEntity, SensorEntity): self._attr_native_value = round(energy_wh / 1000, 2) self._attr_available = True + except AuroraTimeoutError: + self._attr_state = None + self._attr_native_value = None + self._attr_available = False + _LOGGER.debug("No response from inverter (could be dark)") except AuroraError as error: self._attr_state = None self._attr_native_value = None self._attr_available = False - # aurorapy does not have different exceptions (yet) for dealing - # with timeout vs other comms errors. - # This means the (normal) situation of no response during darkness - # raises an exception. - # aurorapy (gitlab) pull request merged 29/5/2019. When >0.2.6 is - # released, this could be modified to : - # except AuroraTimeoutError as e: - # Workaround: look at the text of the exception - if "No response after" in str(error): - _LOGGER.debug("No response from inverter (could be dark)") - else: - raise error + raise error finally: if self._attr_available != self.available_prev: if self._attr_available: diff --git a/tests/components/aurora_abb_powerone/test_config_flow.py b/tests/components/aurora_abb_powerone/test_config_flow.py index e53dcf5ab06..b30d6dc5eeb 100644 --- a/tests/components/aurora_abb_powerone/test_config_flow.py +++ b/tests/components/aurora_abb_powerone/test_config_flow.py @@ -2,7 +2,7 @@ from logging import INFO from unittest.mock import patch -from aurorapy.client import AuroraError +from aurorapy.client import AuroraError, AuroraTimeoutError from serial.tools import list_ports_common from homeassistant import config_entries, data_entry_flow, setup @@ -127,7 +127,7 @@ async def test_form_invalid_com_ports(hass): with patch( "aurorapy.client.AuroraSerialClient.connect", - side_effect=AuroraError("...No response after..."), + side_effect=AuroraTimeoutError("...No response after..."), return_value=None, ): result2 = await hass.config_entries.flow.async_configure( diff --git a/tests/components/aurora_abb_powerone/test_sensor.py b/tests/components/aurora_abb_powerone/test_sensor.py index 0a7b7e33302..f41750ba017 100644 --- a/tests/components/aurora_abb_powerone/test_sensor.py +++ b/tests/components/aurora_abb_powerone/test_sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from unittest.mock import patch -from aurorapy.client import AuroraError +from aurorapy.client import AuroraError, AuroraTimeoutError from homeassistant.components.aurora_abb_powerone.const import ( ATTR_DEVICE_NAME, @@ -126,7 +126,7 @@ async def test_sensor_dark(hass): # sunset with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", - side_effect=AuroraError("No response after 10 seconds"), + side_effect=AuroraTimeoutError("No response after 10 seconds"), ): async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() @@ -144,7 +144,7 @@ async def test_sensor_dark(hass): # sunset with patch("aurorapy.client.AuroraSerialClient.connect", return_value=None), patch( "aurorapy.client.AuroraSerialClient.measure", - side_effect=AuroraError("No response after 10 seconds"), + side_effect=AuroraTimeoutError("No response after 10 seconds"), ): async_fire_time_changed(hass, utcnow + timedelta(seconds=60)) await hass.async_block_till_done() From d9f3e9a71c98483f9ed4313051c340e6919ab82b Mon Sep 17 00:00:00 2001 From: kingy444 Date: Mon, 13 Jun 2022 18:26:35 +1000 Subject: [PATCH 384/947] Add supported_brands to powerview (#73421) --- .../components/hunterdouglas_powerview/manifest.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json index c571056be23..8e2206b2778 100644 --- a/homeassistant/components/hunterdouglas_powerview/manifest.json +++ b/homeassistant/components/hunterdouglas_powerview/manifest.json @@ -17,5 +17,8 @@ ], "zeroconf": ["_powerview._tcp.local."], "iot_class": "local_polling", - "loggers": ["aiopvapi"] + "loggers": ["aiopvapi"], + "supported_brands": { + "luxaflex": "Luxaflex" + } } From ca0a185b32f7b7ea928041da82060a4cf473c96b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jun 2022 11:14:30 +0200 Subject: [PATCH 385/947] Enforce config-flow type hints to get options flow (#72831) * Enforce config-flow type hints to get options flow * Add checks on return_type * Fix tests * Add tests * Add BinOp to test * Update tests/pylint/test_enforce_type_hints.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Update pylint/plugins/hass_enforce_type_hints.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Add TypeHintMatch property * Update pylint/plugins/hass_enforce_type_hints.py Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- pylint/plugins/hass_enforce_type_hints.py | 41 ++++++++++- tests/pylint/test_enforce_type_hints.py | 85 +++++++++++++++++++++++ 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index d8d3c76a028..8194bb72ca5 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -22,6 +22,7 @@ class TypeHintMatch: function_name: str arg_types: dict[int, str] return_type: list[str] | str | None | object + check_return_type_inheritance: bool = False @dataclass @@ -380,6 +381,14 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { ClassTypeHintMatch( base_class="ConfigFlow", matches=[ + TypeHintMatch( + function_name="async_get_options_flow", + arg_types={ + 0: "ConfigEntry", + }, + return_type="OptionsFlow", + check_return_type_inheritance=True, + ), TypeHintMatch( function_name="async_step_dhcp", arg_types={ @@ -504,6 +513,32 @@ def _is_valid_type( return isinstance(node, nodes.Attribute) and node.attrname == expected_type +def _is_valid_return_type(match: TypeHintMatch, node: nodes.NodeNG) -> bool: + if _is_valid_type(match.return_type, node): + return True + + if isinstance(node, nodes.BinOp): + return _is_valid_return_type(match, node.left) and _is_valid_return_type( + match, node.right + ) + + if ( + match.check_return_type_inheritance + and isinstance(match.return_type, str) + and isinstance(node, nodes.Name) + ): + ancestor: nodes.ClassDef + for infer_node in node.infer(): + if isinstance(infer_node, nodes.ClassDef): + if infer_node.name == match.return_type: + return True + for ancestor in infer_node.ancestors(): + if ancestor.name == match.return_type: + return True + + return False + + def _get_all_annotations(node: nodes.FunctionDef) -> list[nodes.NodeNG | None]: args = node.args annotations: list[nodes.NodeNG | None] = ( @@ -619,8 +654,10 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] ) # Check the return type. - if not _is_valid_type(return_type := match.return_type, node.returns): - self.add_message("hass-return-type", node=node, args=return_type or "None") + if not _is_valid_return_type(match, node.returns): + self.add_message( + "hass-return-type", node=node, args=match.return_type or "None" + ) def register(linter: PyLinter) -> None: diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index fa3d32dcb04..86b06a894d0 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -349,3 +349,88 @@ def test_valid_config_flow_step( with assert_no_messages(linter): type_hint_checker.visit_classdef(class_node) + + +def test_invalid_config_flow_async_get_options_flow( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure invalid hints are rejected for ConfigFlow async_get_options_flow.""" + class_node, func_node, arg_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class AxisOptionsFlow(): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + def async_get_options_flow( #@ + config_entry #@ + ) -> AxisOptionsFlow: + return AxisOptionsFlow(config_entry) + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=arg_node, + args=(1, "ConfigEntry"), + line=12, + col_offset=8, + end_line=12, + end_col_offset=20, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="OptionsFlow", + line=11, + col_offset=4, + end_line=11, + end_col_offset=30, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +def test_valid_config_flow_async_get_options_flow( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Ensure valid hints are accepted for ConfigFlow async_get_options_flow.""" + class_node = astroid.extract_node( + """ + class ConfigFlow(): + pass + + class OptionsFlow(): + pass + + class AxisOptionsFlow(OptionsFlow): + pass + + class OtherOptionsFlow(OptionsFlow): + pass + + class AxisFlowHandler( #@ + ConfigFlow, domain=AXIS_DOMAIN + ): + def async_get_options_flow( + config_entry: ConfigEntry + ) -> AxisOptionsFlow | OtherOptionsFlow | OptionsFlow: + if self.use_other: + return OtherOptionsFlow(config_entry) + return AxisOptionsFlow(config_entry) + + """, + "homeassistant.components.pylint_test.config_flow", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) From b58970065103cbc2d9937305f986833b33ce1d80 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jun 2022 13:17:59 +0200 Subject: [PATCH 386/947] Add async_get_options_flow type hints (a-m) (#73430) --- homeassistant/components/aemet/config_flow.py | 6 +++++- homeassistant/components/alarmdecoder/config_flow.py | 6 +++++- homeassistant/components/apple_tv/config_flow.py | 6 ++++-- homeassistant/components/aurora/config_flow.py | 8 ++++++-- homeassistant/components/azure_event_hub/config_flow.py | 6 ++++-- homeassistant/components/blink/config_flow.py | 8 ++++++-- homeassistant/components/coinbase/config_flow.py | 6 +++++- homeassistant/components/control4/config_flow.py | 6 +++++- homeassistant/components/demo/config_flow.py | 8 ++++++-- homeassistant/components/dexcom/config_flow.py | 6 +++++- homeassistant/components/doorbird/config_flow.py | 6 +++++- homeassistant/components/ezviz/config_flow.py | 8 +++++--- homeassistant/components/forked_daapd/config_flow.py | 6 ++++-- homeassistant/components/glances/config_flow.py | 8 ++++++-- homeassistant/components/harmony/config_flow.py | 6 +++++- homeassistant/components/hive/config_flow.py | 7 +++++-- homeassistant/components/honeywell/config_flow.py | 6 +++++- homeassistant/components/insteon/config_flow.py | 6 ++++-- .../components/islamic_prayer_times/config_flow.py | 8 ++++++-- homeassistant/components/kmtronic/config_flow.py | 8 ++++++-- homeassistant/components/konnected/config_flow.py | 6 +++++- homeassistant/components/litejet/config_flow.py | 6 ++++-- homeassistant/components/meteo_france/config_flow.py | 8 ++++++-- homeassistant/components/mikrotik/config_flow.py | 8 ++++++-- homeassistant/components/monoprice/config_flow.py | 8 ++++++-- 25 files changed, 129 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/aemet/config_flow.py b/homeassistant/components/aemet/config_flow.py index 6c97ca98cb8..1188df5b94f 100644 --- a/homeassistant/components/aemet/config_flow.py +++ b/homeassistant/components/aemet/config_flow.py @@ -1,4 +1,6 @@ """Config flow for AEMET OpenData.""" +from __future__ import annotations + from aemet_opendata import AEMET import voluptuous as vol @@ -50,7 +52,9 @@ class AemetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/alarmdecoder/config_flow.py b/homeassistant/components/alarmdecoder/config_flow.py index 37ff5b97994..52d17e407b7 100644 --- a/homeassistant/components/alarmdecoder/config_flow.py +++ b/homeassistant/components/alarmdecoder/config_flow.py @@ -1,4 +1,6 @@ """Config flow for AlarmDecoder.""" +from __future__ import annotations + import logging from adext import AdExt @@ -58,7 +60,9 @@ class AlarmDecoderFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> AlarmDecoderOptionsFlowHandler: """Get the options flow for AlarmDecoder.""" return AlarmDecoderOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 8e8e6006895..1f3133d7e16 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -71,7 +71,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> AppleTVOptionsFlow: """Get options flow for this handler.""" return AppleTVOptionsFlow(config_entry) @@ -523,7 +525,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class AppleTVOptionsFlow(config_entries.OptionsFlow): """Handle Apple TV options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Apple TV options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/aurora/config_flow.py b/homeassistant/components/aurora/config_flow.py index fd9ebbf424c..a2331c19ec6 100644 --- a/homeassistant/components/aurora/config_flow.py +++ b/homeassistant/components/aurora/config_flow.py @@ -1,4 +1,6 @@ """Config flow for SpaceX Launches and Starman.""" +from __future__ import annotations + import logging from aiohttp import ClientError @@ -22,7 +24,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -82,7 +86,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options flow changes.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/azure_event_hub/config_flow.py b/homeassistant/components/azure_event_hub/config_flow.py index a0dded5f487..26980231dc1 100644 --- a/homeassistant/components/azure_event_hub/config_flow.py +++ b/homeassistant/components/azure_event_hub/config_flow.py @@ -79,7 +79,9 @@ class AEHConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> AEHOptionsFlowHandler: """Get the options flow for this handler.""" return AEHOptionsFlowHandler(config_entry) @@ -170,7 +172,7 @@ class AEHConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class AEHOptionsFlowHandler(config_entries.OptionsFlow): """Handle azure event hub options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize AEH options flow.""" self.config_entry = config_entry self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index a4bee490fb3..b62c7414f46 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure Blink.""" +from __future__ import annotations + import logging from blinkpy.auth import Auth, LoginError, TokenRefreshFailed @@ -49,7 +51,9 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> BlinkOptionsFlowHandler: """Get options flow for this handler.""" return BlinkOptionsFlowHandler(config_entry) @@ -129,7 +133,7 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class BlinkOptionsFlowHandler(config_entries.OptionsFlow): """Handle Blink options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Blink options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/coinbase/config_flow.py b/homeassistant/components/coinbase/config_flow.py index 0687bd3f305..6582acc6549 100644 --- a/homeassistant/components/coinbase/config_flow.py +++ b/homeassistant/components/coinbase/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Coinbase integration.""" +from __future__ import annotations + import logging from coinbase.wallet.client import Client @@ -160,7 +162,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/control4/config_flow.py b/homeassistant/components/control4/config_flow.py index 2cf1ca845f7..05fd8a2b7c8 100644 --- a/homeassistant/components/control4/config_flow.py +++ b/homeassistant/components/control4/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Control4 integration.""" +from __future__ import annotations + from asyncio import TimeoutError as asyncioTimeoutError import logging @@ -136,7 +138,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/demo/config_flow.py b/homeassistant/components/demo/config_flow.py index f99693bfeb2..e389574c658 100644 --- a/homeassistant/components/demo/config_flow.py +++ b/homeassistant/components/demo/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure demo component.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -21,7 +23,9 @@ class DemoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -33,7 +37,7 @@ class DemoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/dexcom/config_flow.py b/homeassistant/components/dexcom/config_flow.py index 063d14549db..6ccb09881af 100644 --- a/homeassistant/components/dexcom/config_flow.py +++ b/homeassistant/components/dexcom/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Dexcom integration.""" +from __future__ import annotations + from pydexcom import AccountError, Dexcom, SessionError import voluptuous as vol @@ -53,7 +55,9 @@ class DexcomConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> DexcomOptionsFlowHandler: """Get the options flow for this handler.""" return DexcomOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index cc882b0ed50..678340c0259 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -1,4 +1,6 @@ """Config flow for DoorBird integration.""" +from __future__ import annotations + from http import HTTPStatus from ipaddress import ip_address import logging @@ -144,7 +146,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/ezviz/config_flow.py b/homeassistant/components/ezviz/config_flow.py index 36cf2ac456e..6c334291ee5 100644 --- a/homeassistant/components/ezviz/config_flow.py +++ b/homeassistant/components/ezviz/config_flow.py @@ -1,4 +1,6 @@ """Config flow for ezviz.""" +from __future__ import annotations + import logging from pyezviz.client import EzvizClient @@ -12,7 +14,7 @@ from pyezviz.exceptions import ( from pyezviz.test_cam_rtsp import TestRTSPAuth import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import ( CONF_CUSTOMIZE, CONF_IP_ADDRESS, @@ -164,7 +166,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> EzvizOptionsFlowHandler: """Get the options flow for this handler.""" return EzvizOptionsFlowHandler(config_entry) @@ -311,7 +313,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN): class EzvizOptionsFlowHandler(OptionsFlow): """Handle Ezviz client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/forked_daapd/config_flow.py b/homeassistant/components/forked_daapd/config_flow.py index e3cf6fc7c1d..f9282dfc464 100644 --- a/homeassistant/components/forked_daapd/config_flow.py +++ b/homeassistant/components/forked_daapd/config_flow.py @@ -46,7 +46,7 @@ TEST_CONNECTION_ERROR_DICT = { class ForkedDaapdOptionsFlowHandler(config_entries.OptionsFlow): """Handle a forked-daapd options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry @@ -110,7 +110,9 @@ class ForkedDaapdFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> ForkedDaapdOptionsFlowHandler: """Return options flow handler.""" return ForkedDaapdOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 72bfa6dd917..a4a345116eb 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Glances.""" +from __future__ import annotations + import glances_api import voluptuous as vol @@ -59,7 +61,9 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> GlancesOptionsFlowHandler: """Get the options flow for this handler.""" return GlancesOptionsFlowHandler(config_entry) @@ -86,7 +90,7 @@ class GlancesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class GlancesOptionsFlowHandler(config_entries.OptionsFlow): """Handle Glances client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Glances options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/harmony/config_flow.py b/homeassistant/components/harmony/config_flow.py index 4dca2192c6b..16101f18cff 100644 --- a/homeassistant/components/harmony/config_flow.py +++ b/homeassistant/components/harmony/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Logitech Harmony Hub integration.""" +from __future__ import annotations + import asyncio import logging from urllib.parse import urlparse @@ -137,7 +139,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 90c78aefcbd..16d83dc311d 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -1,4 +1,5 @@ """Config Flow for Hive.""" +from __future__ import annotations from apyhiveapi import Auth from apyhiveapi.helper.hive_exceptions import ( @@ -130,7 +131,9 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> HiveOptionsFlowHandler: """Hive options callback.""" return HiveOptionsFlowHandler(config_entry) @@ -138,7 +141,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class HiveOptionsFlowHandler(config_entries.OptionsFlow): """Config flow options for Hive.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Hive options flow.""" self.hive = None self.config_entry = config_entry diff --git a/homeassistant/components/honeywell/config_flow.py b/homeassistant/components/honeywell/config_flow.py index e6fdd9b54bd..7f7d7d7281a 100644 --- a/homeassistant/components/honeywell/config_flow.py +++ b/homeassistant/components/honeywell/config_flow.py @@ -1,4 +1,6 @@ """Config flow to configure the honeywell integration.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -52,7 +54,9 @@ class HoneywellConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> HoneywellOptionsFlowHandler: """Options callback for Honeywell.""" return HoneywellOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index be68a66b70a..d9261a65c32 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -119,7 +119,9 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> InsteonOptionsFlowHandler: """Define the config flow to handle options.""" return InsteonOptionsFlowHandler(config_entry) @@ -234,7 +236,7 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class InsteonOptionsFlowHandler(config_entries.OptionsFlow): """Handle an Insteon options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Init the InsteonOptionsFlowHandler class.""" self.config_entry = config_entry diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 9963423131c..3379af3860f 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Islamic Prayer Times integration.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -14,7 +16,9 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> IslamicPrayerOptionsFlowHandler: """Get the options flow for this handler.""" return IslamicPrayerOptionsFlowHandler(config_entry) @@ -36,7 +40,7 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): """Handle Islamic Prayer client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/kmtronic/config_flow.py b/homeassistant/components/kmtronic/config_flow.py index 9c7d48a3de9..8a00a03e673 100644 --- a/homeassistant/components/kmtronic/config_flow.py +++ b/homeassistant/components/kmtronic/config_flow.py @@ -1,4 +1,6 @@ """Config flow for kmtronic integration.""" +from __future__ import annotations + import logging import aiohttp @@ -52,7 +54,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> KMTronicOptionsFlow: """Get the options flow for this handler.""" return KMTronicOptionsFlow(config_entry) @@ -88,7 +92,7 @@ class InvalidAuth(exceptions.HomeAssistantError): class KMTronicOptionsFlow(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index b6f80035dbe..94a58227c56 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -1,4 +1,6 @@ """Config flow for konnected.io integration.""" +from __future__ import annotations + import asyncio import copy import logging @@ -373,7 +375,9 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Return the Options Flow.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/litejet/config_flow.py b/homeassistant/components/litejet/config_flow.py index df20337a816..e14eda1b745 100644 --- a/homeassistant/components/litejet/config_flow.py +++ b/homeassistant/components/litejet/config_flow.py @@ -19,7 +19,7 @@ from .const import CONF_DEFAULT_TRANSITION, DOMAIN class LiteJetOptionsFlow(config_entries.OptionsFlow): """Handle LiteJet options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize LiteJet options flow.""" self.config_entry = config_entry @@ -85,6 +85,8 @@ class LiteJetConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> LiteJetOptionsFlow: """Get the options flow for this handler.""" return LiteJetOptionsFlow(config_entry) diff --git a/homeassistant/components/meteo_france/config_flow.py b/homeassistant/components/meteo_france/config_flow.py index 26e2ac1bda2..d05c63ef684 100644 --- a/homeassistant/components/meteo_france/config_flow.py +++ b/homeassistant/components/meteo_france/config_flow.py @@ -1,11 +1,13 @@ """Config flow to configure the Meteo-France integration.""" +from __future__ import annotations + import logging from meteofrance_api.client import MeteoFranceClient import voluptuous as vol from homeassistant import config_entries -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE from homeassistant.core import callback @@ -25,7 +27,9 @@ class MeteoFranceFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> MeteoFranceOptionsFlowHandler: """Get the options flow for this handler.""" return MeteoFranceOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/mikrotik/config_flow.py b/homeassistant/components/mikrotik/config_flow.py index 11117d22842..36b65b6f2ba 100644 --- a/homeassistant/components/mikrotik/config_flow.py +++ b/homeassistant/components/mikrotik/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Mikrotik.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -32,7 +34,9 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MikrotikOptionsFlowHandler: """Get the options flow for this handler.""" return MikrotikOptionsFlowHandler(config_entry) @@ -78,7 +82,7 @@ class MikrotikFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class MikrotikOptionsFlowHandler(config_entries.OptionsFlow): """Handle Mikrotik options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Mikrotik options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/monoprice/config_flow.py b/homeassistant/components/monoprice/config_flow.py index 1261832c371..4065b003ba3 100644 --- a/homeassistant/components/monoprice/config_flow.py +++ b/homeassistant/components/monoprice/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Monoprice 6-Zone Amplifier integration.""" +from __future__ import annotations + import logging from pymonoprice import get_async_monoprice @@ -90,7 +92,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MonopriceOptionsFlowHandler: """Define the config flow to handle options.""" return MonopriceOptionsFlowHandler(config_entry) @@ -110,7 +114,7 @@ def _key_for_source(index, source, previous_sources): class MonopriceOptionsFlowHandler(config_entries.OptionsFlow): """Handle a Monoprice options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry From 42ed0fd47bd6f10e0a47eb12e764501d4d2e5706 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jun 2022 13:30:41 +0200 Subject: [PATCH 387/947] Add async_get_options_flow type hints (n-z) (#73431) --- homeassistant/components/omnilogic/config_flow.py | 8 ++++++-- homeassistant/components/onvif/config_flow.py | 6 ++++-- homeassistant/components/opentherm_gw/config_flow.py | 8 ++++++-- homeassistant/components/openweathermap/config_flow.py | 8 ++++++-- homeassistant/components/plaato/config_flow.py | 4 +++- homeassistant/components/plex/config_flow.py | 8 ++++++-- .../components/pvpc_hourly_pricing/config_flow.py | 8 ++++++-- homeassistant/components/rachio/config_flow.py | 6 +++++- homeassistant/components/risco/config_flow.py | 8 ++++++-- homeassistant/components/roomba/config_flow.py | 7 +++++-- homeassistant/components/screenlogic/config_flow.py | 6 +++++- homeassistant/components/sia/config_flow.py | 6 ++++-- homeassistant/components/somfy_mylink/config_flow.py | 6 +++++- homeassistant/components/subaru/config_flow.py | 6 +++++- homeassistant/components/tado/config_flow.py | 6 +++++- homeassistant/components/totalconnect/config_flow.py | 8 ++++++-- homeassistant/components/transmission/config_flow.py | 8 ++++++-- homeassistant/components/wiffi/config_flow.py | 8 ++++++-- homeassistant/components/ws66i/config_flow.py | 8 ++++++-- homeassistant/components/yeelight/config_flow.py | 8 +++++--- 20 files changed, 106 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/omnilogic/config_flow.py b/homeassistant/components/omnilogic/config_flow.py index d5239760fcc..1635eaa7558 100644 --- a/homeassistant/components/omnilogic/config_flow.py +++ b/homeassistant/components/omnilogic/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Omnilogic integration.""" +from __future__ import annotations + import logging from omnilogic import LoginException, OmniLogic, OmniLogicException @@ -21,7 +23,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -71,7 +75,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle Omnilogic client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index 1ee0be18467..d7fb46079d9 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -76,7 +76,9 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OnvifOptionsFlowHandler: """Get the options flow for this handler.""" return OnvifOptionsFlowHandler(config_entry) @@ -262,7 +264,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class OnvifOptionsFlowHandler(config_entries.OptionsFlow): """Handle ONVIF options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize ONVIF options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) diff --git a/homeassistant/components/opentherm_gw/config_flow.py b/homeassistant/components/opentherm_gw/config_flow.py index 7c3bc8f8f6b..1d66d6e2069 100644 --- a/homeassistant/components/opentherm_gw/config_flow.py +++ b/homeassistant/components/opentherm_gw/config_flow.py @@ -1,4 +1,6 @@ """OpenTherm Gateway config flow.""" +from __future__ import annotations + import asyncio import pyotgw @@ -34,7 +36,9 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OpenThermGwOptionsFlow: """Get the options flow for this handler.""" return OpenThermGwOptionsFlow(config_entry) @@ -111,7 +115,7 @@ class OpenThermGwConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OpenThermGwOptionsFlow(config_entries.OptionsFlow): """Handle opentherm_gw options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize the options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/openweathermap/config_flow.py b/homeassistant/components/openweathermap/config_flow.py index 0b7a3a1a25f..612965bdb2f 100644 --- a/homeassistant/components/openweathermap/config_flow.py +++ b/homeassistant/components/openweathermap/config_flow.py @@ -1,4 +1,6 @@ """Config flow for OpenWeatherMap.""" +from __future__ import annotations + from pyowm import OWM from pyowm.commons.exceptions import APIRequestError, UnauthorizedError import voluptuous as vol @@ -33,7 +35,9 @@ class OpenWeatherMapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OpenWeatherMapOptionsFlow: """Get the options flow for this handler.""" return OpenWeatherMapOptionsFlow(config_entry) @@ -89,7 +93,7 @@ class OpenWeatherMapConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OpenWeatherMapOptionsFlow(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/plaato/config_flow.py b/homeassistant/components/plaato/config_flow.py index dc9533c4be2..637122b1d9c 100644 --- a/homeassistant/components/plaato/config_flow.py +++ b/homeassistant/components/plaato/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Plaato.""" +from __future__ import annotations + from pyplaato.plaato import PlaatoDeviceType import voluptuous as vol @@ -161,7 +163,7 @@ class PlaatoConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> PlaatoOptionsFlowHandler: """Get the options flow for this handler.""" return PlaatoOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 8da67dafc3d..3489e41364e 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Plex.""" +from __future__ import annotations + import copy import logging @@ -85,7 +87,9 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> PlexOptionsFlowHandler: """Get the options flow for this handler.""" return PlexOptionsFlowHandler(config_entry) @@ -334,7 +338,7 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class PlexOptionsFlowHandler(config_entries.OptionsFlow): """Handle Plex options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Plex options flow.""" self.options = copy.deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] diff --git a/homeassistant/components/pvpc_hourly_pricing/config_flow.py b/homeassistant/components/pvpc_hourly_pricing/config_flow.py index 76694d570b5..f5aeb951d33 100644 --- a/homeassistant/components/pvpc_hourly_pricing/config_flow.py +++ b/homeassistant/components/pvpc_hourly_pricing/config_flow.py @@ -1,4 +1,6 @@ """Config flow for pvpc_hourly_pricing.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -15,7 +17,9 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> PVPCOptionsFlowHandler: """Get the options flow for this handler.""" return PVPCOptionsFlowHandler(config_entry) @@ -36,7 +40,7 @@ class TariffSelectorConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class PVPCOptionsFlowHandler(config_entries.OptionsFlow): """Handle PVPC options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/rachio/config_flow.py b/homeassistant/components/rachio/config_flow.py index 9e93dba065e..00f31003ba6 100644 --- a/homeassistant/components/rachio/config_flow.py +++ b/homeassistant/components/rachio/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Rachio integration.""" +from __future__ import annotations + from http import HTTPStatus import logging @@ -92,7 +94,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/risco/config_flow.py b/homeassistant/components/risco/config_flow.py index c20aa2af287..e2e139a19e0 100644 --- a/homeassistant/components/risco/config_flow.py +++ b/homeassistant/components/risco/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Risco integration.""" +from __future__ import annotations + import logging from pyrisco import CannotConnectError, RiscoAPI, UnauthorizedError @@ -67,7 +69,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> RiscoOptionsFlowHandler: """Define the config flow to handle options.""" return RiscoOptionsFlowHandler(config_entry) @@ -98,7 +102,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class RiscoOptionsFlowHandler(config_entries.OptionsFlow): """Handle a Risco options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry self._data = {**DEFAULT_OPTIONS, **config_entry.options} diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 7aee875308b..0a1c51ca38c 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -1,4 +1,5 @@ """Config flow to configure roomba component.""" +from __future__ import annotations import asyncio from functools import partial @@ -78,7 +79,9 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) @@ -267,7 +270,7 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 1aeedfb421d..2b845d453df 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -1,4 +1,6 @@ """Config flow for ScreenLogic.""" +from __future__ import annotations + import logging from screenlogicpy import ScreenLogicError, discovery @@ -69,7 +71,9 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> ScreenLogicOptionsFlowHandler: """Get the options flow for ScreenLogic.""" return ScreenLogicOptionsFlowHandler(config_entry) diff --git a/homeassistant/components/sia/config_flow.py b/homeassistant/components/sia/config_flow.py index a2f7bc744e3..df03882e995 100644 --- a/homeassistant/components/sia/config_flow.py +++ b/homeassistant/components/sia/config_flow.py @@ -94,7 +94,9 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> SIAOptionsFlowHandler: """Get the options flow for this handler.""" return SIAOptionsFlowHandler(config_entry) @@ -170,7 +172,7 @@ class SIAConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class SIAOptionsFlowHandler(config_entries.OptionsFlow): """Handle SIA options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize SIA options flow.""" self.config_entry = config_entry self.options = deepcopy(dict(config_entry.options)) diff --git a/homeassistant/components/somfy_mylink/config_flow.py b/homeassistant/components/somfy_mylink/config_flow.py index 768d12da45b..de38ac271ce 100644 --- a/homeassistant/components/somfy_mylink/config_flow.py +++ b/homeassistant/components/somfy_mylink/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Somfy MyLink integration.""" +from __future__ import annotations + import asyncio from copy import deepcopy import logging @@ -110,7 +112,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/subaru/config_flow.py b/homeassistant/components/subaru/config_flow.py index 788b6f04fd5..79c412c8f85 100644 --- a/homeassistant/components/subaru/config_flow.py +++ b/homeassistant/components/subaru/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Subaru integration.""" +from __future__ import annotations + from datetime import datetime import logging @@ -83,7 +85,9 @@ class SubaruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py index a03b370d0a3..ec195573203 100644 --- a/homeassistant/components/tado/config_flow.py +++ b/homeassistant/components/tado/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Tado integration.""" +from __future__ import annotations + import logging from PyTado.interface import Tado @@ -112,7 +114,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 49e60b5b46e..057328cffb0 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -1,4 +1,6 @@ """Config flow for the Total Connect component.""" +from __future__ import annotations + from total_connect_client.client import TotalConnectClient from total_connect_client.exceptions import AuthenticationError import voluptuous as vol @@ -166,7 +168,9 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> TotalConnectOptionsFlowHandler: """Get options flow.""" return TotalConnectOptionsFlowHandler(config_entry) @@ -174,7 +178,7 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class TotalConnectOptionsFlowHandler(config_entries.OptionsFlow): """TotalConnect options flow handler.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index ce62475b2eb..b57279ebbbb 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Transmission Bittorent Client.""" +from __future__ import annotations + import voluptuous as vol from homeassistant import config_entries @@ -44,7 +46,9 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> TransmissionOptionsFlowHandler: """Get the options flow for this handler.""" return TransmissionOptionsFlowHandler(config_entry) @@ -87,7 +91,7 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class TransmissionOptionsFlowHandler(config_entries.OptionsFlow): """Handle Transmission client options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Transmission options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/wiffi/config_flow.py b/homeassistant/components/wiffi/config_flow.py index 5087915181e..d6da03c2134 100644 --- a/homeassistant/components/wiffi/config_flow.py +++ b/homeassistant/components/wiffi/config_flow.py @@ -2,6 +2,8 @@ Used by UI to setup a wiffi integration. """ +from __future__ import annotations + import errno import voluptuous as vol @@ -21,7 +23,9 @@ class WiffiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Create Wiffi server setup option flow.""" return OptionsFlowHandler(config_entry) @@ -66,7 +70,7 @@ class WiffiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Wiffi server setup option flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize options flow.""" self.config_entry = config_entry diff --git a/homeassistant/components/ws66i/config_flow.py b/homeassistant/components/ws66i/config_flow.py index b84872da036..a7deb74eb3e 100644 --- a/homeassistant/components/ws66i/config_flow.py +++ b/homeassistant/components/ws66i/config_flow.py @@ -1,4 +1,6 @@ """Config flow for WS66i 6-Zone Amplifier integration.""" +from __future__ import annotations + import logging from typing import Any @@ -114,7 +116,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @core.callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> Ws66iOptionsFlowHandler: """Define the config flow to handle options.""" return Ws66iOptionsFlowHandler(config_entry) @@ -131,7 +135,7 @@ def _key_for_source(index, source, previous_sources): class Ws66iOptionsFlowHandler(config_entries.OptionsFlow): """Handle a WS66i options flow.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize.""" self.config_entry = config_entry diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 440b717fd8c..5a457b7da95 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -1,4 +1,6 @@ """Config flow for Yeelight integration.""" +from __future__ import annotations + import asyncio import logging from urllib.parse import urlparse @@ -10,7 +12,7 @@ from yeelight.main import get_known_models from homeassistant import config_entries, exceptions from homeassistant.components import dhcp, ssdp, zeroconf -from homeassistant.config_entries import ConfigEntryState +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -46,7 +48,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlowHandler: """Return the options flow.""" return OptionsFlowHandler(config_entry) @@ -276,7 +278,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Handle a option flow for Yeelight.""" - def __init__(self, config_entry): + def __init__(self, config_entry: ConfigEntry) -> None: """Initialize the option flow.""" self._config_entry = config_entry From f846cd033fcbd7f4a155d022e24e2f9a14b72b76 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jun 2022 13:35:50 +0200 Subject: [PATCH 388/947] Add async_get_options_flow type hints (mqtt) (#73434) --- homeassistant/components/mqtt/config_flow.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 822ae712573..a8d0957921d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -1,4 +1,6 @@ """Config flow for MQTT.""" +from __future__ import annotations + from collections import OrderedDict import queue @@ -15,6 +17,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, ) +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .client import MqttClientSetup @@ -45,7 +48,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): _hassio_discovery = None @staticmethod - def async_get_options_flow(config_entry): + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> MQTTOptionsFlowHandler: """Get the options flow for this handler.""" return MQTTOptionsFlowHandler(config_entry) @@ -136,10 +142,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class MQTTOptionsFlowHandler(config_entries.OptionsFlow): """Handle MQTT options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize MQTT options flow.""" self.config_entry = config_entry - self.broker_config = {} + self.broker_config: dict[str, str | int] = {} self.options = dict(config_entry.options) async def async_step_init(self, user_input=None): From 48e3d68b531dfd49107b850368932e9d0be1241c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 13 Jun 2022 13:38:53 +0200 Subject: [PATCH 389/947] Clean up MQTT platform entry setup at discovery (#72371) * Setup MQTT discovery with entry setup * Wait for entry setup in test * flake --- homeassistant/components/mqtt/__init__.py | 40 +++++++++++++--------- homeassistant/components/mqtt/discovery.py | 24 ------------- tests/components/mqtt/test_init.py | 5 ++- 3 files changed, 25 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index f9a6ebd025f..417d1400758 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -236,9 +236,7 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await _async_setup_discovery(hass, mqtt_client.conf, entry) -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Load a config entry.""" # Merge basic configuration, and add missing defaults for basic options _merge_basic_config(hass, entry, hass.data.get(DATA_MQTT_CONFIG, {})) @@ -386,25 +384,33 @@ async def async_setup_entry( # noqa: C901 hass.data[DATA_MQTT_UPDATED_CONFIG] = config_yaml.get(DOMAIN, {}) async_dispatcher_send(hass, MQTT_RELOADED) - async def async_forward_entry_setup(): - """Forward the config entry setup to the platforms.""" - async with hass.data[DATA_CONFIG_ENTRY_LOCK]: - for component in PLATFORMS: - config_entries_key = f"{component}.mqtt" - if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: - hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) - await hass.config_entries.async_forward_entry_setup( - entry, component - ) + async def async_forward_entry_setup_and_setup_discovery(config_entry): + """Forward the config entry setup to the platforms and set up discovery.""" + # Local import to avoid circular dependencies + # pylint: disable-next=import-outside-toplevel + from . import device_automation, tag + + await asyncio.gather( + *( + [ + device_automation.async_setup_entry(hass, config_entry), + tag.async_setup_entry(hass, config_entry), + ] + + [ + hass.config_entries.async_forward_entry_setup(entry, component) + for component in PLATFORMS + ] + ) + ) + # Setup discovery + if conf.get(CONF_DISCOVERY): + await _async_setup_discovery(hass, conf, entry) # Setup reload service after all platforms have loaded entry.async_on_unload( hass.bus.async_listen("event_mqtt_reloaded", _async_reload_platforms) ) - hass.async_create_task(async_forward_entry_setup()) - - if conf.get(CONF_DISCOVERY): - await _async_setup_discovery(hass, conf, entry) + hass.async_create_task(async_forward_entry_setup_and_setup_discovery(entry)) if DATA_MQTT_RELOAD_NEEDED in hass.data: hass.data.pop(DATA_MQTT_RELOAD_NEEDED) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 3bbf8ef61ad..04dd2d7917f 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -27,8 +27,6 @@ from .const import ( ATTR_DISCOVERY_TOPIC, CONF_AVAILABILITY, CONF_TOPIC, - CONFIG_ENTRY_IS_SETUP, - DATA_CONFIG_ENTRY_LOCK, DOMAIN, ) @@ -227,28 +225,6 @@ async def async_start( # noqa: C901 # Add component _LOGGER.info("Found new component: %s %s", component, discovery_id) hass.data[ALREADY_DISCOVERED][discovery_hash] = None - - config_entries_key = f"{component}.mqtt" - async with hass.data[DATA_CONFIG_ENTRY_LOCK]: - if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: - if component == "device_automation": - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import device_automation - - await device_automation.async_setup_entry(hass, config_entry) - elif component == "tag": - # Local import to avoid circular dependencies - # pylint: disable-next=import-outside-toplevel - from . import tag - - await tag.async_setup_entry(hass, config_entry) - else: - await hass.config_entries.async_forward_entry_setup( - config_entry, component - ) - hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key) - async_dispatcher_send( hass, MQTT_DISCOVERY_NEW.format(component, "mqtt"), payload ) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 861b50d2f71..8159933a9a4 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1780,13 +1780,12 @@ async def test_setup_entry_with_config_override( # mqtt present in yaml config assert await async_setup_component(hass, mqtt.DOMAIN, {}) await hass.async_block_till_done() - await mqtt_mock_entry_with_yaml_config() # User sets up a config entry entry = MockConfigEntry(domain=mqtt.DOMAIN, data={mqtt.CONF_BROKER: "test-broker"}) entry.add_to_hass(hass) - with patch("homeassistant.components.mqtt.PLATFORMS", []): - assert await hass.config_entries.async_setup(entry.entry_id) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() # Discover a device to verify the entry was setup correctly async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) From 657e7f9a4c3d05bf81ddc873ca7a30c1e711e175 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 13 Jun 2022 13:44:12 +0200 Subject: [PATCH 390/947] Simplify MQTT test for setup manual mqtt item from yaml (#72916) simplify test setup manual mqtt item from yaml --- .../mqtt/test_alarm_control_panel.py | 6 ++-- tests/components/mqtt/test_binary_sensor.py | 6 ++-- tests/components/mqtt/test_button.py | 6 ++-- tests/components/mqtt/test_camera.py | 6 ++-- tests/components/mqtt/test_climate.py | 6 ++-- tests/components/mqtt/test_common.py | 8 +---- tests/components/mqtt/test_cover.py | 6 ++-- tests/components/mqtt/test_device_tracker.py | 8 ++--- tests/components/mqtt/test_fan.py | 6 ++-- tests/components/mqtt/test_humidifier.py | 6 ++-- tests/components/mqtt/test_init.py | 30 ++++--------------- tests/components/mqtt/test_legacy_vacuum.py | 6 ++-- tests/components/mqtt/test_light.py | 6 ++-- tests/components/mqtt/test_light_json.py | 6 ++-- tests/components/mqtt/test_light_template.py | 6 ++-- tests/components/mqtt/test_lock.py | 4 +-- tests/components/mqtt/test_number.py | 6 ++-- tests/components/mqtt/test_scene.py | 6 ++-- tests/components/mqtt/test_select.py | 6 ++-- tests/components/mqtt/test_sensor.py | 6 ++-- tests/components/mqtt/test_siren.py | 6 ++-- tests/components/mqtt/test_state_vacuum.py | 6 ++-- tests/components/mqtt/test_switch.py | 6 ++-- 23 files changed, 48 insertions(+), 116 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 2b013ddf8dd..4ea41aa4c64 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -953,13 +953,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = alarm_control_panel.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index ebb1d78138f..0561e9fe056 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -1063,13 +1063,11 @@ async def test_skip_restoring_state_with_over_due_expire_trigger( assert "Skip state recovery after reload for binary_sensor.test3" in caplog.text -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = binary_sensor.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 35deccf2bfe..9fa16aa33bb 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -462,13 +462,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = button.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 54d829ce9f9..f219178859b 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -330,13 +330,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = camera.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 77843cee777..35eec9aa1ff 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -1866,13 +1866,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = CLIMATE_DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 24482129f3d..92feaa3c109 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1790,13 +1790,7 @@ async def help_test_reloadable_late(hass, caplog, tmp_path, domain, config): assert hass.states.get(f"{domain}.test_new_3") -async def help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - platform, - config, -): +async def help_test_setup_manual_entity_from_yaml(hass, platform, config): """Help to test setup from yaml through configuration entry.""" config_structure = {mqtt.DOMAIN: {platform: config}} diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 31e30ebf11a..c2406aef9b4 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -3348,13 +3348,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = cover.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 34042105af2..c731280f575 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -244,9 +244,7 @@ async def test_legacy_matching_source_type( assert hass.states.get(entity_id).attributes["source_type"] == SOURCE_TYPE_BLUETOOTH -async def test_setup_with_modern_schema( - hass, caplog, tmp_path, mock_device_tracker_conf -): +async def test_setup_with_modern_schema(hass, mock_device_tracker_conf): """Test setup using the modern schema.""" dev_id = "jan" entity_id = f"{DOMAIN}.{dev_id}" @@ -255,8 +253,6 @@ async def test_setup_with_modern_schema( hass.config.components = {"zone"} config = {"name": dev_id, "state_topic": topic} - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, DOMAIN, config - ) + await help_test_setup_manual_entity_from_yaml(hass, DOMAIN, config) assert hass.states.get(entity_id) is not None diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 145edf5ac7d..6a4920ef45a 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1894,13 +1894,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = fan.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index ea9a6edf0e3..cbbf69e2947 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -1267,13 +1267,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = humidifier.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 8159933a9a4..474bc03f876 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1362,48 +1362,30 @@ async def test_setup_override_configuration(hass, caplog, tmp_path): assert calls_username_password_set[0][1] == "somepassword" -async def test_setup_manual_mqtt_with_platform_key(hass, caplog, tmp_path): +async def test_setup_manual_mqtt_with_platform_key(hass, caplog): """Test set up a manual MQTT item with a platform key.""" config = {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} - await help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - "light", - config, - ) + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert ( "Invalid config for [light]: [platform] is an invalid option for [light]. " "Check: light->platform. (See ?, line ?)" in caplog.text ) -async def test_setup_manual_mqtt_with_invalid_config(hass, caplog, tmp_path): +async def test_setup_manual_mqtt_with_invalid_config(hass, caplog): """Test set up a manual MQTT item with an invalid config.""" config = {"name": "test"} - await help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - "light", - config, - ) + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert ( "Invalid config for [light]: required key not provided @ data['command_topic']." " Got None. (See ?, line ?)" in caplog.text ) -async def test_setup_manual_mqtt_empty_platform(hass, caplog, tmp_path): +async def test_setup_manual_mqtt_empty_platform(hass, caplog): """Test set up a manual MQTT platform without items.""" config = None - await help_test_setup_manual_entity_from_yaml( - hass, - caplog, - tmp_path, - "light", - config, - ) + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert "voluptuous.error.MultipleInvalid" not in caplog.text diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 43b6e839904..0371153d750 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -966,13 +966,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = vacuum.DOMAIN config = deepcopy(DEFAULT_CONFIG) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 08d5432ba27..7ca10683ed7 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -3787,13 +3787,11 @@ async def test_sending_mqtt_effect_command_with_template( assert state.attributes.get("effect") == "colorloop" -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = light.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c24c5e87937..3e1eb10d717 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -2146,13 +2146,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = light.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 0d4b95e9152..fa2ef113976 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -1250,13 +1250,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = light.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index b48557efc8f..0353702152f 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -738,7 +738,5 @@ async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index a49b6de198d..648499b8751 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -784,13 +784,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = number.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index eb5cb94df2d..305389ba72d 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -222,13 +222,11 @@ async def test_reloadable_late(hass, mqtt_client_mock, caplog, tmp_path): await help_test_reloadable_late(hass, caplog, tmp_path, domain, config) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = scene.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 888dd301018..5b02c4f3a31 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -667,13 +667,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = select.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 7081ae45993..0cc275c9d1f 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -1197,13 +1197,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = sensor.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 2db2060c133..1dce382421e 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -959,13 +959,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = siren.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index f20a881dda1..64545d2c140 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -692,13 +692,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = vacuum.DOMAIN config = deepcopy(DEFAULT_CONFIG) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index b217bf40c22..1ed8db34d8d 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -648,13 +648,11 @@ async def test_encoding_subscribable_topics( ) -async def test_setup_manual_entity_from_yaml(hass, caplog, tmp_path): +async def test_setup_manual_entity_from_yaml(hass): """Test setup manual configured MQTT entity.""" platform = switch.DOMAIN config = copy.deepcopy(DEFAULT_CONFIG[platform]) config["name"] = "test" del config["platform"] - await help_test_setup_manual_entity_from_yaml( - hass, caplog, tmp_path, platform, config - ) + await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None From c195d462cc17db013c577771bc5911664349b4f3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jun 2022 13:53:47 +0200 Subject: [PATCH 391/947] Add async_get_options_flow type hints (hvv) (#73433) --- .../components/hvv_departures/config_flow.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/hvv_departures/config_flow.py b/homeassistant/components/hvv_departures/config_flow.py index 488579af12b..d96ab359dda 100644 --- a/homeassistant/components/hvv_departures/config_flow.py +++ b/homeassistant/components/hvv_departures/config_flow.py @@ -1,5 +1,8 @@ """Config flow for HVV integration.""" +from __future__ import annotations + import logging +from typing import Any from pygti.auth import GTI_DEFAULT_HOST from pygti.exceptions import CannotConnect, InvalidAuth @@ -122,7 +125,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @staticmethod @callback - def async_get_options_flow(config_entry): + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: """Get options flow.""" return OptionsFlowHandler(config_entry) @@ -130,12 +135,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): class OptionsFlowHandler(config_entries.OptionsFlow): """Options flow handler.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize HVV Departures options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) - self.departure_filters = {} - self.hub = None + self.departure_filters: dict[str, Any] = {} async def async_step_init(self, user_input=None): """Manage the options.""" @@ -143,10 +147,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if not self.departure_filters: departure_list = {} - self.hub = self.hass.data[DOMAIN][self.config_entry.entry_id] + hub: GTIHub = self.hass.data[DOMAIN][self.config_entry.entry_id] try: - departure_list = await self.hub.gti.departureList( + departure_list = await hub.gti.departureList( { "station": self.config_entry.data[CONF_STATION], "time": {"date": "heute", "time": "jetzt"}, From b84e844c7679f48fe09fea68c228f2313eae2724 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 13 Jun 2022 13:55:38 +0200 Subject: [PATCH 392/947] Add async_get_options_flow type hints (cast) (#73432) --- homeassistant/components/cast/config_flow.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index aaf8d5b9c6c..fc657fd2422 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -1,8 +1,13 @@ """Config flow for Cast.""" +from __future__ import annotations + +from typing import Any + import voluptuous as vol from homeassistant import config_entries from homeassistant.components import zeroconf +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -25,7 +30,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._wanted_uuid = set() @staticmethod - def async_get_options_flow(config_entry): + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> CastOptionsFlowHandler: """Get the options flow for this handler.""" return CastOptionsFlowHandler(config_entry) @@ -110,10 +118,10 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class CastOptionsFlowHandler(config_entries.OptionsFlow): """Handle Google Cast options.""" - def __init__(self, config_entry): + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Initialize Google Cast options flow.""" self.config_entry = config_entry - self.updated_config = {} + self.updated_config: dict[str, Any] = {} async def async_step_init(self, user_input=None): """Manage the Google Cast options.""" From dca4d3cd61d7f872621ee4021450cc6a0fbd930e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jun 2022 08:44:46 -1000 Subject: [PATCH 393/947] Significantly improve yaml load times when the C loader is available (#73337) --- .github/workflows/ci.yaml | 1 + .github/workflows/wheels.yml | 2 +- Dockerfile.dev | 1 + homeassistant/scripts/check_config.py | 6 +- homeassistant/util/yaml/loader.py | 172 +++++++++++++----- .../fixtures/configuration_invalid.notyaml | 2 +- tests/test_config.py | 18 +- tests/util/yaml/test_init.py | 66 +++++-- 8 files changed, 190 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fb46ab63202..27a761872d9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -28,6 +28,7 @@ env: PIP_CACHE: /tmp/pip-cache SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 + HASS_CI: 1 concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6019e533530..605820efb33 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -158,7 +158,7 @@ jobs: wheels-key: ${{ secrets.WHEELS_KEY }} wheels-user: wheels env-file: true - apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;cargo" + apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;cargo" pip: "Cython;numpy;scikit-build" skip-binary: aiohttp,grpcio constraints: "homeassistant/package_constraints.txt" diff --git a/Dockerfile.dev b/Dockerfile.dev index 322c63f53dd..0559ebb43cd 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -18,6 +18,7 @@ RUN \ libavfilter-dev \ libpcap-dev \ libturbojpeg0 \ + libyaml-dev \ libxml2 \ git \ cmake \ diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 6182b909f74..221dafa729c 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -191,7 +191,7 @@ def check(config_dir, secrets=False): if secrets: # Ensure !secrets point to the patched function - yaml_loader.SafeLineLoader.add_constructor("!secret", yaml_loader.secret_yaml) + yaml_loader.add_constructor("!secret", yaml_loader.secret_yaml) def secrets_proxy(*args): secrets = Secrets(*args) @@ -219,9 +219,7 @@ def check(config_dir, secrets=False): pat.stop() if secrets: # Ensure !secrets point to the original function - yaml_loader.SafeLineLoader.add_constructor( - "!secret", yaml_loader.secret_yaml - ) + yaml_loader.add_constructor("!secret", yaml_loader.secret_yaml) return res diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 3507ab96286..e3add3a7c44 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections import OrderedDict from collections.abc import Iterator import fnmatch +from io import StringIO import logging import os from pathlib import Path @@ -11,6 +12,14 @@ from typing import Any, TextIO, TypeVar, Union, overload import yaml +try: + from yaml import CSafeLoader as FastestAvailableSafeLoader + + HAS_C_LOADER = True +except ImportError: + HAS_C_LOADER = False + from yaml import SafeLoader as FastestAvailableSafeLoader # type: ignore[misc] + from homeassistant.exceptions import HomeAssistantError from .const import SECRET_YAML @@ -88,6 +97,30 @@ class Secrets: return secrets +class SafeLoader(FastestAvailableSafeLoader): + """The fastest available safe loader.""" + + def __init__(self, stream: Any, secrets: Secrets | None = None) -> None: + """Initialize a safe line loader.""" + self.stream = stream + if isinstance(stream, str): + self.name = "" + elif isinstance(stream, bytes): + self.name = "" + else: + self.name = getattr(stream, "name", "") + super().__init__(stream) + self.secrets = secrets + + def get_name(self) -> str: + """Get the name of the loader.""" + return self.name + + def get_stream_name(self) -> str: + """Get the name of the stream.""" + return self.stream.name or "" + + class SafeLineLoader(yaml.SafeLoader): """Loader class that keeps track of line numbers.""" @@ -103,6 +136,17 @@ class SafeLineLoader(yaml.SafeLoader): node.__line__ = last_line + 1 # type: ignore[attr-defined] return node + def get_name(self) -> str: + """Get the name of the loader.""" + return self.name + + def get_stream_name(self) -> str: + """Get the name of the stream.""" + return self.stream.name or "" + + +LoaderType = Union[SafeLineLoader, SafeLoader] + def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: """Load a YAML file.""" @@ -114,60 +158,90 @@ def load_yaml(fname: str, secrets: Secrets | None = None) -> JSON_TYPE: raise HomeAssistantError(exc) from exc -def parse_yaml(content: str | TextIO, secrets: Secrets | None = None) -> JSON_TYPE: - """Load a YAML file.""" +def parse_yaml( + content: str | TextIO | StringIO, secrets: Secrets | None = None +) -> JSON_TYPE: + """Parse YAML with the fastest available loader.""" + if not HAS_C_LOADER: + return _parse_yaml_pure_python(content, secrets) try: - # If configuration file is empty YAML returns None - # We convert that to an empty dict - return ( - yaml.load(content, Loader=lambda stream: SafeLineLoader(stream, secrets)) - or OrderedDict() - ) + return _parse_yaml(SafeLoader, content, secrets) + except yaml.YAMLError: + # Loading failed, so we now load with the slow line loader + # since the C one will not give us line numbers + if isinstance(content, (StringIO, TextIO)): + # Rewind the stream so we can try again + content.seek(0, 0) + return _parse_yaml_pure_python(content, secrets) + + +def _parse_yaml_pure_python( + content: str | TextIO | StringIO, secrets: Secrets | None = None +) -> JSON_TYPE: + """Parse YAML with the pure python loader (this is very slow).""" + try: + return _parse_yaml(SafeLineLoader, content, secrets) except yaml.YAMLError as exc: _LOGGER.error(str(exc)) raise HomeAssistantError(exc) from exc +def _parse_yaml( + loader: type[SafeLoader] | type[SafeLineLoader], + content: str | TextIO, + secrets: Secrets | None = None, +) -> JSON_TYPE: + """Load a YAML file.""" + # If configuration file is empty YAML returns None + # We convert that to an empty dict + return ( + yaml.load(content, Loader=lambda stream: loader(stream, secrets)) + or OrderedDict() + ) + + @overload def _add_reference( - obj: list | NodeListClass, loader: SafeLineLoader, node: yaml.nodes.Node + obj: list | NodeListClass, + loader: LoaderType, + node: yaml.nodes.Node, ) -> NodeListClass: ... @overload def _add_reference( - obj: str | NodeStrClass, loader: SafeLineLoader, node: yaml.nodes.Node + obj: str | NodeStrClass, + loader: LoaderType, + node: yaml.nodes.Node, ) -> NodeStrClass: ... @overload -def _add_reference( - obj: _DictT, loader: SafeLineLoader, node: yaml.nodes.Node -) -> _DictT: +def _add_reference(obj: _DictT, loader: LoaderType, node: yaml.nodes.Node) -> _DictT: ... -def _add_reference(obj, loader: SafeLineLoader, node: yaml.nodes.Node): # type: ignore[no-untyped-def] +def _add_reference(obj, loader: LoaderType, node: yaml.nodes.Node): # type: ignore[no-untyped-def] """Add file reference information to an object.""" if isinstance(obj, list): obj = NodeListClass(obj) if isinstance(obj, str): obj = NodeStrClass(obj) - setattr(obj, "__config_file__", loader.name) + setattr(obj, "__config_file__", loader.get_name()) setattr(obj, "__line__", node.start_mark.line) return obj -def _include_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: +def _include_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load another YAML file and embeds it using the !include tag. Example: device_tracker: !include device_tracker.yaml """ - fname = os.path.join(os.path.dirname(loader.name), node.value) + fname = os.path.join(os.path.dirname(loader.get_name()), node.value) try: return _add_reference(load_yaml(fname, loader.secrets), loader, node) except FileNotFoundError as exc: @@ -191,12 +265,10 @@ def _find_files(directory: str, pattern: str) -> Iterator[str]: yield filename -def _include_dir_named_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node -) -> OrderedDict: +def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> OrderedDict: """Load multiple files from directory as a dictionary.""" mapping: OrderedDict = OrderedDict() - loc = os.path.join(os.path.dirname(loader.name), node.value) + loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): filename = os.path.splitext(os.path.basename(fname))[0] if os.path.basename(fname) == SECRET_YAML: @@ -206,11 +278,11 @@ def _include_dir_named_yaml( def _include_dir_merge_named_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node + loader: LoaderType, node: yaml.nodes.Node ) -> OrderedDict: """Load multiple files from directory as a merged dictionary.""" mapping: OrderedDict = OrderedDict() - loc = os.path.join(os.path.dirname(loader.name), node.value) + loc = os.path.join(os.path.dirname(loader.get_name()), node.value) for fname in _find_files(loc, "*.yaml"): if os.path.basename(fname) == SECRET_YAML: continue @@ -221,10 +293,10 @@ def _include_dir_merge_named_yaml( def _include_dir_list_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node + loader: LoaderType, node: yaml.nodes.Node ) -> list[JSON_TYPE]: """Load multiple files from directory as a list.""" - loc = os.path.join(os.path.dirname(loader.name), node.value) + loc = os.path.join(os.path.dirname(loader.get_name()), node.value) return [ load_yaml(f, loader.secrets) for f in _find_files(loc, "*.yaml") @@ -233,10 +305,10 @@ def _include_dir_list_yaml( def _include_dir_merge_list_yaml( - loader: SafeLineLoader, node: yaml.nodes.Node + loader: LoaderType, node: yaml.nodes.Node ) -> JSON_TYPE: """Load multiple files from directory as a merged list.""" - loc: str = os.path.join(os.path.dirname(loader.name), node.value) + loc: str = os.path.join(os.path.dirname(loader.get_name()), node.value) merged_list: list[JSON_TYPE] = [] for fname in _find_files(loc, "*.yaml"): if os.path.basename(fname) == SECRET_YAML: @@ -247,7 +319,7 @@ def _include_dir_merge_list_yaml( return _add_reference(merged_list, loader, node) -def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> OrderedDict: +def _ordered_dict(loader: LoaderType, node: yaml.nodes.MappingNode) -> OrderedDict: """Load YAML mappings into an ordered dictionary to preserve key order.""" loader.flatten_mapping(node) nodes = loader.construct_pairs(node) @@ -259,14 +331,14 @@ def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> Order try: hash(key) except TypeError as exc: - fname = getattr(loader.stream, "name", "") + fname = loader.get_stream_name() raise yaml.MarkedYAMLError( context=f'invalid key: "{key}"', context_mark=yaml.Mark(fname, 0, line, -1, None, None), # type: ignore[arg-type] ) from exc if key in seen: - fname = getattr(loader.stream, "name", "") + fname = loader.get_stream_name() _LOGGER.warning( 'YAML file %s contains duplicate key "%s". Check lines %d and %d', fname, @@ -279,13 +351,13 @@ def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> Order return _add_reference(OrderedDict(nodes), loader, node) -def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: +def _construct_seq(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Add line number and file name to Load YAML sequence.""" (obj,) = loader.construct_yaml_seq(node) return _add_reference(obj, loader, node) -def _env_var_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> str: +def _env_var_yaml(loader: LoaderType, node: yaml.nodes.Node) -> str: """Load environment variables and embed it into the configuration YAML.""" args = node.value.split() @@ -298,27 +370,27 @@ def _env_var_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> str: raise HomeAssistantError(node.value) -def secret_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: +def secret_yaml(loader: LoaderType, node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" if loader.secrets is None: raise HomeAssistantError("Secrets not supported in this YAML file") - return loader.secrets.get(loader.name, node.value) + return loader.secrets.get(loader.get_name(), node.value) -SafeLineLoader.add_constructor("!include", _include_yaml) -SafeLineLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict -) -SafeLineLoader.add_constructor( - yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq -) -SafeLineLoader.add_constructor("!env_var", _env_var_yaml) -SafeLineLoader.add_constructor("!secret", secret_yaml) -SafeLineLoader.add_constructor("!include_dir_list", _include_dir_list_yaml) -SafeLineLoader.add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml) -SafeLineLoader.add_constructor("!include_dir_named", _include_dir_named_yaml) -SafeLineLoader.add_constructor( - "!include_dir_merge_named", _include_dir_merge_named_yaml -) -SafeLineLoader.add_constructor("!input", Input.from_node) +def add_constructor(tag: Any, constructor: Any) -> None: + """Add to constructor to all loaders.""" + for yaml_loader in (SafeLoader, SafeLineLoader): + yaml_loader.add_constructor(tag, constructor) + + +add_constructor("!include", _include_yaml) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _ordered_dict) +add_constructor(yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq) +add_constructor("!env_var", _env_var_yaml) +add_constructor("!secret", secret_yaml) +add_constructor("!include_dir_list", _include_dir_list_yaml) +add_constructor("!include_dir_merge_list", _include_dir_merge_list_yaml) +add_constructor("!include_dir_named", _include_dir_named_yaml) +add_constructor("!include_dir_merge_named", _include_dir_merge_named_yaml) +add_constructor("!input", Input.from_node) diff --git a/tests/components/rest/fixtures/configuration_invalid.notyaml b/tests/components/rest/fixtures/configuration_invalid.notyaml index 548d8bcf5a0..4afb3b7ce96 100644 --- a/tests/components/rest/fixtures/configuration_invalid.notyaml +++ b/tests/components/rest/fixtures/configuration_invalid.notyaml @@ -1,2 +1,2 @@ -*!* NOT YAML +-*!*- NOT YAML diff --git a/tests/test_config.py b/tests/test_config.py index 552139fa0ef..9b3f9d8755f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,6 +1,7 @@ """Test config utils.""" # pylint: disable=protected-access from collections import OrderedDict +import contextlib import copy import os from unittest import mock @@ -147,7 +148,7 @@ def test_load_yaml_config_raises_error_if_not_dict(): def test_load_yaml_config_raises_error_if_malformed_yaml(): """Test error raised if invalid YAML.""" with open(YAML_PATH, "w") as fp: - fp.write(":") + fp.write(":-") with pytest.raises(HomeAssistantError): config_util.load_yaml_config_file(YAML_PATH) @@ -156,11 +157,22 @@ def test_load_yaml_config_raises_error_if_malformed_yaml(): def test_load_yaml_config_raises_error_if_unsafe_yaml(): """Test error raised if unsafe YAML.""" with open(YAML_PATH, "w") as fp: - fp.write("hello: !!python/object/apply:os.system") + fp.write("- !!python/object/apply:os.system []") - with pytest.raises(HomeAssistantError): + with patch.object(os, "system") as system_mock, contextlib.suppress( + HomeAssistantError + ): config_util.load_yaml_config_file(YAML_PATH) + assert len(system_mock.mock_calls) == 0 + + # Here we validate that the test above is a good test + # since previously the syntax was not valid + with open(YAML_PATH) as fp, patch.object(os, "system") as system_mock: + list(yaml.unsafe_load_all(fp)) + + assert len(system_mock.mock_calls) == 1 + def test_load_yaml_config_preserves_key_order(): """Test removal of library.""" diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 2b86b3c50e9..1bdadf87a2d 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -1,10 +1,12 @@ """Test Home Assistant yaml loader.""" +import importlib import io import os import unittest from unittest.mock import patch import pytest +import yaml as pyyaml from homeassistant.config import YAML_CONFIG_FILE, load_yaml_config_file from homeassistant.exceptions import HomeAssistantError @@ -14,7 +16,24 @@ from homeassistant.util.yaml import loader as yaml_loader from tests.common import get_test_config_dir, patch_yaml_files -def test_simple_list(): +@pytest.fixture(params=["enable_c_loader", "disable_c_loader"]) +def try_both_loaders(request): + """Disable the yaml c loader.""" + if not request.param == "disable_c_loader": + yield + return + try: + cloader = pyyaml.CSafeLoader + except ImportError: + return + del pyyaml.CSafeLoader + importlib.reload(yaml_loader) + yield + pyyaml.CSafeLoader = cloader + importlib.reload(yaml_loader) + + +def test_simple_list(try_both_loaders): """Test simple list.""" conf = "config:\n - simple\n - list" with io.StringIO(conf) as file: @@ -22,7 +41,7 @@ def test_simple_list(): assert doc["config"] == ["simple", "list"] -def test_simple_dict(): +def test_simple_dict(try_both_loaders): """Test simple dict.""" conf = "key: value" with io.StringIO(conf) as file: @@ -37,14 +56,14 @@ def test_unhashable_key(): load_yaml_config_file(YAML_CONFIG_FILE) -def test_no_key(): +def test_no_key(try_both_loaders): """Test item without a key.""" files = {YAML_CONFIG_FILE: "a: a\nnokeyhere"} with pytest.raises(HomeAssistantError), patch_yaml_files(files): yaml.load_yaml(YAML_CONFIG_FILE) -def test_environment_variable(): +def test_environment_variable(try_both_loaders): """Test config file with environment variable.""" os.environ["PASSWORD"] = "secret_password" conf = "password: !env_var PASSWORD" @@ -54,7 +73,7 @@ def test_environment_variable(): del os.environ["PASSWORD"] -def test_environment_variable_default(): +def test_environment_variable_default(try_both_loaders): """Test config file with default value for environment variable.""" conf = "password: !env_var PASSWORD secret_password" with io.StringIO(conf) as file: @@ -62,14 +81,14 @@ def test_environment_variable_default(): assert doc["password"] == "secret_password" -def test_invalid_environment_variable(): +def test_invalid_environment_variable(try_both_loaders): """Test config file with no environment variable sat.""" conf = "password: !env_var PASSWORD" with pytest.raises(HomeAssistantError), io.StringIO(conf) as file: yaml_loader.yaml.load(file, Loader=yaml_loader.SafeLineLoader) -def test_include_yaml(): +def test_include_yaml(try_both_loaders): """Test include yaml.""" with patch_yaml_files({"test.yaml": "value"}): conf = "key: !include test.yaml" @@ -85,7 +104,7 @@ def test_include_yaml(): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_list(mock_walk): +def test_include_dir_list(mock_walk, try_both_loaders): """Test include dir list yaml.""" mock_walk.return_value = [["/test", [], ["two.yaml", "one.yaml"]]] @@ -97,7 +116,7 @@ def test_include_dir_list(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_list_recursive(mock_walk): +def test_include_dir_list_recursive(mock_walk, try_both_loaders): """Test include dir recursive list yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["zero.yaml"]], @@ -124,7 +143,7 @@ def test_include_dir_list_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_named(mock_walk): +def test_include_dir_named(mock_walk, try_both_loaders): """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", [], ["first.yaml", "second.yaml", "secrets.yaml"]] @@ -139,7 +158,7 @@ def test_include_dir_named(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_named_recursive(mock_walk): +def test_include_dir_named_recursive(mock_walk, try_both_loaders): """Test include dir named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -167,7 +186,7 @@ def test_include_dir_named_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_list(mock_walk): +def test_include_dir_merge_list(mock_walk, try_both_loaders): """Test include dir merge list yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -181,7 +200,7 @@ def test_include_dir_merge_list(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_list_recursive(mock_walk): +def test_include_dir_merge_list_recursive(mock_walk, try_both_loaders): """Test include dir merge list yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -208,7 +227,7 @@ def test_include_dir_merge_list_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_named(mock_walk): +def test_include_dir_merge_named(mock_walk, try_both_loaders): """Test include dir merge named yaml.""" mock_walk.return_value = [["/test", [], ["first.yaml", "second.yaml"]]] @@ -225,7 +244,7 @@ def test_include_dir_merge_named(mock_walk): @patch("homeassistant.util.yaml.loader.os.walk") -def test_include_dir_merge_named_recursive(mock_walk): +def test_include_dir_merge_named_recursive(mock_walk, try_both_loaders): """Test include dir merge named yaml.""" mock_walk.return_value = [ ["/test", ["tmp2", ".ignore", "ignore"], ["first.yaml"]], @@ -257,7 +276,7 @@ def test_include_dir_merge_named_recursive(mock_walk): @patch("homeassistant.util.yaml.loader.open", create=True) -def test_load_yaml_encoding_error(mock_open): +def test_load_yaml_encoding_error(mock_open, try_both_loaders): """Test raising a UnicodeDecodeError.""" mock_open.side_effect = UnicodeDecodeError("", b"", 1, 0, "") with pytest.raises(HomeAssistantError): @@ -413,7 +432,7 @@ def test_representing_yaml_loaded_data(): assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n" -def test_duplicate_key(caplog): +def test_duplicate_key(caplog, try_both_loaders): """Test duplicate dict keys.""" files = {YAML_CONFIG_FILE: "key: thing1\nkey: thing2"} with patch_yaml_files(files): @@ -421,7 +440,7 @@ def test_duplicate_key(caplog): assert "contains duplicate key" in caplog.text -def test_no_recursive_secrets(caplog): +def test_no_recursive_secrets(caplog, try_both_loaders): """Test that loading of secrets from the secrets file fails correctly.""" files = {YAML_CONFIG_FILE: "key: !secret a", yaml.SECRET_YAML: "a: 1\nb: !secret a"} with patch_yaml_files(files), pytest.raises(HomeAssistantError) as e: @@ -441,7 +460,16 @@ def test_input_class(): assert len({input, input2}) == 1 -def test_input(): +def test_input(try_both_loaders): """Test loading inputs.""" data = {"hello": yaml.Input("test_name")} assert yaml.parse_yaml(yaml.dump(data)) == data + + +@pytest.mark.skipif( + not os.environ.get("HASS_CI"), + reason="This test validates that the CI has the C loader available", +) +def test_c_loader_is_available_in_ci(): + """Verify we are testing the C loader in the CI.""" + assert yaml.loader.HAS_C_LOADER is True From c660fae8d8dfd190186fc404ca77cc1bc4c0cda3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 13 Jun 2022 21:17:08 +0200 Subject: [PATCH 394/947] Sensibo Add timer (#73072) --- .../components/sensibo/binary_sensor.py | 39 ++- homeassistant/components/sensibo/climate.py | 34 ++- homeassistant/components/sensibo/entity.py | 14 +- homeassistant/components/sensibo/sensor.py | 39 ++- .../components/sensibo/services.yaml | 28 +++ tests/components/sensibo/test_climate.py | 234 +++++++++++++++++- 6 files changed, 373 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 3a7deadc405..72003e0a418 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -1,9 +1,9 @@ """Binary Sensor platform for Sensibo integration.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from pysensibo.model import MotionSensor, SensiboDevice @@ -36,6 +36,7 @@ class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[SensiboDevice], bool | None] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None @dataclass @@ -77,13 +78,25 @@ MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = ( ), ) -DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( +MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( SensiboDeviceBinarySensorEntityDescription( key="room_occupied", device_class=BinarySensorDeviceClass.MOTION, name="Room Occupied", icon="mdi:motion-sensor", value_fn=lambda data: data.room_occupied, + extra_fn=None, + ), +) + +DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( + SensiboDeviceBinarySensorEntityDescription( + key="timer_on", + device_class=BinarySensorDeviceClass.RUNNING, + name="Timer Running", + icon="mdi:timer", + value_fn=lambda data: data.timer_on, + extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, ), ) @@ -94,6 +107,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost Enabled", icon="mdi:wind-power-outline", value_fn=lambda data: data.pure_boost_enabled, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_ac_integration", @@ -102,6 +116,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with AC", icon="mdi:connection", value_fn=lambda data: data.pure_ac_integration, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_geo_integration", @@ -110,6 +125,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Presence", icon="mdi:connection", value_fn=lambda data: data.pure_geo_integration, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_measure_integration", @@ -118,6 +134,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Indoor Air Quality", icon="mdi:connection", value_fn=lambda data: data.pure_measure_integration, + extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_prime_integration", @@ -126,6 +143,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Outdoor Air Quality", icon="mdi:connection", value_fn=lambda data: data.pure_prime_integration, + extra_fn=None, ), ) @@ -148,11 +166,17 @@ async def async_setup_entry( for sensor_id, sensor_data in device_data.motion_sensors.items() for description in MOTION_SENSOR_TYPES ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for description in MOTION_DEVICE_SENSOR_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.motion_sensors is not None + ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) for description in DEVICE_SENSOR_TYPES for device_id, device_data in coordinator.data.parsed.items() - if getattr(device_data, description.key) is not None + if device_data.model != "pure" ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) @@ -223,3 +247,10 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self.entity_description.value_fn(self.device_data) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self.entity_description.extra_fn is not None: + return self.entity_description.extra_fn(self.device_data) + return None diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index c7967d05be0..f0ce7b74c01 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_platform +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.temperature import convert as convert_temperature @@ -27,6 +27,8 @@ from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity SERVICE_ASSUME_STATE = "assume_state" +SERVICE_TIMER = "timer" +ATTR_MINUTES = "minutes" PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { @@ -85,6 +87,14 @@ async def async_setup_entry( }, "async_assume_state", ) + platform.async_register_entity_service( + SERVICE_TIMER, + { + vol.Required(ATTR_STATE): vol.In(["on", "off"]), + vol.Optional(ATTR_MINUTES): cv.positive_int, + }, + "async_set_timer", + ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @@ -276,3 +286,25 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Sync state with api.""" await self._async_set_ac_state_property("on", state != HVACMode.OFF, True) await self.coordinator.async_refresh() + + async def async_set_timer(self, state: str, minutes: int | None = None) -> None: + """Set or delete timer.""" + if state == "off" and self.device_data.timer_id is None: + raise HomeAssistantError("No timer to delete") + + if state == "on" and minutes is None: + raise ValueError("No value provided for timer") + + if state == "off": + result = await self.async_send_command("del_timer") + else: + new_state = bool(self.device_data.ac_states["on"] is False) + params = { + "minutesFromNow": minutes, + "acState": {**self.device_data.ac_states, "on": new_state}, + } + result = await self.async_send_command("set_timer", params) + + if result["status"] == "success": + return await self.coordinator.async_request_refresh() + raise HomeAssistantError(f"Could not set timer for device {self.name}") diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index c2f4869a4e6..430d7ac61ac 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -57,7 +57,7 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): ) async def async_send_command( - self, command: str, params: dict[str, Any] + self, command: str, params: dict[str, Any] | None = None ) -> dict[str, Any]: """Send command to Sensibo api.""" try: @@ -72,16 +72,20 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): return result async def async_send_api_call( - self, command: str, params: dict[str, Any] + self, command: str, params: dict[str, Any] | None = None ) -> dict[str, Any]: """Send api call.""" result: dict[str, Any] = {"status": None} if command == "set_calibration": + if TYPE_CHECKING: + assert params is not None result = await self._client.async_set_calibration( self._device_id, params["data"], ) if command == "set_ac_state": + if TYPE_CHECKING: + assert params is not None result = await self._client.async_set_ac_state_property( self._device_id, params["name"], @@ -89,6 +93,12 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): params["ac_states"], params["assumed_state"], ) + if command == "set_timer": + if TYPE_CHECKING: + assert params is not None + result = await self._client.async_set_timer(self._device_id, params) + if command == "del_timer": + result = await self._client.async_del_timer(self._device_id) return result diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index f37a054b606..259a24ab876 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -1,9 +1,10 @@ """Sensor platform for Sensibo integration.""" from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING +from datetime import datetime +from typing import TYPE_CHECKING, Any from pysensibo.model import MotionSensor, SensiboDevice @@ -44,7 +45,8 @@ class MotionBaseEntityDescriptionMixin: class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" - value_fn: Callable[[SensiboDevice], StateType] + value_fn: Callable[[SensiboDevice], StateType | datetime] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None @dataclass @@ -111,13 +113,25 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( name="PM2.5", icon="mdi:air-filter", value_fn=lambda data: data.pm25, + extra_fn=None, ), SensiboDeviceSensorEntityDescription( key="pure_sensitivity", name="Pure Sensitivity", icon="mdi:air-filter", - value_fn=lambda data: str(data.pure_sensitivity).lower(), - device_class="sensibo__sensitivity", + value_fn=lambda data: data.pure_sensitivity, + extra_fn=None, + ), +) + +DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( + SensiboDeviceSensorEntityDescription( + key="timer_time", + device_class=SensorDeviceClass.TIMESTAMP, + name="Timer End Time", + icon="mdi:timer", + value_fn=lambda data: data.timer_time, + extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, ), ) @@ -146,6 +160,12 @@ async def async_setup_entry( for description in PURE_SENSOR_TYPES if device_data.model == "pure" ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for device_id, device_data in coordinator.data.parsed.items() + for description in DEVICE_SENSOR_TYPES + if device_data.model != "pure" + ) async_add_entities(entities) @@ -205,6 +225,13 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, SensorEntity): self._attr_name = f"{self.device_data.name} {entity_description.name}" @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return value of sensor.""" return self.entity_description.value_fn(self.device_data) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self.entity_description.extra_fn is not None: + return self.entity_description.extra_fn(self.device_data) + return None diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index bbbdb8611e8..1a64f8703b4 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -16,3 +16,31 @@ assume_state: options: - "on" - "off" +timer: + name: Timer + description: Set or delete timer for device. + target: + entity: + integration: sensibo + domain: climate + fields: + state: + name: State + description: Timer on or off. + required: true + example: "on" + selector: + select: + options: + - "on" + - "off" + minutes: + name: Minutes + description: Countdown for timer (for timer state on) + required: false + example: 30 + selector: + number: + min: 0 + step: 1 + mode: box diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index ead4fb02d88..16e83162600 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -1,8 +1,8 @@ """The test for the sensibo binary sensor platform.""" from __future__ import annotations -from datetime import timedelta -from unittest.mock import patch +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, patch from pysensibo.model import SensiboData import pytest @@ -21,7 +21,9 @@ from homeassistant.components.climate.const import ( SERVICE_SET_TEMPERATURE, ) from homeassistant.components.sensibo.climate import ( + ATTR_MINUTES, SERVICE_ASSUME_STATE, + SERVICE_TIMER, _find_valid_target_temp, ) from homeassistant.components.sensibo.const import DOMAIN @@ -32,6 +34,7 @@ from homeassistant.const import ( ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -675,3 +678,230 @@ async def test_climate_no_fan_no_swing( assert state.attributes["swing_mode"] is None assert state.attributes["fan_modes"] is None assert state.attributes["swing_modes"] is None + + +async def test_climate_set_timer( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate Set Timer service.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("climate.hallway") + assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", "SzTGE4oZ4D") + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "timer_time", + datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + assert ( + hass.states.get("sensor.hallway_timer_end_time").state + == "2022-06-06T12:00:00+00:00" + ) + assert hass.states.get("binary_sensor.hallway_timer_running").state == "on" + assert hass.states.get("binary_sensor.hallway_timer_running").attributes == { + "device_class": "running", + "friendly_name": "Hallway Timer Running", + "icon": "mdi:timer", + "id": "SzTGE4oZ4D", + "turn_on": False, + } + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", + return_value={"status": "success"}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", False) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_time", None) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" + + +async def test_climate_set_timer_failures( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate Set Timer service failures.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("climate.hallway") + assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" + + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "success", "result": {"id": ""}}, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", None) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "timer_time", + datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "off", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_TIMER, + { + ATTR_ENTITY_ID: state1.entity_id, + ATTR_STATE: "on", + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() From 0ffeb6c3040d9ff226d92f47b6977500d0be5325 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jun 2022 10:10:05 -1000 Subject: [PATCH 395/947] Check if requirements are installed in the executor (#71611) --- homeassistant/requirements.py | 326 ++++++++++++++++++++-------------- tests/test_requirements.py | 45 ++--- 2 files changed, 209 insertions(+), 162 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 7631586d626..9a8cd20983a 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -15,10 +15,7 @@ from .util import package as pkg_util PIP_TIMEOUT = 60 # The default is too low when the internet connection is satellite or high latency MAX_INSTALL_FAILURES = 3 -DATA_PIP_LOCK = "pip_lock" -DATA_PKG_CACHE = "pkg_cache" -DATA_INTEGRATIONS_WITH_REQS = "integrations_with_reqs" -DATA_INSTALL_FAILURE_HISTORY = "install_failure_history" +DATA_REQUIREMENTS_MANAGER = "requirements_manager" CONSTRAINT_FILE = "package_constraints.txt" DISCOVERY_INTEGRATIONS: dict[str, Iterable[str]] = { "dhcp": ("dhcp",), @@ -40,7 +37,7 @@ class RequirementsNotFound(HomeAssistantError): async def async_get_integration_with_requirements( - hass: HomeAssistant, domain: str, done: set[str] | None = None + hass: HomeAssistant, domain: str ) -> Integration: """Get an integration with all requirements installed, including the dependencies. @@ -48,97 +45,8 @@ async def async_get_integration_with_requirements( is invalid, RequirementNotFound if there was some type of failure to install requirements. """ - if done is None: - done = {domain} - else: - done.add(domain) - - integration = await async_get_integration(hass, domain) - - if hass.config.skip_pip: - return integration - - if (cache := hass.data.get(DATA_INTEGRATIONS_WITH_REQS)) is None: - cache = hass.data[DATA_INTEGRATIONS_WITH_REQS] = {} - - int_or_evt: Integration | asyncio.Event | None | UndefinedType = cache.get( - domain, UNDEFINED - ) - - if isinstance(int_or_evt, asyncio.Event): - await int_or_evt.wait() - - # When we have waited and it's UNDEFINED, it doesn't exist - # We don't cache that it doesn't exist, or else people can't fix it - # and then restart, because their config will never be valid. - if (int_or_evt := cache.get(domain, UNDEFINED)) is UNDEFINED: - raise IntegrationNotFound(domain) - - if int_or_evt is not UNDEFINED: - return cast(Integration, int_or_evt) - - event = cache[domain] = asyncio.Event() - - try: - await _async_process_integration(hass, integration, done) - except Exception: - del cache[domain] - event.set() - raise - - cache[domain] = integration - event.set() - return integration - - -async def _async_process_integration( - hass: HomeAssistant, integration: Integration, done: set[str] -) -> None: - """Process an integration and requirements.""" - if integration.requirements: - await async_process_requirements( - hass, integration.domain, integration.requirements - ) - - deps_to_check = [ - dep - for dep in integration.dependencies + integration.after_dependencies - if dep not in done - ] - - for check_domain, to_check in DISCOVERY_INTEGRATIONS.items(): - if ( - check_domain not in done - and check_domain not in deps_to_check - and any(check in integration.manifest for check in to_check) - ): - deps_to_check.append(check_domain) - - if not deps_to_check: - return - - results = await asyncio.gather( - *( - async_get_integration_with_requirements(hass, dep, done) - for dep in deps_to_check - ), - return_exceptions=True, - ) - for result in results: - if not isinstance(result, BaseException): - continue - if not isinstance(result, IntegrationNotFound) or not ( - not integration.is_built_in - and result.domain in integration.after_dependencies - ): - raise result - - -@callback -def async_clear_install_history(hass: HomeAssistant) -> None: - """Forget the install history.""" - if install_failure_history := hass.data.get(DATA_INSTALL_FAILURE_HISTORY): - install_failure_history.clear() + manager = _async_get_manager(hass) + return await manager.async_get_integration_with_requirements(domain) async def async_process_requirements( @@ -149,49 +57,24 @@ async def async_process_requirements( This method is a coroutine. It will raise RequirementsNotFound if an requirement can't be satisfied. """ - if (pip_lock := hass.data.get(DATA_PIP_LOCK)) is None: - pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock() - install_failure_history = hass.data.get(DATA_INSTALL_FAILURE_HISTORY) - if install_failure_history is None: - install_failure_history = hass.data[DATA_INSTALL_FAILURE_HISTORY] = set() - - kwargs = pip_kwargs(hass.config.config_dir) - - async with pip_lock: - for req in requirements: - await _async_process_requirements( - hass, name, req, install_failure_history, kwargs - ) + await _async_get_manager(hass).async_process_requirements(name, requirements) -async def _async_process_requirements( - hass: HomeAssistant, - name: str, - req: str, - install_failure_history: set[str], - kwargs: Any, -) -> None: - """Install a requirement and save failures.""" - if req in install_failure_history: - _LOGGER.info( - "Multiple attempts to install %s failed, install will be retried after next configuration check or restart", - req, - ) - raise RequirementsNotFound(name, [req]) +@callback +def _async_get_manager(hass: HomeAssistant) -> RequirementsManager: + """Get the requirements manager.""" + if DATA_REQUIREMENTS_MANAGER in hass.data: + manager: RequirementsManager = hass.data[DATA_REQUIREMENTS_MANAGER] + return manager - if pkg_util.is_installed(req): - return + manager = hass.data[DATA_REQUIREMENTS_MANAGER] = RequirementsManager(hass) + return manager - def _install(req: str, kwargs: dict[str, Any]) -> bool: - """Install requirement.""" - return pkg_util.install_package(req, **kwargs) - for _ in range(MAX_INSTALL_FAILURES): - if await hass.async_add_executor_job(_install, req, kwargs): - return - - install_failure_history.add(req) - raise RequirementsNotFound(name, [req]) +@callback +def async_clear_install_history(hass: HomeAssistant) -> None: + """Forget the install history.""" + _async_get_manager(hass).install_failure_history.clear() def pip_kwargs(config_dir: str | None) -> dict[str, Any]: @@ -207,3 +90,178 @@ def pip_kwargs(config_dir: str | None) -> dict[str, Any]: if not (config_dir is None or pkg_util.is_virtual_env()) and not is_docker: kwargs["target"] = os.path.join(config_dir, "deps") return kwargs + + +def _install_with_retry(requirement: str, kwargs: dict[str, Any]) -> bool: + """Try to install a package up to MAX_INSTALL_FAILURES times.""" + for _ in range(MAX_INSTALL_FAILURES): + if pkg_util.install_package(requirement, **kwargs): + return True + return False + + +def _install_requirements_if_missing( + requirements: list[str], kwargs: dict[str, Any] +) -> tuple[set[str], set[str]]: + """Install requirements if missing.""" + installed: set[str] = set() + failures: set[str] = set() + for req in requirements: + if pkg_util.is_installed(req) or _install_with_retry(req, kwargs): + installed.add(req) + continue + failures.add(req) + return installed, failures + + +class RequirementsManager: + """Manage requirements.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Init the requirements manager.""" + self.hass = hass + self.pip_lock = asyncio.Lock() + self.integrations_with_reqs: dict[ + str, Integration | asyncio.Event | None | UndefinedType + ] = {} + self.install_failure_history: set[str] = set() + self.is_installed_cache: set[str] = set() + + async def async_get_integration_with_requirements( + self, domain: str, done: set[str] | None = None + ) -> Integration: + """Get an integration with all requirements installed, including the dependencies. + + This can raise IntegrationNotFound if manifest or integration + is invalid, RequirementNotFound if there was some type of + failure to install requirements. + """ + + if done is None: + done = {domain} + else: + done.add(domain) + + integration = await async_get_integration(self.hass, domain) + + if self.hass.config.skip_pip: + return integration + + cache = self.integrations_with_reqs + int_or_evt = cache.get(domain, UNDEFINED) + + if isinstance(int_or_evt, asyncio.Event): + await int_or_evt.wait() + + # When we have waited and it's UNDEFINED, it doesn't exist + # We don't cache that it doesn't exist, or else people can't fix it + # and then restart, because their config will never be valid. + if (int_or_evt := cache.get(domain, UNDEFINED)) is UNDEFINED: + raise IntegrationNotFound(domain) + + if int_or_evt is not UNDEFINED: + return cast(Integration, int_or_evt) + + event = cache[domain] = asyncio.Event() + + try: + await self._async_process_integration(integration, done) + except Exception: + del cache[domain] + event.set() + raise + + cache[domain] = integration + event.set() + return integration + + async def _async_process_integration( + self, integration: Integration, done: set[str] + ) -> None: + """Process an integration and requirements.""" + if integration.requirements: + await self.async_process_requirements( + integration.domain, integration.requirements + ) + + deps_to_check = [ + dep + for dep in integration.dependencies + integration.after_dependencies + if dep not in done + ] + + for check_domain, to_check in DISCOVERY_INTEGRATIONS.items(): + if ( + check_domain not in done + and check_domain not in deps_to_check + and any(check in integration.manifest for check in to_check) + ): + deps_to_check.append(check_domain) + + if not deps_to_check: + return + + results = await asyncio.gather( + *( + self.async_get_integration_with_requirements(dep, done) + for dep in deps_to_check + ), + return_exceptions=True, + ) + for result in results: + if not isinstance(result, BaseException): + continue + if not isinstance(result, IntegrationNotFound) or not ( + not integration.is_built_in + and result.domain in integration.after_dependencies + ): + raise result + + async def async_process_requirements( + self, name: str, requirements: list[str] + ) -> None: + """Install the requirements for a component or platform. + + This method is a coroutine. It will raise RequirementsNotFound + if an requirement can't be satisfied. + """ + if not (missing := self._find_missing_requirements(requirements)): + return + self._raise_for_failed_requirements(name, missing) + + async with self.pip_lock: + # Recaculate missing again now that we have the lock + await self._async_process_requirements( + name, self._find_missing_requirements(requirements) + ) + + def _find_missing_requirements(self, requirements: list[str]) -> list[str]: + """Find requirements that are missing in the cache.""" + return [req for req in requirements if req not in self.is_installed_cache] + + def _raise_for_failed_requirements( + self, integration: str, missing: list[str] + ) -> None: + """Raise RequirementsNotFound so we do not keep trying requirements that have already failed.""" + for req in missing: + if req in self.install_failure_history: + _LOGGER.info( + "Multiple attempts to install %s failed, install will be retried after next configuration check or restart", + req, + ) + raise RequirementsNotFound(integration, [req]) + + async def _async_process_requirements( + self, + name: str, + requirements: list[str], + ) -> None: + """Install a requirement and save failures.""" + kwargs = pip_kwargs(self.hass.config.config_dir) + installed, failures = await self.hass.async_add_executor_job( + _install_requirements_if_missing, requirements, kwargs + ) + self.is_installed_cache |= installed + self.install_failure_history |= failures + if failures: + raise RequirementsNotFound(name, list(failures)) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index 7e4dba42c2f..77499707489 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -213,16 +213,9 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" - assert len(mock_is_installed.mock_calls) == 1 - assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ - "test-comp==1.0.0", - ] - + assert len(mock_is_installed.mock_calls) == 0 # On another attempt we remember failures and don't try again - assert len(mock_inst.mock_calls) == 1 - assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ - "test-comp==1.0.0" - ] + assert len(mock_inst.mock_calls) == 0 # Now clear the history and so we try again async_clear_install_history(hass) @@ -239,14 +232,13 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" - assert len(mock_is_installed.mock_calls) == 3 + assert len(mock_is_installed.mock_calls) == 2 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] - assert len(mock_inst.mock_calls) == 7 + assert len(mock_inst.mock_calls) == 6 assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-after-dep==1.0.0", @@ -254,7 +246,6 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha "test-comp-dep==1.0.0", "test-comp-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] # Now clear the history and mock success @@ -272,18 +263,16 @@ async def test_get_integration_with_requirements_pip_install_fails_two_passes(ha assert integration assert integration.domain == "test_component" - assert len(mock_is_installed.mock_calls) == 3 + assert len(mock_is_installed.mock_calls) == 2 assert sorted(mock_call[1][0] for mock_call in mock_is_installed.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] - assert len(mock_inst.mock_calls) == 3 + assert len(mock_inst.mock_calls) == 2 assert sorted(mock_call[1][0] for mock_call in mock_inst.mock_calls) == [ "test-comp-after-dep==1.0.0", "test-comp-dep==1.0.0", - "test-comp==1.0.0", ] @@ -408,12 +397,12 @@ async def test_discovery_requirements_mqtt(hass): hass, MockModule("mqtt_comp", partial_manifest={"mqtt": ["foo/discovery"]}) ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "mqtt_comp") assert len(mock_process.mock_calls) == 2 # mqtt also depends on http - assert mock_process.mock_calls[0][1][2] == mqtt.requirements + assert mock_process.mock_calls[0][1][1] == mqtt.requirements async def test_discovery_requirements_ssdp(hass): @@ -425,17 +414,17 @@ async def test_discovery_requirements_ssdp(hass): hass, MockModule("ssdp_comp", partial_manifest={"ssdp": [{"st": "roku:ecp"}]}) ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "ssdp_comp") assert len(mock_process.mock_calls) == 4 - assert mock_process.mock_calls[0][1][2] == ssdp.requirements + assert mock_process.mock_calls[0][1][1] == ssdp.requirements # Ensure zeroconf is a dep for ssdp assert { - mock_process.mock_calls[1][1][1], - mock_process.mock_calls[2][1][1], - mock_process.mock_calls[3][1][1], + mock_process.mock_calls[1][1][0], + mock_process.mock_calls[2][1][0], + mock_process.mock_calls[3][1][0], } == {"network", "zeroconf", "http"} @@ -454,12 +443,12 @@ async def test_discovery_requirements_zeroconf(hass, partial_manifest): ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "comp") assert len(mock_process.mock_calls) == 3 # zeroconf also depends on http - assert mock_process.mock_calls[0][1][2] == zeroconf.requirements + assert mock_process.mock_calls[0][1][1] == zeroconf.requirements async def test_discovery_requirements_dhcp(hass): @@ -477,9 +466,9 @@ async def test_discovery_requirements_dhcp(hass): ), ) with patch( - "homeassistant.requirements.async_process_requirements", + "homeassistant.requirements.RequirementsManager.async_process_requirements", ) as mock_process: await async_get_integration_with_requirements(hass, "comp") assert len(mock_process.mock_calls) == 1 # dhcp does not depend on http - assert mock_process.mock_calls[0][1][2] == dhcp.requirements + assert mock_process.mock_calls[0][1][1] == dhcp.requirements From 034c0c0593ba850faf624df581bb567fdc670a4f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jun 2022 10:14:30 -1000 Subject: [PATCH 396/947] Improve YAML Dump times with C Dumper (#73424) --- homeassistant/helpers/selector.py | 5 ++-- homeassistant/util/yaml/dumper.py | 26 +++++++++++++++---- .../blueprint/test_websocket_api.py | 14 ++++++++-- tests/util/yaml/test_init.py | 25 +++++++++++++++--- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index 87574949f4e..ccb7ac67dfb 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -5,13 +5,12 @@ from collections.abc import Callable, Sequence from typing import Any, TypedDict, cast import voluptuous as vol -import yaml from homeassistant.backports.enum import StrEnum from homeassistant.const import CONF_MODE, CONF_UNIT_OF_MEASUREMENT from homeassistant.core import split_entity_id, valid_entity_id from homeassistant.util import decorator -from homeassistant.util.yaml.dumper import represent_odict +from homeassistant.util.yaml.dumper import add_representer, represent_odict from . import config_validation as cv @@ -889,7 +888,7 @@ class TimeSelector(Selector): return cast(str, data) -yaml.SafeDumper.add_representer( +add_representer( Selector, lambda dumper, value: represent_odict( dumper, "tag:yaml.org,2002:map", value.serialize() diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py index 3eafc8abdd7..9f69c6c346e 100644 --- a/homeassistant/util/yaml/dumper.py +++ b/homeassistant/util/yaml/dumper.py @@ -1,5 +1,6 @@ """Custom dumper and representers.""" from collections import OrderedDict +from typing import Any import yaml @@ -8,10 +9,20 @@ from .objects import Input, NodeListClass # mypy: allow-untyped-calls, no-warn-return-any +try: + from yaml import CSafeDumper as FastestAvailableSafeDumper +except ImportError: + from yaml import SafeDumper as FastestAvailableSafeDumper # type: ignore[misc] + + def dump(_dict: dict) -> str: """Dump YAML to a string and remove null.""" - return yaml.safe_dump( - _dict, default_flow_style=False, allow_unicode=True, sort_keys=False + return yaml.dump( + _dict, + default_flow_style=False, + allow_unicode=True, + sort_keys=False, + Dumper=FastestAvailableSafeDumper, ).replace(": null\n", ":\n") @@ -51,17 +62,22 @@ def represent_odict( # type: ignore[no-untyped-def] return node -yaml.SafeDumper.add_representer( +def add_representer(klass: Any, representer: Any) -> None: + """Add to representer to the dumper.""" + FastestAvailableSafeDumper.add_representer(klass, representer) + + +add_representer( OrderedDict, lambda dumper, value: represent_odict(dumper, "tag:yaml.org,2002:map", value), ) -yaml.SafeDumper.add_representer( +add_representer( NodeListClass, lambda dumper, value: dumper.represent_sequence("tag:yaml.org,2002:seq", value), ) -yaml.SafeDumper.add_representer( +add_representer( Input, lambda dumper, value: dumper.represent_scalar("!input", value.name), ) diff --git a/tests/components/blueprint/test_websocket_api.py b/tests/components/blueprint/test_websocket_api.py index 9376710abee..eb2d12f5081 100644 --- a/tests/components/blueprint/test_websocket_api.py +++ b/tests/components/blueprint/test_websocket_api.py @@ -5,6 +5,7 @@ from unittest.mock import Mock, patch import pytest from homeassistant.setup import async_setup_component +from homeassistant.util.yaml import parse_yaml @pytest.fixture(autouse=True) @@ -130,9 +131,18 @@ async def test_save_blueprint(hass, aioclient_mock, hass_ws_client): assert msg["id"] == 6 assert msg["success"] assert write_mock.mock_calls - assert write_mock.call_args[0] == ( - "blueprint:\n name: Call service based on event\n domain: automation\n input:\n trigger_event:\n selector:\n text: {}\n service_to_call:\n a_number:\n selector:\n number:\n mode: box\n step: 1.0\n source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n platform: event\n event_type: !input 'trigger_event'\naction:\n service: !input 'service_to_call'\n entity_id: light.kitchen\n", + # There are subtle differences in the dumper quoting + # behavior when quoting is not required as both produce + # valid yaml + output_yaml = write_mock.call_args[0][0] + assert output_yaml in ( + # pure python dumper will quote the value after !input + "blueprint:\n name: Call service based on event\n domain: automation\n input:\n trigger_event:\n selector:\n text: {}\n service_to_call:\n a_number:\n selector:\n number:\n mode: box\n step: 1.0\n source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n platform: event\n event_type: !input 'trigger_event'\naction:\n service: !input 'service_to_call'\n entity_id: light.kitchen\n" + # c dumper will not quote the value after !input + "blueprint:\n name: Call service based on event\n domain: automation\n input:\n trigger_event:\n selector:\n text: {}\n service_to_call:\n a_number:\n selector:\n number:\n mode: box\n step: 1.0\n source_url: https://github.com/balloob/home-assistant-config/blob/main/blueprints/automation/motion_light.yaml\ntrigger:\n platform: event\n event_type: !input trigger_event\naction:\n service: !input service_to_call\n entity_id: light.kitchen\n" ) + # Make sure ita parsable and does not raise + assert len(parse_yaml(output_yaml)) > 1 async def test_save_existing_file(hass, aioclient_mock, hass_ws_client): diff --git a/tests/util/yaml/test_init.py b/tests/util/yaml/test_init.py index 1bdadf87a2d..11dc40233dc 100644 --- a/tests/util/yaml/test_init.py +++ b/tests/util/yaml/test_init.py @@ -33,6 +33,23 @@ def try_both_loaders(request): importlib.reload(yaml_loader) +@pytest.fixture(params=["enable_c_dumper", "disable_c_dumper"]) +def try_both_dumpers(request): + """Disable the yaml c dumper.""" + if not request.param == "disable_c_dumper": + yield + return + try: + cdumper = pyyaml.CSafeDumper + except ImportError: + return + del pyyaml.CSafeDumper + importlib.reload(yaml_loader) + yield + pyyaml.CSafeDumper = cdumper + importlib.reload(yaml_loader) + + def test_simple_list(try_both_loaders): """Test simple list.""" conf = "config:\n - simple\n - list" @@ -283,12 +300,12 @@ def test_load_yaml_encoding_error(mock_open, try_both_loaders): yaml_loader.load_yaml("test") -def test_dump(): +def test_dump(try_both_dumpers): """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "b"}) == "a:\nb: b\n" -def test_dump_unicode(): +def test_dump_unicode(try_both_dumpers): """The that the dump method returns empty None values.""" assert yaml.dump({"a": None, "b": "привет"}) == "a:\nb: привет\n" @@ -424,7 +441,7 @@ class TestSecrets(unittest.TestCase): ) -def test_representing_yaml_loaded_data(): +def test_representing_yaml_loaded_data(try_both_dumpers): """Test we can represent YAML loaded data.""" files = {YAML_CONFIG_FILE: 'key: [1, "2", 3]'} with patch_yaml_files(files): @@ -460,7 +477,7 @@ def test_input_class(): assert len({input, input2}) == 1 -def test_input(try_both_loaders): +def test_input(try_both_loaders, try_both_dumpers): """Test loading inputs.""" data = {"hello": yaml.Input("test_name")} assert yaml.parse_yaml(yaml.dump(data)) == data From 51b4d15c8cb83bb715222841aa48e83f77ef38ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jun 2022 10:17:10 -1000 Subject: [PATCH 397/947] Speed up mqtt tests (#73423) Co-authored-by: jbouwh Co-authored-by: Jan Bouwhuis --- .../mqtt/test_alarm_control_panel.py | 10 ++++++++ tests/components/mqtt/test_binary_sensor.py | 8 ++++++ tests/components/mqtt/test_button.py | 14 ++++++++++- tests/components/mqtt/test_camera.py | 8 ++++++ tests/components/mqtt/test_climate.py | 9 ++++++- tests/components/mqtt/test_cover.py | 8 ++++++ tests/components/mqtt/test_device_tracker.py | 11 +++++++- .../mqtt/test_device_tracker_discovery.py | 11 +++++++- tests/components/mqtt/test_device_trigger.py | 12 +++++++++ tests/components/mqtt/test_diagnostics.py | 13 +++++++++- tests/components/mqtt/test_discovery.py | 25 +++++++++++++++++++ tests/components/mqtt/test_fan.py | 8 ++++++ tests/components/mqtt/test_humidifier.py | 8 ++++++ tests/components/mqtt/test_init.py | 14 +++++++++++ tests/components/mqtt/test_legacy_vacuum.py | 9 ++++++- tests/components/mqtt/test_light.py | 8 ++++++ tests/components/mqtt/test_light_json.py | 8 ++++++ tests/components/mqtt/test_light_template.py | 8 ++++++ tests/components/mqtt/test_lock.py | 8 ++++++ tests/components/mqtt/test_number.py | 8 ++++++ tests/components/mqtt/test_scene.py | 9 ++++++- tests/components/mqtt/test_select.py | 14 ++++++++++- tests/components/mqtt/test_sensor.py | 8 ++++++ tests/components/mqtt/test_siren.py | 8 ++++++ tests/components/mqtt/test_state_vacuum.py | 8 ++++++ tests/components/mqtt/test_subscription.py | 11 +++++++- tests/components/mqtt/test_switch.py | 8 ++++++ tests/components/mqtt/test_tag.py | 8 ++++++ tests/components/mqtt/test_trigger.py | 9 ++++++- 29 files changed, 281 insertions(+), 10 deletions(-) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 4ea41aa4c64..f4a72829046 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -30,6 +30,7 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -112,6 +113,15 @@ DEFAULT_CONFIG_REMOTE_CODE_TEXT = { } +@pytest.fixture(autouse=True) +def alarm_control_panel_platform_only(): + """Only setup the alarm_control_panel platform to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", [Platform.ALARM_CONTROL_PANEL] + ): + yield + + async def test_fail_setup_without_state_topic(hass, mqtt_mock_entry_no_yaml_config): """Test for failing with no state topic.""" with assert_setup_component(0, alarm_control_panel.DOMAIN) as config: diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 0561e9fe056..20037a88d1c 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -62,6 +63,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def binary_sensor_platform_only(): + """Only setup the binary_sensor platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + async def test_setting_sensor_value_expires_availability_topic( hass, mqtt_mock_entry_with_yaml_config, caplog ): diff --git a/tests/components/mqtt/test_button.py b/tests/components/mqtt/test_button.py index 9fa16aa33bb..8748ef3be4d 100644 --- a/tests/components/mqtt/test_button.py +++ b/tests/components/mqtt/test_button.py @@ -5,7 +5,12 @@ from unittest.mock import patch import pytest from homeassistant.components import button -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + STATE_UNKNOWN, + Platform, +) from homeassistant.setup import async_setup_component from .test_common import ( @@ -41,6 +46,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def button_platform_only(): + """Only setup the button platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BUTTON]): + yield + + @pytest.mark.freeze_time("2021-11-08 13:31:44+00:00") async def test_sending_mqtt_commands(hass, mqtt_mock_entry_with_yaml_config): """Test the sending MQTT commands.""" diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index f219178859b..84bf4181a2c 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -9,6 +9,7 @@ import pytest from homeassistant.components import camera from homeassistant.components.mqtt.camera import MQTT_CAMERA_ATTRIBUTES_BLOCKED +from homeassistant.const import Platform from homeassistant.setup import async_setup_component from .test_common import ( @@ -46,6 +47,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def camera_platform_only(): + """Only setup the camera platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CAMERA]): + yield + + async def test_run_camera_setup( hass, hass_client_no_auth, mqtt_mock_entry_with_yaml_config ): diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 35eec9aa1ff..c633f267e76 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -26,7 +26,7 @@ from homeassistant.components.climate.const import ( HVACMode, ) from homeassistant.components.mqtt.climate import MQTT_CLIMATE_ATTRIBUTES_BLOCKED -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, Platform from homeassistant.setup import async_setup_component from .test_common import ( @@ -106,6 +106,13 @@ DEFAULT_LEGACY_CONFIG = { } +@pytest.fixture(autouse=True) +def climate_platform_only(): + """Only setup the climate platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CLIMATE]): + yield + + async def test_setup_params(hass, mqtt_mock_entry_with_yaml_config): """Test the initial parameters.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index c2406aef9b4..c0d63cec1b4 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -43,6 +43,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -83,6 +84,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def cover_platform_only(): + """Only setup the cover platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.COVER]): + yield + + async def test_state_via_state_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index c731280f575..a9eb9b20825 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -1,8 +1,10 @@ """The tests for the MQTT device tracker platform using configuration.yaml.""" from unittest.mock import patch +import pytest + from homeassistant.components.device_tracker.const import DOMAIN, SOURCE_TYPE_BLUETOOTH -from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME +from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME, Platform from homeassistant.setup import async_setup_component from .test_common import help_test_setup_manual_entity_from_yaml @@ -10,6 +12,13 @@ from .test_common import help_test_setup_manual_entity_from_yaml from tests.common import async_fire_mqtt_message +@pytest.fixture(autouse=True) +def device_tracker_platform_only(): + """Only setup the device_tracker platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.DEVICE_TRACKER]): + yield + + # Deprecated in HA Core 2022.6 async def test_legacy_ensure_device_tracker_platform_validation( hass, mqtt_mock_entry_with_yaml_config diff --git a/tests/components/mqtt/test_device_tracker_discovery.py b/tests/components/mqtt/test_device_tracker_discovery.py index 31853ad1dee..ac4058c9372 100644 --- a/tests/components/mqtt/test_device_tracker_discovery.py +++ b/tests/components/mqtt/test_device_tracker_discovery.py @@ -1,11 +1,13 @@ """The tests for the MQTT device_tracker discovery platform.""" +from unittest.mock import patch + import pytest from homeassistant.components import device_tracker from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN from homeassistant.components.mqtt.discovery import ALREADY_DISCOVERED -from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_UNKNOWN, Platform from homeassistant.setup import async_setup_component from .test_common import help_test_setting_blocked_attribute_via_mqtt_json_message @@ -21,6 +23,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def device_tracker_platform_only(): + """Only setup the device_tracker platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.DEVICE_TRACKER]): + yield + + @pytest.fixture def device_reg(hass): """Return an empty, loaded, registry.""" diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index fe08c85a853..842e1dc4106 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -1,11 +1,13 @@ """The tests for MQTT device triggers.""" import json +from unittest.mock import patch import pytest import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt import _LOGGER, DOMAIN, debug_info +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.helpers.trigger import async_initialize_triggers from homeassistant.setup import async_setup_component @@ -39,6 +41,16 @@ def calls(hass): return async_mock_service(hass, "test", "automation") +@pytest.fixture(autouse=True) +def binary_sensor_and_sensor_only(): + """Only setup the binary_sensor and sensor platform to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", + [Platform.BINARY_SENSOR, Platform.SENSOR], + ): + yield + + async def test_get_triggers( hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): diff --git a/tests/components/mqtt/test_diagnostics.py b/tests/components/mqtt/test_diagnostics.py index 65399a22f70..8cc5d0b1070 100644 --- a/tests/components/mqtt/test_diagnostics.py +++ b/tests/components/mqtt/test_diagnostics.py @@ -1,11 +1,12 @@ """Test MQTT diagnostics.""" import json -from unittest.mock import ANY +from unittest.mock import ANY, patch import pytest from homeassistant.components import mqtt +from homeassistant.const import Platform from tests.common import async_fire_mqtt_message, mock_device_registry from tests.components.diagnostics import ( @@ -31,6 +32,16 @@ default_config = { } +@pytest.fixture(autouse=True) +def device_tracker_sensor_only(): + """Only setup the device_tracker and sensor platforms to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", + [Platform.DEVICE_TRACKER, Platform.SENSOR], + ): + yield + + @pytest.fixture def device_reg(hass): """Return an empty, loaded, registry.""" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index df20dc031d0..d185d3334d0 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -18,6 +18,7 @@ from homeassistant.const import ( STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -65,6 +66,7 @@ async def test_subscribing_config_topic(hass, mqtt_mock_entry_no_yaml_config): assert discovery_topic + "/+/+/+/config" in topics +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) @pytest.mark.parametrize( "topic, log", [ @@ -92,6 +94,7 @@ async def test_invalid_topic(hass, mqtt_mock_entry_no_yaml_config, caplog, topic caplog.clear() +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_invalid_json(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in invalid JSON.""" await mqtt_mock_entry_no_yaml_config() @@ -131,6 +134,7 @@ async def test_only_valid_components(hass, mqtt_mock_entry_no_yaml_config, caplo assert not mock_dispatcher_send.called +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_correct_config_discovery(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in correct JSON.""" await mqtt_mock_entry_no_yaml_config() @@ -148,6 +152,7 @@ async def test_correct_config_discovery(hass, mqtt_mock_entry_no_yaml_config, ca assert ("binary_sensor", "bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]) async def test_discover_fan(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovering an MQTT fan.""" await mqtt_mock_entry_no_yaml_config() @@ -165,6 +170,7 @@ async def test_discover_fan(hass, mqtt_mock_entry_no_yaml_config, caplog): assert ("fan", "bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CLIMATE]) async def test_discover_climate(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test discovering an MQTT climate component.""" await mqtt_mock_entry_no_yaml_config() @@ -184,6 +190,7 @@ async def test_discover_climate(hass, mqtt_mock_entry_no_yaml_config, caplog): assert ("climate", "bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.ALARM_CONTROL_PANEL]) async def test_discover_alarm_control_panel( hass, mqtt_mock_entry_no_yaml_config, caplog ): @@ -365,6 +372,7 @@ async def test_discovery_with_object_id( assert (domain, "object bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_discovery_incl_nodeid(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test sending in correct JSON with optional node_id included.""" await mqtt_mock_entry_no_yaml_config() @@ -382,6 +390,7 @@ async def test_discovery_incl_nodeid(hass, mqtt_mock_entry_no_yaml_config, caplo assert ("binary_sensor", "my_node_id bla") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_non_duplicate_discovery(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test for a non duplicate component.""" await mqtt_mock_entry_no_yaml_config() @@ -406,6 +415,7 @@ async def test_non_duplicate_discovery(hass, mqtt_mock_entry_no_yaml_config, cap assert "Component has already been discovered: binary_sensor bla" in caplog.text +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test removal of component through empty discovery message.""" await mqtt_mock_entry_no_yaml_config() @@ -424,6 +434,7 @@ async def test_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): assert state is None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test rediscover of removed component.""" await mqtt_mock_entry_no_yaml_config() @@ -451,6 +462,7 @@ async def test_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): assert state is not None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate rediscover of removed component.""" await mqtt_mock_entry_no_yaml_config() @@ -500,6 +512,7 @@ async def test_rapid_rediscover(hass, mqtt_mock_entry_no_yaml_config, caplog): assert events[4].data["old_state"] is None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_rediscover_unique(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate rediscover of removed component.""" await mqtt_mock_entry_no_yaml_config() @@ -559,6 +572,7 @@ async def test_rapid_rediscover_unique(hass, mqtt_mock_entry_no_yaml_config, cap assert events[3].data["old_state"] is None +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_rapid_reconfigure(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test immediate reconfigure of added component.""" await mqtt_mock_entry_no_yaml_config() @@ -611,6 +625,7 @@ async def test_rapid_reconfigure(hass, mqtt_mock_entry_no_yaml_config, caplog): assert events[2].data["new_state"].attributes["friendly_name"] == "Wine" +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) async def test_duplicate_removal(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test for a non duplicate component.""" await mqtt_mock_entry_no_yaml_config() @@ -688,6 +703,7 @@ async def test_cleanup_device( ) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_cleanup_device_mqtt( hass, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): @@ -730,6 +746,7 @@ async def test_cleanup_device_mqtt( mqtt_mock.async_publish.assert_not_called() +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) async def test_cleanup_device_multiple_config_entries( hass, hass_ws_client, device_reg, entity_reg, mqtt_mock_entry_no_yaml_config ): @@ -905,6 +922,7 @@ async def test_cleanup_device_multiple_config_entries_mqtt( mqtt_mock.async_publish.assert_not_called() +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of abbreviated discovery payload.""" await mqtt_mock_entry_no_yaml_config() @@ -963,6 +981,7 @@ async def test_discovery_expansion(hass, mqtt_mock_entry_no_yaml_config, caplog) assert state.state == STATE_UNAVAILABLE +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion_2(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of abbreviated discovery payload.""" await mqtt_mock_entry_no_yaml_config() @@ -1003,6 +1022,7 @@ async def test_discovery_expansion_2(hass, mqtt_mock_entry_no_yaml_config, caplo assert state.state == STATE_UNKNOWN +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) @pytest.mark.no_fail_on_log_exception async def test_discovery_expansion_3(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test expansion of broken discovery payload.""" @@ -1084,6 +1104,7 @@ async def test_discovery_expansion_without_encoding_and_value_template_1( assert state.state == STATE_UNAVAILABLE +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_discovery_expansion_without_encoding_and_value_template_2( hass, mqtt_mock_entry_no_yaml_config, caplog ): @@ -1190,6 +1211,7 @@ async def test_missing_discover_abbreviations( assert not missing +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]) async def test_no_implicit_state_topic_switch( hass, mqtt_mock_entry_no_yaml_config, caplog ): @@ -1214,6 +1236,7 @@ async def test_no_implicit_state_topic_switch( assert state.state == STATE_UNKNOWN +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) @pytest.mark.parametrize( "mqtt_config", [ @@ -1242,6 +1265,7 @@ async def test_complex_discovery_topic_prefix( assert ("binary_sensor", "node1 object1") in hass.data[ALREADY_DISCOVERED] +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_mqtt_integration_discovery_subscribe_unsubscribe( hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): @@ -1283,6 +1307,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( assert not mqtt_client_mock.unsubscribe.called +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_mqtt_discovery_unsubscribe_once( hass, mqtt_client_mock, mqtt_mock_entry_no_yaml_config ): diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 6a4920ef45a..b9ca5e3888d 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -28,6 +28,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -74,6 +75,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def fan_platform_only(): + """Only setup the fan platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.FAN]): + yield + + async def test_fail_setup_if_no_command_topic( hass, caplog, mqtt_mock_entry_no_yaml_config ): diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index cbbf69e2947..e1e757762df 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -29,6 +29,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -75,6 +76,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def humidifer_platform_only(): + """Only setup the humidifer platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.HUMIDIFIER]): + yield + + async def async_turn_on( hass, entity_id=ENTITY_MATCH_ALL, diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 474bc03f876..b75def64834 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -22,6 +22,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, + Platform, ) import homeassistant.core as ha from homeassistant.core import CoreState, HomeAssistant, callback @@ -51,6 +52,16 @@ class RecordCallsPartial(partial): __name__ = "RecordCallPartialTest" +@pytest.fixture(autouse=True) +def sensor_platforms_only(): + """Only setup the sensor platforms to speed up tests.""" + with patch( + "homeassistant.components.mqtt.PLATFORMS", + [Platform.SENSOR, Platform.BINARY_SENSOR], + ): + yield + + @pytest.fixture(autouse=True) def mock_storage(hass_storage): """Autouse hass_storage for the TestCase tests.""" @@ -1362,6 +1373,7 @@ async def test_setup_override_configuration(hass, caplog, tmp_path): assert calls_username_password_set[0][1] == "somepassword" +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_setup_manual_mqtt_with_platform_key(hass, caplog): """Test set up a manual MQTT item with a platform key.""" config = {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} @@ -1372,6 +1384,7 @@ async def test_setup_manual_mqtt_with_platform_key(hass, caplog): ) +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_setup_manual_mqtt_with_invalid_config(hass, caplog): """Test set up a manual MQTT item with an invalid config.""" config = {"name": "test"} @@ -2013,6 +2026,7 @@ async def test_mqtt_ws_get_device_debug_info( assert response["result"] == expected_result +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.CAMERA]) async def test_mqtt_ws_get_device_debug_info_binary( hass, device_reg, hass_ws_client, mqtt_mock_entry_no_yaml_config ): diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 0371153d750..224e81781cf 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -29,7 +29,7 @@ from homeassistant.components.vacuum import ( ATTR_STATUS, VacuumEntityFeature, ) -from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON, Platform from homeassistant.setup import async_setup_component from .test_common import ( @@ -89,6 +89,13 @@ DEFAULT_CONFIG = { DEFAULT_CONFIG_2 = {vacuum.DOMAIN: {"platform": "mqtt", "name": "test"}} +@pytest.fixture(autouse=True) +def vacuum_platform_only(): + """Only setup the vacuum platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VACUUM]): + yield + + async def test_default_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test that the correct supported features.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 7ca10683ed7..4d8d8f24a3c 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -209,6 +209,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -251,6 +252,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]): + yield + + async def test_fail_setup_if_no_command_topic(hass, mqtt_mock_entry_no_yaml_config): """Test if command fails with command topic.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 3e1eb10d717..64733b4f0f7 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -103,6 +103,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -150,6 +151,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]): + yield + + class JsonValidator: """Helper to compare JSON.""" diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index fa2ef113976..6e271d08651 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -41,6 +41,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -90,6 +91,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]): + yield + + @pytest.mark.parametrize( "test_config", [ diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 0353702152f..1bf4183e60f 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + Platform, ) from homeassistant.setup import async_setup_component @@ -58,6 +59,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def lock_platform_only(): + """Only setup the lock platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LOCK]): + yield + + async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index 648499b8751..ea79c5cd7aa 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -23,6 +23,7 @@ from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -64,6 +65,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def number_platform_only(): + """Only setup the number platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.NUMBER]): + yield + + async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload.""" topic = "test/number" diff --git a/tests/components/mqtt/test_scene.py b/tests/components/mqtt/test_scene.py index 305389ba72d..3036565dad5 100644 --- a/tests/components/mqtt/test_scene.py +++ b/tests/components/mqtt/test_scene.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components import scene -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, STATE_UNKNOWN, Platform import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -34,6 +34,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def scene_platform_only(): + """Only setup the scene platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SCENE]): + yield + + async def test_sending_mqtt_commands(hass, mqtt_mock_entry_with_yaml_config): """Test the sending MQTT commands.""" fake_state = ha.State("scene.test", STATE_UNKNOWN) diff --git a/tests/components/mqtt/test_select.py b/tests/components/mqtt/test_select.py index 5b02c4f3a31..c22bd43b86f 100644 --- a/tests/components/mqtt/test_select.py +++ b/tests/components/mqtt/test_select.py @@ -13,7 +13,12 @@ from homeassistant.components.select import ( DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) -from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_ASSUMED_STATE, + ATTR_ENTITY_ID, + STATE_UNKNOWN, + Platform, +) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -59,6 +64,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def select_platform_only(): + """Only setup the select platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SELECT]): + yield + + async def test_run_select_setup(hass, mqtt_mock_entry_with_yaml_config): """Test that it fetches the given payload.""" topic = "test/select" diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 0cc275c9d1f..f30bcf43392 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + Platform, ) import homeassistant.core as ha from homeassistant.helpers import device_registry as dr @@ -72,6 +73,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def sensor_platform_only(): + """Only setup the sensor platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]): + yield + + async def test_setting_sensor_value_via_mqtt_message( hass, mqtt_mock_entry_with_yaml_config ): diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index 1dce382421e..c3916acb34b 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -15,6 +15,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -55,6 +56,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def siren_platform_only(): + """Only setup the siren platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SIREN]): + yield + + async def async_turn_on(hass, entity_id=ENTITY_MATCH_ALL, parameters={}) -> None: """Turn all or specified siren on.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 64545d2c140..b0b89c28646 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -31,6 +31,7 @@ from homeassistant.const import ( CONF_PLATFORM, ENTITY_MATCH_ALL, STATE_UNKNOWN, + Platform, ) from homeassistant.setup import async_setup_component @@ -87,6 +88,13 @@ DEFAULT_CONFIG_2 = { } +@pytest.fixture(autouse=True) +def vacuum_platform_only(): + """Only setup the vacuum platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.VACUUM]): + yield + + async def test_default_supported_features(hass, mqtt_mock_entry_with_yaml_config): """Test that the correct supported features.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index 7c1663b9c09..3be66f0aa90 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -1,5 +1,7 @@ """The tests for the MQTT subscription component.""" -from unittest.mock import ANY +from unittest.mock import ANY, patch + +import pytest from homeassistant.components.mqtt.subscription import ( async_prepare_subscribe_topics, @@ -11,6 +13,13 @@ from homeassistant.core import callback from tests.common import async_fire_mqtt_message +@pytest.fixture(autouse=True) +def no_platforms(): + """Skip platform setup to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", []): + yield + + async def test_subscribe_topics(hass, mqtt_mock_entry_no_yaml_config, caplog): """Test subscription to topics.""" await mqtt_mock_entry_no_yaml_config() diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 1ed8db34d8d..ba23efc859c 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -11,6 +11,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + Platform, ) import homeassistant.core as ha from homeassistant.setup import async_setup_component @@ -53,6 +54,13 @@ DEFAULT_CONFIG = { } +@pytest.fixture(autouse=True) +def switch_platform_only(): + """Only setup the switch platform to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SWITCH]): + yield + + async def test_controlling_state_via_topic(hass, mqtt_mock_entry_with_yaml_config): """Test the controlling state via topic.""" assert await async_setup_component( diff --git a/tests/components/mqtt/test_tag.py b/tests/components/mqtt/test_tag.py index 09be31011f2..f06dd6f5244 100644 --- a/tests/components/mqtt/test_tag.py +++ b/tests/components/mqtt/test_tag.py @@ -7,6 +7,7 @@ import pytest from homeassistant.components.device_automation import DeviceAutomationType from homeassistant.components.mqtt.const import DOMAIN as MQTT_DOMAIN +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -42,6 +43,13 @@ DEFAULT_TAG_SCAN_JSON = ( ) +@pytest.fixture(autouse=True) +def binary_sensor_only(): + """Only setup the binary_sensor platform to speed up test.""" + with patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]): + yield + + @pytest.fixture def device_reg(hass): """Return an empty, loaded, registry.""" diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index a4079558c34..4c0a70707eb 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -1,5 +1,5 @@ """The tests for the MQTT automation.""" -from unittest.mock import ANY +from unittest.mock import ANY, patch import pytest @@ -17,6 +17,13 @@ def calls(hass): return async_mock_service(hass, "test", "automation") +@pytest.fixture(autouse=True) +def no_platforms(): + """Skip platform setup to speed up tests.""" + with patch("homeassistant.components.mqtt.PLATFORMS", []): + yield + + @pytest.fixture(autouse=True) async def setup_comp(hass, mqtt_mock_entry_no_yaml_config): """Initialize components.""" From bb8b51eda3e3605df7db7cd92153d6c03745c802 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jun 2022 10:56:08 -1000 Subject: [PATCH 398/947] Fix typos in ConfigEntryState.recoverable (#73449) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a8b2752d2aa..2aa5b1b8c62 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -106,7 +106,7 @@ class ConfigEntryState(Enum): def recoverable(self) -> bool: """Get if the state is recoverable. - If the entry is state is recoverable, unloads + If the entry state is recoverable, unloads and reloads are allowed. """ return self._recoverable From 08b55939fbc5333a374846e6ec2bdfa1ac5ca70f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 13 Jun 2022 11:33:29 -1000 Subject: [PATCH 399/947] Avoid creating executor job in requirements if another call satisfied the requirement (#73451) --- homeassistant/requirements.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 9a8cd20983a..bd06cf61e4b 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -231,9 +231,9 @@ class RequirementsManager: async with self.pip_lock: # Recaculate missing again now that we have the lock - await self._async_process_requirements( - name, self._find_missing_requirements(requirements) - ) + missing = self._find_missing_requirements(requirements) + if missing: + await self._async_process_requirements(name, missing) def _find_missing_requirements(self, requirements: list[str]) -> list[str]: """Find requirements that are missing in the cache.""" From 4005af99aa8aa5aeb41f63a9c4ec7aa78174ec6e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 14 Jun 2022 00:26:59 +0000 Subject: [PATCH 400/947] [ci skip] Translation update --- .../airvisual/translations/sensor.sv.json | 7 ++++ .../components/asuswrt/translations/sv.json | 2 +- .../aurora_abb_powerone/translations/sv.json | 17 ++++++++++ .../azure_devops/translations/sv.json | 11 +++++++ .../binary_sensor/translations/sv.json | 8 +++-- .../cloudflare/translations/bg.json | 5 +++ .../components/cover/translations/sv.json | 4 +-- .../components/daikin/translations/bg.json | 3 +- .../dialogflow/translations/sv.json | 3 ++ .../components/efergy/translations/sv.json | 17 ++++++++++ .../eight_sleep/translations/ca.json | 19 +++++++++++ .../eight_sleep/translations/no.json | 19 +++++++++++ .../eight_sleep/translations/zh-Hant.json | 19 +++++++++++ .../components/elkm1/translations/bg.json | 3 ++ .../components/energy/translations/sv.json | 3 ++ .../enphase_envoy/translations/bg.json | 7 ++++ .../components/flume/translations/bg.json | 3 ++ .../forecast_solar/translations/sv.json | 11 +++++++ .../components/group/translations/bg.json | 5 +++ .../homewizard/translations/sv.json | 9 +++++ .../translations/sv.json | 2 +- .../components/kmtronic/translations/bg.json | 1 + .../components/meater/translations/sv.json | 3 ++ .../met_eireann/translations/bg.json | 11 +++++++ .../components/mill/translations/sv.json | 11 +++++++ .../components/motioneye/translations/bg.json | 11 +++++++ .../components/mysensors/translations/sv.json | 7 ++++ .../components/nest/translations/sv.json | 3 +- .../components/netatmo/translations/bg.json | 9 +++++ .../components/nuheat/translations/bg.json | 7 ++++ .../components/nut/translations/bg.json | 1 + .../components/octoprint/translations/sv.json | 11 +++++++ .../ovo_energy/translations/sv.json | 17 ++++++++++ .../panasonic_viera/translations/bg.json | 1 + .../components/picnic/translations/bg.json | 3 ++ .../components/powerwall/translations/sv.json | 9 +++-- .../components/rfxtrx/translations/bg.json | 3 ++ .../components/rpi_power/translations/sv.json | 12 +++++++ .../components/samsungtv/translations/sv.json | 2 +- .../components/select/translations/sv.json | 7 ++++ .../components/sensor/translations/sv.json | 33 +++++++++++++++---- .../srp_energy/translations/sv.json | 16 +++++++++ .../components/subaru/translations/bg.json | 3 ++ .../synology_dsm/translations/bg.json | 1 + .../components/threshold/translations/sv.json | 16 +++++++++ .../totalconnect/translations/bg.json | 3 +- .../components/vesync/translations/bg.json | 3 ++ .../waze_travel_time/translations/bg.json | 3 +- .../wolflink/translations/sensor.sv.json | 1 + .../xiaomi_miio/translations/sv.json | 11 +++++++ .../components/zwave_js/translations/bg.json | 1 + .../components/zwave_js/translations/sv.json | 7 ++++ 52 files changed, 383 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/airvisual/translations/sensor.sv.json create mode 100644 homeassistant/components/aurora_abb_powerone/translations/sv.json create mode 100644 homeassistant/components/azure_devops/translations/sv.json create mode 100644 homeassistant/components/efergy/translations/sv.json create mode 100644 homeassistant/components/eight_sleep/translations/ca.json create mode 100644 homeassistant/components/eight_sleep/translations/no.json create mode 100644 homeassistant/components/eight_sleep/translations/zh-Hant.json create mode 100644 homeassistant/components/energy/translations/sv.json create mode 100644 homeassistant/components/enphase_envoy/translations/bg.json create mode 100644 homeassistant/components/forecast_solar/translations/sv.json create mode 100644 homeassistant/components/homewizard/translations/sv.json create mode 100644 homeassistant/components/met_eireann/translations/bg.json create mode 100644 homeassistant/components/mill/translations/sv.json create mode 100644 homeassistant/components/motioneye/translations/bg.json create mode 100644 homeassistant/components/mysensors/translations/sv.json create mode 100644 homeassistant/components/nuheat/translations/bg.json create mode 100644 homeassistant/components/octoprint/translations/sv.json create mode 100644 homeassistant/components/ovo_energy/translations/sv.json create mode 100644 homeassistant/components/rpi_power/translations/sv.json create mode 100644 homeassistant/components/select/translations/sv.json create mode 100644 homeassistant/components/srp_energy/translations/sv.json create mode 100644 homeassistant/components/threshold/translations/sv.json create mode 100644 homeassistant/components/xiaomi_miio/translations/sv.json create mode 100644 homeassistant/components/zwave_js/translations/sv.json diff --git a/homeassistant/components/airvisual/translations/sensor.sv.json b/homeassistant/components/airvisual/translations/sensor.sv.json new file mode 100644 index 00000000000..f1fa0bbdcd8 --- /dev/null +++ b/homeassistant/components/airvisual/translations/sensor.sv.json @@ -0,0 +1,7 @@ +{ + "state": { + "airvisual__pollutant_label": { + "co": "Kolmonoxid" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/asuswrt/translations/sv.json b/homeassistant/components/asuswrt/translations/sv.json index 057a107356a..4e7196b1b21 100644 --- a/homeassistant/components/asuswrt/translations/sv.json +++ b/homeassistant/components/asuswrt/translations/sv.json @@ -31,7 +31,7 @@ "step": { "init": { "data": { - "consider_home": "Sekunder att v\u00e4nta tills attt en enhet anses borta", + "consider_home": "Sekunder att v\u00e4nta tills att en enhet anses borta", "dnsmasq": "Platsen i routern f\u00f6r dnsmasq.leases-filerna", "interface": "Gr\u00e4nssnittet som du vill ha statistik fr\u00e5n (t.ex. eth0, eth1 etc)", "require_ip": "Enheterna m\u00e5ste ha IP (f\u00f6r accesspunktsl\u00e4ge)", diff --git a/homeassistant/components/aurora_abb_powerone/translations/sv.json b/homeassistant/components/aurora_abb_powerone/translations/sv.json new file mode 100644 index 00000000000..361fc8bbbb7 --- /dev/null +++ b/homeassistant/components/aurora_abb_powerone/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "no_serial_ports": "Inga com portar funna. M\u00e5ste ha en RS485 enhet f\u00f6r att kommunicera" + }, + "error": { + "cannot_open_serial_port": "Kan inte \u00f6ppna serieporten, kontrollera och f\u00f6rs\u00f6k igen." + }, + "step": { + "user": { + "data": { + "port": "RS485 eller USB-RS485 adapter port" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/azure_devops/translations/sv.json b/homeassistant/components/azure_devops/translations/sv.json new file mode 100644 index 00000000000..e87d9570334 --- /dev/null +++ b/homeassistant/components/azure_devops/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "project": "Projekt" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/sv.json b/homeassistant/components/binary_sensor/translations/sv.json index eb23c7f12c6..58f97e77977 100644 --- a/homeassistant/components/binary_sensor/translations/sv.json +++ b/homeassistant/components/binary_sensor/translations/sv.json @@ -36,9 +36,10 @@ "is_on": "{entity_name} \u00e4r p\u00e5", "is_open": "{entity_name} \u00e4r \u00f6ppen", "is_plugged_in": "{entity_name} \u00e4r ansluten", - "is_powered": "{entity_name} \u00e4r str\u00f6mf\u00f6rd", + "is_powered": "{entity_name} \u00e4r p\u00e5slagen", "is_present": "{entity_name} \u00e4r n\u00e4rvarande", "is_problem": "{entity_name} uppt\u00e4cker problem", + "is_running": "{entity_name} k\u00f6rs", "is_smoke": "{entity_name} detekterar r\u00f6k", "is_sound": "{entity_name} uppt\u00e4cker ljud", "is_unsafe": "{entity_name} \u00e4r os\u00e4ker", @@ -72,13 +73,13 @@ "not_occupied": "{entity_name} blev inte upptagen", "not_opened": "{entity_name} st\u00e4ngd", "not_plugged_in": "{entity_name} urkopplad", - "not_powered": "{entity_name} inte str\u00f6mf\u00f6rd", + "not_powered": "{entity_name} inte p\u00e5slagen", "not_present": "{entity_name} inte n\u00e4rvarande", "not_unsafe": "{entity_name} blev s\u00e4ker", "occupied": "{entity_name} blev upptagen", "opened": "{entity_name} \u00f6ppnades", "plugged_in": "{entity_name} ansluten", - "powered": "{entity_name} str\u00f6mf\u00f6rd", + "powered": "{entity_name} p\u00e5slagen", "present": "{entity_name} n\u00e4rvarande", "problem": "{entity_name} b\u00f6rjade uppt\u00e4cka problem", "smoke": "{entity_name} b\u00f6rjade detektera r\u00f6k", @@ -90,6 +91,7 @@ } }, "device_class": { + "heat": "v\u00e4rme", "motion": "r\u00f6relse", "power": "effekt" }, diff --git a/homeassistant/components/cloudflare/translations/bg.json b/homeassistant/components/cloudflare/translations/bg.json index ec50ba10dc8..84593a40a00 100644 --- a/homeassistant/components/cloudflare/translations/bg.json +++ b/homeassistant/components/cloudflare/translations/bg.json @@ -11,6 +11,11 @@ }, "flow_title": "{name}", "step": { + "records": { + "data": { + "records": "\u0417\u0430\u043f\u0438\u0441\u0438" + } + }, "user": { "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Cloudflare" }, diff --git a/homeassistant/components/cover/translations/sv.json b/homeassistant/components/cover/translations/sv.json index a9509740330..624b5102d82 100644 --- a/homeassistant/components/cover/translations/sv.json +++ b/homeassistant/components/cover/translations/sv.json @@ -9,8 +9,8 @@ "is_closing": "{entity_name} st\u00e4ngs", "is_open": "{entity_name} \u00e4r \u00f6ppen", "is_opening": "{entity_name} \u00f6ppnas", - "is_position": "Aktuell position f\u00f6r {entity_name} \u00e4r", - "is_tilt_position": "Aktuell {entity_name} lutningsposition \u00e4r" + "is_position": "Nuvarande position f\u00f6r {entity_name} \u00e4r", + "is_tilt_position": "Nuvarande {entity_name} lutningsposition \u00e4r" }, "trigger_type": { "closed": "{entity_name} st\u00e4ngd", diff --git a/homeassistant/components/daikin/translations/bg.json b/homeassistant/components/daikin/translations/bg.json index b2d1963e4cb..a07f37ab8d5 100644 --- a/homeassistant/components/daikin/translations/bg.json +++ b/homeassistant/components/daikin/translations/bg.json @@ -5,7 +5,8 @@ "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, "error": { - "api_password": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 API \u043a\u043b\u044e\u0447 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430." + "api_password": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435, \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0439\u0442\u0435 API \u043a\u043b\u044e\u0447 \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u0430.", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { diff --git a/homeassistant/components/dialogflow/translations/sv.json b/homeassistant/components/dialogflow/translations/sv.json index 9642b4b7bec..ebae7e612d0 100644 --- a/homeassistant/components/dialogflow/translations/sv.json +++ b/homeassistant/components/dialogflow/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "webhook_not_internet_accessible": "Din Home Assistant instans m\u00e5ste kunna n\u00e5s fr\u00e5n Internet f\u00f6r att ta emot webhook meddelanden" + }, "create_entry": { "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera [webhook funktionen i Dialogflow]({dialogflow_url}).\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information." }, diff --git a/homeassistant/components/efergy/translations/sv.json b/homeassistant/components/efergy/translations/sv.json new file mode 100644 index 00000000000..b163c1a520b --- /dev/null +++ b/homeassistant/components/efergy/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "user": { + "data": { + "api_key": "API nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/ca.json b/homeassistant/components/eight_sleep/translations/ca.json new file mode 100644 index 00000000000..0abb283ce08 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/ca.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat", + "cannot_connect": "No es pot connectar amb el n\u00favol d'Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "No es pot connectar amb el n\u00favol d'Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/no.json b/homeassistant/components/eight_sleep/translations/no.json new file mode 100644 index 00000000000..715f27e2075 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/no.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert", + "cannot_connect": "Kan ikke koble til Eight Sleep-skyen: {error}" + }, + "error": { + "cannot_connect": "Kan ikke koble til Eight Sleep-skyen: {error}" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/zh-Hant.json b/homeassistant/components/eight_sleep/translations/zh-Hant.json new file mode 100644 index 00000000000..cda9624d5f0 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Eight Sleep cloud\uff1a{error}" + }, + "error": { + "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Eight Sleep cloud\uff1a{error}" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/bg.json b/homeassistant/components/elkm1/translations/bg.json index 5e83523d419..46a60e96408 100644 --- a/homeassistant/components/elkm1/translations/bg.json +++ b/homeassistant/components/elkm1/translations/bg.json @@ -5,6 +5,9 @@ "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "flow_title": "{mac_address} ({host})", "step": { "discovered_connection": { diff --git a/homeassistant/components/energy/translations/sv.json b/homeassistant/components/energy/translations/sv.json new file mode 100644 index 00000000000..168ae4ae877 --- /dev/null +++ b/homeassistant/components/energy/translations/sv.json @@ -0,0 +1,3 @@ +{ + "title": "Energi" +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/bg.json b/homeassistant/components/enphase_envoy/translations/bg.json new file mode 100644 index 00000000000..c0ccf23f5b5 --- /dev/null +++ b/homeassistant/components/enphase_envoy/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flume/translations/bg.json b/homeassistant/components/flume/translations/bg.json index 6eca91e8ed2..14aa8f088f3 100644 --- a/homeassistant/components/flume/translations/bg.json +++ b/homeassistant/components/flume/translations/bg.json @@ -9,6 +9,9 @@ "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { + "reauth_confirm": { + "description": "\u041f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0437\u0430 {username} \u0432\u0435\u0447\u0435 \u043d\u0435 \u0435 \u0432\u0430\u043b\u0438\u0434\u043d\u0430." + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/forecast_solar/translations/sv.json b/homeassistant/components/forecast_solar/translations/sv.json new file mode 100644 index 00000000000..fceb441190b --- /dev/null +++ b/homeassistant/components/forecast_solar/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "modules power": "Total maxeffekt (Watt) p\u00e5 dina solpaneler" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/group/translations/bg.json b/homeassistant/components/group/translations/bg.json index 6be0657c774..d0982fcfb66 100644 --- a/homeassistant/components/group/translations/bg.json +++ b/homeassistant/components/group/translations/bg.json @@ -8,6 +8,11 @@ }, "title": "\u041d\u043e\u0432\u0430 \u0433\u0440\u0443\u043f\u0430" }, + "fan": { + "data": { + "name": "\u0418\u043c\u0435" + } + }, "lock": { "data": { "entities": "\u0427\u043b\u0435\u043d\u043e\u0432\u0435", diff --git a/homeassistant/components/homewizard/translations/sv.json b/homeassistant/components/homewizard/translations/sv.json new file mode 100644 index 00000000000..c9bc9cd66a9 --- /dev/null +++ b/homeassistant/components/homewizard/translations/sv.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "Konfigurera enhet" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hunterdouglas_powerview/translations/sv.json b/homeassistant/components/hunterdouglas_powerview/translations/sv.json index 04371b16514..e572ec2c4a7 100644 --- a/homeassistant/components/hunterdouglas_powerview/translations/sv.json +++ b/homeassistant/components/hunterdouglas_powerview/translations/sv.json @@ -9,7 +9,7 @@ }, "step": { "link": { - "description": "Do vill du konfigurera {name} ({host})?", + "description": "Vill du konfigurera {name} ({host})?", "title": "Anslut till PowerView Hub" }, "user": { diff --git a/homeassistant/components/kmtronic/translations/bg.json b/homeassistant/components/kmtronic/translations/bg.json index a84e1c3bfdf..d152ddfcf20 100644 --- a/homeassistant/components/kmtronic/translations/bg.json +++ b/homeassistant/components/kmtronic/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { diff --git a/homeassistant/components/meater/translations/sv.json b/homeassistant/components/meater/translations/sv.json index 383fbbeb5a6..b920ba3abde 100644 --- a/homeassistant/components/meater/translations/sv.json +++ b/homeassistant/components/meater/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "service_unavailable_error": "Programmeringsgr\u00e4nssnittet g\u00e5r inte att komma \u00e5t f\u00f6r n\u00e4rvarande. F\u00f6rs\u00f6k igen senare." + }, "step": { "reauth_confirm": { "data": { diff --git a/homeassistant/components/met_eireann/translations/bg.json b/homeassistant/components/met_eireann/translations/bg.json new file mode 100644 index 00000000000..35cfa0ad1d7 --- /dev/null +++ b/homeassistant/components/met_eireann/translations/bg.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "\u0418\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mill/translations/sv.json b/homeassistant/components/mill/translations/sv.json new file mode 100644 index 00000000000..cd5effd10d3 --- /dev/null +++ b/homeassistant/components/mill/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "connection_type": "V\u00e4lj anslutningstyp" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/bg.json b/homeassistant/components/motioneye/translations/bg.json new file mode 100644 index 00000000000..02c83a6e916 --- /dev/null +++ b/homeassistant/components/motioneye/translations/bg.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "url": "URL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/sv.json b/homeassistant/components/mysensors/translations/sv.json new file mode 100644 index 00000000000..fbbcbdff5e6 --- /dev/null +++ b/homeassistant/components/mysensors/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "invalid_serial": "Ogiltig serieport" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/sv.json b/homeassistant/components/nest/translations/sv.json index d929451e504..e0fef47aaec 100644 --- a/homeassistant/components/nest/translations/sv.json +++ b/homeassistant/components/nest/translations/sv.json @@ -27,7 +27,8 @@ }, "device_automation": { "trigger_type": { - "camera_motion": "R\u00f6relse uppt\u00e4ckt" + "camera_motion": "R\u00f6relse uppt\u00e4ckt", + "camera_person": "Person detekterad" } } } \ No newline at end of file diff --git a/homeassistant/components/netatmo/translations/bg.json b/homeassistant/components/netatmo/translations/bg.json index 95a038871be..30cbd4c7167 100644 --- a/homeassistant/components/netatmo/translations/bg.json +++ b/homeassistant/components/netatmo/translations/bg.json @@ -16,5 +16,14 @@ "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f\u0442\u0430" } } + }, + "options": { + "step": { + "public_weather": { + "data": { + "area_name": "\u0418\u043c\u0435 \u043d\u0430 \u043e\u0431\u043b\u0430\u0441\u0442\u0442\u0430" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/nuheat/translations/bg.json b/homeassistant/components/nuheat/translations/bg.json new file mode 100644 index 00000000000..5d274ec2b73 --- /dev/null +++ b/homeassistant/components/nuheat/translations/bg.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nut/translations/bg.json b/homeassistant/components/nut/translations/bg.json index 4983c9a14b2..09f0ff26e5d 100644 --- a/homeassistant/components/nut/translations/bg.json +++ b/homeassistant/components/nut/translations/bg.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/octoprint/translations/sv.json b/homeassistant/components/octoprint/translations/sv.json new file mode 100644 index 00000000000..e17feb4bbe6 --- /dev/null +++ b/homeassistant/components/octoprint/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "ssl": "Anv\u00e4nd SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ovo_energy/translations/sv.json b/homeassistant/components/ovo_energy/translations/sv.json new file mode 100644 index 00000000000..054280346d3 --- /dev/null +++ b/homeassistant/components/ovo_energy/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "reauth": { + "data": { + "password": "L\u00f6senord" + } + }, + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/bg.json b/homeassistant/components/panasonic_viera/translations/bg.json index 9dc5d863d85..6433e60193d 100644 --- a/homeassistant/components/panasonic_viera/translations/bg.json +++ b/homeassistant/components/panasonic_viera/translations/bg.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "error": { diff --git a/homeassistant/components/picnic/translations/bg.json b/homeassistant/components/picnic/translations/bg.json index c0ccf23f5b5..ffb593eb287 100644 --- a/homeassistant/components/picnic/translations/bg.json +++ b/homeassistant/components/picnic/translations/bg.json @@ -2,6 +2,9 @@ "config": { "abort": { "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" } } } \ No newline at end of file diff --git a/homeassistant/components/powerwall/translations/sv.json b/homeassistant/components/powerwall/translations/sv.json index 0c6f94cd697..01b1eccd5c0 100644 --- a/homeassistant/components/powerwall/translations/sv.json +++ b/homeassistant/components/powerwall/translations/sv.json @@ -1,14 +1,19 @@ { "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, "error": { - "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", + "cannot_connect": "Det gick inte att ansluta", + "invalid_auth": "Felaktig autentisering", "unknown": "Ov\u00e4ntat fel", "wrong_version": "Powerwall anv\u00e4nder en programvaruversion som inte st\u00f6ds. T\u00e4nk p\u00e5 att uppgradera eller rapportera det h\u00e4r problemet s\u00e5 att det kan l\u00f6sas." }, "step": { "user": { "data": { - "ip_address": "IP-adress" + "ip_address": "IP-adress", + "password": "L\u00f6senord" } } } diff --git a/homeassistant/components/rfxtrx/translations/bg.json b/homeassistant/components/rfxtrx/translations/bg.json index c03ec99553e..dec08bcfaa5 100644 --- a/homeassistant/components/rfxtrx/translations/bg.json +++ b/homeassistant/components/rfxtrx/translations/bg.json @@ -35,6 +35,9 @@ "data": { "protocols": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438" } + }, + "set_device_options": { + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043e\u043f\u0446\u0438\u0438\u0442\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e" } } } diff --git a/homeassistant/components/rpi_power/translations/sv.json b/homeassistant/components/rpi_power/translations/sv.json new file mode 100644 index 00000000000..0ca2f1f5748 --- /dev/null +++ b/homeassistant/components/rpi_power/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Bara en konfiguration \u00e4r till\u00e5ten." + }, + "step": { + "confirm": { + "description": "Vill du b\u00f6rja med inst\u00e4llning?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/translations/sv.json b/homeassistant/components/samsungtv/translations/sv.json index e9c0803c865..0141800c5c0 100644 --- a/homeassistant/components/samsungtv/translations/sv.json +++ b/homeassistant/components/samsungtv/translations/sv.json @@ -5,7 +5,7 @@ "already_in_progress": "Samsung TV-konfiguration p\u00e5g\u00e5r redan.", "auth_missing": "Home Assistant har inte beh\u00f6righet att ansluta till denna Samsung TV. Kontrollera tv:ns inst\u00e4llningar f\u00f6r att godk\u00e4nna Home Assistant.", "id_missing": "Denna Samsung-enhet har inget serienummer.", - "not_supported": "Denna Samsung TV-enhet st\u00f6ds f\u00f6r n\u00e4rvarande inte.", + "not_supported": "Denna Samsung enhet st\u00f6ds f\u00f6r n\u00e4rvarande inte.", "unknown": "Ov\u00e4ntat fel" }, "flow_title": "{device}", diff --git a/homeassistant/components/select/translations/sv.json b/homeassistant/components/select/translations/sv.json new file mode 100644 index 00000000000..d388cb6c622 --- /dev/null +++ b/homeassistant/components/select/translations/sv.json @@ -0,0 +1,7 @@ +{ + "device_automation": { + "condition_type": { + "selected_option": "Nuvarande {entity_name} markerad option" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/translations/sv.json b/homeassistant/components/sensor/translations/sv.json index eeec1090a90..49c49b16c69 100644 --- a/homeassistant/components/sensor/translations/sv.json +++ b/homeassistant/components/sensor/translations/sv.json @@ -1,25 +1,44 @@ { "device_automation": { "condition_type": { - "is_battery_level": "Aktuell {entity_name} batteriniv\u00e5", - "is_humidity": "Aktuell {entity_name} fuktighet", - "is_illuminance": "Aktuell {entity_name} belysning", - "is_power": "Aktuell {entity_name} effekt", + "is_battery_level": "Nuvarande {entity_name} batteriniv\u00e5", + "is_carbon_dioxide": "Nuvarande {entity_name} koncentration av koldioxid", + "is_carbon_monoxide": "Nuvarande {entity_name} koncentration av kolmonoxid", + "is_current": "Nuvarande", + "is_energy": "Nuvarande {entity_name} energi", + "is_frequency": "Nuvarande frekvens", + "is_humidity": "Nuvarande {entity_name} fuktighet", + "is_illuminance": "Nuvarande {entity_name} belysning", + "is_nitrogen_dioxide": "Nuvarande {entity_name} koncentration av kv\u00e4vedioxid", + "is_nitrogen_monoxide": "Nuvarande {entity_name} koncentration av kv\u00e4veoxid", + "is_ozone": "Nuvarande {entity_name} koncentration av ozon", + "is_pm1": "Nuvarande {entity_name} koncentration av PM1 partiklar", + "is_pm10": "Nuvarande {entity_name} koncentration av PM10 partiklar", + "is_pm25": "Nuvarande {entity_name} koncentration av PM2.5 partiklar", + "is_power": "Nuvarande {entity_name} effekt", + "is_power_factor": "Nuvarande {entity_name} effektfaktor", "is_pressure": "Aktuellt {entity_name} tryck", - "is_signal_strength": "Aktuell {entity_name} signalstyrka", + "is_reactive_power": "Nuvarande {entity_name} reaktiv effekt", + "is_signal_strength": "Nuvarande {entity_name} signalstyrka", "is_temperature": "Aktuell {entity_name} temperatur", - "is_value": "Aktuellt {entity_name} v\u00e4rde" + "is_value": "Nuvarande {entity_name} v\u00e4rde", + "is_volatile_organic_compounds": "Nuvarande {entity_name} koncentration av flyktiga organiska \u00e4mnen", + "is_voltage": "Nuvarande {entity_name} sp\u00e4nning" }, "trigger_type": { "battery_level": "{entity_name} batteriniv\u00e5 \u00e4ndras", + "energy": "Energif\u00f6r\u00e4ndringar", "humidity": "{entity_name} fuktighet \u00e4ndras", "illuminance": "{entity_name} belysning \u00e4ndras", "power": "{entity_name} effektf\u00f6r\u00e4ndringar", "power_factor": "effektfaktorf\u00f6r\u00e4ndringar", "pressure": "{entity_name} tryckf\u00f6r\u00e4ndringar", + "reactive_power": "{entity_name} reaktiv effekt\u00e4ndring", "signal_strength": "{entity_name} signalstyrka \u00e4ndras", "temperature": "{entity_name} temperaturf\u00f6r\u00e4ndringar", - "value": "{entity_name} v\u00e4rde \u00e4ndras" + "value": "{entity_name} v\u00e4rde \u00e4ndras", + "volatile_organic_compounds": "{entity_name} koncentrations\u00e4ndringar av flyktiga organiska \u00e4mnen", + "voltage": "{entity_name} sp\u00e4nningsf\u00f6r\u00e4ndringar" } }, "state": { diff --git a/homeassistant/components/srp_energy/translations/sv.json b/homeassistant/components/srp_energy/translations/sv.json new file mode 100644 index 00000000000..880970c74ff --- /dev/null +++ b/homeassistant/components/srp_energy/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "Kan inte ansluta", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/bg.json b/homeassistant/components/subaru/translations/bg.json index 00b879eca0d..a3c6d55e3e9 100644 --- a/homeassistant/components/subaru/translations/bg.json +++ b/homeassistant/components/subaru/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "incorrect_validation_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d \u043a\u043e\u0434 \u0437\u0430 \u0432\u0430\u043b\u0438\u0434\u0438\u0440\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/synology_dsm/translations/bg.json b/homeassistant/components/synology_dsm/translations/bg.json index a3a107a36e2..dcd0a5ab730 100644 --- a/homeassistant/components/synology_dsm/translations/bg.json +++ b/homeassistant/components/synology_dsm/translations/bg.json @@ -6,6 +6,7 @@ "reconfigure_successful": "\u041f\u0440\u0435\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "otp_failed": "\u0414\u0432\u0443\u0441\u0442\u0435\u043f\u0435\u043d\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u0441 \u043d\u043e\u0432 \u043a\u043e\u0434 \u0437\u0430 \u0434\u043e\u0441\u0442\u044a\u043f", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, diff --git a/homeassistant/components/threshold/translations/sv.json b/homeassistant/components/threshold/translations/sv.json new file mode 100644 index 00000000000..613b2c25412 --- /dev/null +++ b/homeassistant/components/threshold/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "need_lower_upper": "Undre och \u00f6vre gr\u00e4ns kan inte vara tomma" + } + }, + "options": { + "step": { + "init": { + "data": { + "lower": "Undre gr\u00e4ns" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/totalconnect/translations/bg.json b/homeassistant/components/totalconnect/translations/bg.json index 1858bd74b7b..e5aed3bb504 100644 --- a/homeassistant/components/totalconnect/translations/bg.json +++ b/homeassistant/components/totalconnect/translations/bg.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "step": { "locations": { diff --git a/homeassistant/components/vesync/translations/bg.json b/homeassistant/components/vesync/translations/bg.json index bb496b3422a..c435a669d5a 100644 --- a/homeassistant/components/vesync/translations/bg.json +++ b/homeassistant/components/vesync/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/waze_travel_time/translations/bg.json b/homeassistant/components/waze_travel_time/translations/bg.json index 35cfa0ad1d7..f7d35259c93 100644 --- a/homeassistant/components/waze_travel_time/translations/bg.json +++ b/homeassistant/components/waze_travel_time/translations/bg.json @@ -3,7 +3,8 @@ "step": { "user": { "data": { - "name": "\u0418\u043c\u0435" + "name": "\u0418\u043c\u0435", + "region": "\u0420\u0435\u0433\u0438\u043e\u043d" } } } diff --git a/homeassistant/components/wolflink/translations/sensor.sv.json b/homeassistant/components/wolflink/translations/sensor.sv.json index 7b55b80227e..ddfd466dce2 100644 --- a/homeassistant/components/wolflink/translations/sensor.sv.json +++ b/homeassistant/components/wolflink/translations/sensor.sv.json @@ -3,6 +3,7 @@ "wolflink__state": { "aktiviert": "Aktiverad", "aus": "Inaktiverad", + "sparen": "Ekonomi", "test": "Test" } } diff --git a/homeassistant/components/xiaomi_miio/translations/sv.json b/homeassistant/components/xiaomi_miio/translations/sv.json new file mode 100644 index 00000000000..17b92cb5058 --- /dev/null +++ b/homeassistant/components/xiaomi_miio/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "cloud": { + "data": { + "cloud_password": "Molnl\u00f6senord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/bg.json b/homeassistant/components/zwave_js/translations/bg.json index 5d2486acbc1..dd6d70483d3 100644 --- a/homeassistant/components/zwave_js/translations/bg.json +++ b/homeassistant/components/zwave_js/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "flow_title": "{name}", diff --git a/homeassistant/components/zwave_js/translations/sv.json b/homeassistant/components/zwave_js/translations/sv.json new file mode 100644 index 00000000000..ecbacaaa3b3 --- /dev/null +++ b/homeassistant/components/zwave_js/translations/sv.json @@ -0,0 +1,7 @@ +{ + "device_automation": { + "condition_type": { + "value": "Nuvarande v\u00e4rde f\u00f6r ett Z-Wave v\u00e4rde" + } + } +} \ No newline at end of file From e3b6c7a66fb580838792be72854e79478b704fb9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jun 2022 08:25:11 +0200 Subject: [PATCH 401/947] Add Home Assistant Yellow integration (#73272) Co-authored-by: Martin Hjelmare Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 2 + homeassistant/components/hassio/__init__.py | 1 + .../homeassistant_yellow/__init__.py | 35 ++++++ .../homeassistant_yellow/config_flow.py | 22 ++++ .../components/homeassistant_yellow/const.py | 3 + .../homeassistant_yellow/hardware.py | 34 ++++++ .../homeassistant_yellow/manifest.json | 9 ++ homeassistant/components/zha/config_flow.py | 51 ++++++++- homeassistant/components/zha/manifest.json | 2 +- script/hassfest/manifest.py | 1 + tests/components/hassio/test_init.py | 1 + .../homeassistant_yellow/__init__.py | 1 + .../homeassistant_yellow/conftest.py | 14 +++ .../homeassistant_yellow/test_config_flow.py | 58 ++++++++++ .../homeassistant_yellow/test_hardware.py | 89 +++++++++++++++ .../homeassistant_yellow/test_init.py | 84 ++++++++++++++ tests/components/zha/test_config_flow.py | 104 ++++++++++++++++++ 17 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homeassistant_yellow/__init__.py create mode 100644 homeassistant/components/homeassistant_yellow/config_flow.py create mode 100644 homeassistant/components/homeassistant_yellow/const.py create mode 100644 homeassistant/components/homeassistant_yellow/hardware.py create mode 100644 homeassistant/components/homeassistant_yellow/manifest.json create mode 100644 tests/components/homeassistant_yellow/__init__.py create mode 100644 tests/components/homeassistant_yellow/conftest.py create mode 100644 tests/components/homeassistant_yellow/test_config_flow.py create mode 100644 tests/components/homeassistant_yellow/test_hardware.py create mode 100644 tests/components/homeassistant_yellow/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 9a57f3e791a..2d4fb5ceebf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -447,6 +447,8 @@ build.json @home-assistant/supervisor /tests/components/home_plus_control/ @chemaaa /homeassistant/components/homeassistant/ @home-assistant/core /tests/components/homeassistant/ @home-assistant/core +/homeassistant/components/homeassistant_yellow/ @home-assistant/core +/tests/components/homeassistant_yellow/ @home-assistant/core /homeassistant/components/homekit/ @bdraco /tests/components/homekit/ @bdraco /homeassistant/components/homekit_controller/ @Jc2k @bdraco diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 0df29a6153b..cd3c704d4c9 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -215,6 +215,7 @@ HARDWARE_INTEGRATIONS = { "rpi3-64": "raspberry_pi", "rpi4": "raspberry_pi", "rpi4-64": "raspberry_pi", + "yellow": "homeassistant_yellow", } diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py new file mode 100644 index 00000000000..89a73ab769a --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -0,0 +1,35 @@ +"""The Home Assistant Yellow integration.""" +from __future__ import annotations + +from homeassistant.components.hassio import get_os_info +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a Home Assistant Yellow config entry.""" + if (os_info := get_os_info(hass)) is None: + # The hassio integration has not yet fetched data from the supervisor + raise ConfigEntryNotReady + + board: str | None + if (board := os_info.get("board")) is None or not board == "yellow": + # Not running on a Home Assistant Yellow, Home Assistant may have been migrated + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + await hass.config_entries.flow.async_init( + "zha", + context={"source": "hardware"}, + data={ + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + }, + ) + + return True diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py new file mode 100644 index 00000000000..191a28f47a4 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -0,0 +1,22 @@ +"""Config flow for the Home Assistant Yellow integration.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class HomeAssistantYellowConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Home Assistant Yellow.""" + + VERSION = 1 + + async def async_step_system(self, data: dict[str, Any] | None = None) -> FlowResult: + """Handle the initial step.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + return self.async_create_entry(title="Home Assistant Yellow", data={}) diff --git a/homeassistant/components/homeassistant_yellow/const.py b/homeassistant/components/homeassistant_yellow/const.py new file mode 100644 index 00000000000..41eae70b3f2 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/const.py @@ -0,0 +1,3 @@ +"""Constants for the Home Assistant Yellow integration.""" + +DOMAIN = "homeassistant_yellow" diff --git a/homeassistant/components/homeassistant_yellow/hardware.py b/homeassistant/components/homeassistant_yellow/hardware.py new file mode 100644 index 00000000000..aa1fe4b745b --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/hardware.py @@ -0,0 +1,34 @@ +"""The Home Assistant Yellow hardware platform.""" +from __future__ import annotations + +from homeassistant.components.hardware.models import BoardInfo, HardwareInfo +from homeassistant.components.hassio import get_os_info +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError + +BOARD_NAME = "Home Assistant Yellow" +MANUFACTURER = "homeassistant" +MODEL = "yellow" + + +@callback +def async_info(hass: HomeAssistant) -> HardwareInfo: + """Return board info.""" + if (os_info := get_os_info(hass)) is None: + raise HomeAssistantError + board: str | None + if (board := os_info.get("board")) is None: + raise HomeAssistantError + if not board == "yellow": + raise HomeAssistantError + + return HardwareInfo( + board=BoardInfo( + hassio_board_id=board, + manufacturer=MANUFACTURER, + model=MODEL, + revision=None, + ), + name=BOARD_NAME, + url=None, + ) diff --git a/homeassistant/components/homeassistant_yellow/manifest.json b/homeassistant/components/homeassistant_yellow/manifest.json new file mode 100644 index 00000000000..47e6c8e2cd8 --- /dev/null +++ b/homeassistant/components/homeassistant_yellow/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "homeassistant_yellow", + "name": "Home Assistant Yellow", + "config_flow": false, + "documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow", + "dependencies": ["hardware", "hassio"], + "codeowners": ["@home-assistant/core"], + "integration_type": "hardware" +} diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index e116954cdcb..1832424587b 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -8,7 +8,7 @@ import voluptuous as vol from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from homeassistant import config_entries -from homeassistant.components import usb, zeroconf +from homeassistant.components import onboarding, usb, zeroconf from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult @@ -36,6 +36,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Initialize flow instance.""" self._device_path = None + self._device_settings = None self._radio_type = None self._title = None @@ -242,6 +243,54 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_hardware(self, data=None): + """Handle hardware flow.""" + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + if not data: + return self.async_abort(reason="invalid_hardware_data") + if data.get("radio_type") != "efr32": + return self.async_abort(reason="invalid_hardware_data") + self._radio_type = RadioType.ezsp.name + app_cls = RadioType[self._radio_type].controller + + schema = { + vol.Required( + CONF_DEVICE_PATH, default=self._device_path or vol.UNDEFINED + ): str + } + radio_schema = app_cls.SCHEMA_DEVICE.schema + assert not isinstance(radio_schema, vol.Schema) + + for param, value in radio_schema.items(): + if param in SUPPORTED_PORT_SETTINGS: + schema[param] = value + try: + self._device_settings = vol.Schema(schema)(data.get("port")) + except vol.Invalid: + return self.async_abort(reason="invalid_hardware_data") + + self._title = data["port"]["path"] + + self._set_confirm_only() + return await self.async_step_confirm_hardware() + + async def async_step_confirm_hardware(self, user_input=None): + """Confirm a hardware discovery.""" + if user_input is not None or not onboarding.async_is_onboarded(self.hass): + return self.async_create_entry( + title=self._title, + data={ + CONF_DEVICE: self._device_settings, + CONF_RADIO_TYPE: self._radio_type, + }, + ) + + return self.async_show_form( + step_id="confirm_hardware", + description_placeholders={CONF_NAME: self._title}, + ) + async def detect_radios(dev_path: str) -> dict[str, Any] | None: """Probe all radio types on the device port.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 023af7a8a0e..71131f240a9 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -87,7 +87,7 @@ "name": "*zigate*" } ], - "after_dependencies": ["usb", "zeroconf"], + "after_dependencies": ["onboarding", "usb", "zeroconf"], "iot_class": "local_polling", "loggers": [ "aiosqlite", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 7f2e8e0d477..0cd20364533 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -56,6 +56,7 @@ NO_IOT_CLASS = [ "hardware", "history", "homeassistant", + "homeassistant_yellow", "image", "input_boolean", "input_button", diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 1569e834562..6ac3debe3d8 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -732,6 +732,7 @@ async def test_coordinator_updates(hass, caplog): ({"board": "rpi3-64"}, "raspberry_pi"), ({"board": "rpi4"}, "raspberry_pi"), ({"board": "rpi4-64"}, "raspberry_pi"), + ({"board": "yellow"}, "homeassistant_yellow"), ], ) async def test_setup_hardware_integration(hass, aioclient_mock, integration): diff --git a/tests/components/homeassistant_yellow/__init__.py b/tests/components/homeassistant_yellow/__init__.py new file mode 100644 index 00000000000..a03eed7b9b2 --- /dev/null +++ b/tests/components/homeassistant_yellow/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant Yellow integration.""" diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py new file mode 100644 index 00000000000..8700e361dc8 --- /dev/null +++ b/tests/components/homeassistant_yellow/conftest.py @@ -0,0 +1,14 @@ +"""Test fixtures for the Home Assistant Yellow integration.""" +from unittest.mock import patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_zha(): + """Mock the zha integration.""" + with patch( + "homeassistant.components.zha.async_setup_entry", + return_value=True, + ): + yield diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py new file mode 100644 index 00000000000..2e96b05a919 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -0,0 +1,58 @@ +"""Test the Home Assistant Yellow config flow.""" +from unittest.mock import patch + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_config_flow(hass: HomeAssistant) -> None: + """Test the config flow.""" + mock_integration(hass, MockModule("hassio")) + + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Home Assistant Yellow" + assert result["data"] == {} + assert result["options"] == {} + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == {} + assert config_entry.title == "Home Assistant Yellow" + + +async def test_config_flow_single_entry(hass: HomeAssistant) -> None: + """Test only a single entry is allowed.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "system"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + mock_setup_entry.assert_not_called() diff --git a/tests/components/homeassistant_yellow/test_hardware.py b/tests/components/homeassistant_yellow/test_hardware.py new file mode 100644 index 00000000000..28403334ec1 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_hardware.py @@ -0,0 +1,89 @@ +"""Test the Home Assistant Yellow hardware platform.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +async def test_hardware_info(hass: HomeAssistant, hass_ws_client) -> None: + """Test we can get the board info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value={"board": "yellow"}, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == { + "hardware": [ + { + "board": { + "hassio_board_id": "yellow", + "manufacturer": "homeassistant", + "model": "yellow", + "revision": None, + }, + "name": "Home Assistant Yellow", + "url": None, + } + ] + } + + +@pytest.mark.parametrize("os_info", [None, {"board": None}, {"board": "other"}]) +async def test_hardware_info_fail(hass: HomeAssistant, hass_ws_client, os_info) -> None: + """Test async_info raises if os_info is not as expected.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.homeassistant_yellow.hardware.get_os_info", + return_value=os_info, + ): + await client.send_json({"id": 1, "type": "hardware/info"}) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["success"] + assert msg["result"] == {"hardware": []} diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py new file mode 100644 index 00000000000..308c392ea26 --- /dev/null +++ b/tests/components/homeassistant_yellow/test_init.py @@ -0,0 +1,84 @@ +"""Test the Home Assistant Yellow integration.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.homeassistant_yellow.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, MockModule, mock_integration + + +@pytest.mark.parametrize( + "onboarded, num_entries, num_flows", ((False, 1, 0), (True, 0, 1)) +) +async def test_setup_entry( + hass: HomeAssistant, onboarded, num_entries, num_flows +) -> None: + """Test setup of a config entry, including setup of zha.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ) as mock_get_os_info, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=onboarded + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + assert len(hass.config_entries.async_entries("zha")) == num_entries + assert len(hass.config_entries.flow.async_progress_by_handler("zha")) == num_flows + + +async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: + """Test setup of a config entry with wrong board type.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "generic-x86-64"}, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + +async def test_setup_entry_wait_hassio(hass: HomeAssistant) -> None: + """Test setup of a config entry when hassio has not fetched os_info.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value=None, + ) as mock_get_os_info: + assert not await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + assert config_entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index dee04165c1e..df68c21b6c0 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -766,3 +766,107 @@ async def test_migration_ti_cc_to_znp(old_type, new_type, hass, config_entry): assert config_entry.version > 2 assert config_entry.data[CONF_RADIO_TYPE] == new_type + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_not_onboarded(hass): + """Test hardware flow.""" + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "/dev/ttyAMA1" + assert result["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +@patch("homeassistant.components.zha.async_setup_entry", AsyncMock(return_value=True)) +async def test_hardware_onboarded(hass): + """Test hardware flow.""" + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=True + ): + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "confirm_hardware" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "/dev/ttyAMA1" + assert result["data"] == { + CONF_DEVICE: { + CONF_BAUDRATE: 115200, + CONF_FLOWCONTROL: "hardware", + CONF_DEVICE_PATH: "/dev/ttyAMA1", + }, + CONF_RADIO_TYPE: "ezsp", + } + + +async def test_hardware_already_setup(hass): + """Test hardware flow -- already setup.""" + + MockConfigEntry( + domain=DOMAIN, data={CONF_DEVICE: {CONF_DEVICE_PATH: "/dev/ttyUSB1"}} + ).add_to_hass(hass) + + data = { + "radio_type": "efr32", + "port": { + "path": "/dev/ttyAMA1", + "baudrate": 115200, + "flow_control": "hardware", + }, + } + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +@pytest.mark.parametrize( + "data", (None, {}, {"radio_type": "best_radio"}, {"radio_type": "efr32"}) +) +async def test_hardware_invalid_data(hass, data): + """Test onboarding flow -- invalid data.""" + + result = await hass.config_entries.flow.async_init( + "zha", context={"source": "hardware"}, data=data + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "invalid_hardware_data" From 3da3503673186458014db71289c940643bcd5222 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jun 2022 09:40:57 +0200 Subject: [PATCH 402/947] Add temperature unit conversion support to NumberEntity (#73233) * Add temperature unit conversion to number * Remove type enforcements * Lint * Fix legacy unit_of_measurement * Address review comments * Fix unit_of_measurement, improve test coverage --- homeassistant/components/number/__init__.py | 248 ++++++++- tests/components/number/test_init.py | 499 +++++++++++++++++- .../custom_components/test/number.py | 54 +- 3 files changed, 779 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 47a80f00561..75f98447865 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -1,16 +1,20 @@ """Component to allow numeric input for platforms.""" from __future__ import annotations +from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass from datetime import timedelta +import inspect import logging +from math import ceil, floor from typing import Any, final import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MODE +from homeassistant.const import ATTR_MODE, TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -19,6 +23,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType +from homeassistant.util import temperature as temperature_util from .const import ( ATTR_MAX, @@ -41,6 +46,13 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) _LOGGER = logging.getLogger(__name__) +class NumberDeviceClass(StrEnum): + """Device class for numbers.""" + + # temperature (C/F) + TEMPERATURE = "temperature" + + class NumberMode(StrEnum): """Modes for number entities.""" @@ -49,6 +61,11 @@ class NumberMode(StrEnum): SLIDER = "slider" +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + NumberDeviceClass.TEMPERATURE: temperature_util.convert, +} + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Number entities.""" component = hass.data[DOMAIN] = EntityComponent( @@ -72,7 +89,15 @@ async def async_set_value(entity: NumberEntity, service_call: ServiceCall) -> No raise ValueError( f"Value {value} for {entity.name} is outside valid range {entity.min_value} - {entity.max_value}" ) - await entity.async_set_value(value) + try: + native_value = entity.convert_to_native_value(value) + # Clamp to the native range + native_value = min( + max(native_value, entity.native_min_value), entity.native_max_value + ) + await entity.async_set_native_value(native_value) + except NotImplementedError: + await entity.async_set_value(value) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -93,8 +118,56 @@ class NumberEntityDescription(EntityDescription): max_value: float | None = None min_value: float | None = None + native_max_value: float | None = None + native_min_value: float | None = None + native_unit_of_measurement: str | None = None + native_step: float | None = None step: float | None = None + def __post_init__(self) -> None: + """Post initialisation processing.""" + if ( + self.max_value is not None + or self.min_value is not None + or self.step is not None + or self.unit_of_measurement is not None + ): + caller = inspect.stack()[2] + module = inspect.getmodule(caller[0]) + if module and module.__file__ and "custom_components" in module.__file__: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s is setting deprecated attributes on an instance of " + "NumberEntityDescription, this is not valid and will be unsupported " + "from Home Assistant 2022.10. Please %s", + module.__name__ if module else self.__class__.__name__, + report_issue, + ) + self.native_unit_of_measurement = self.unit_of_measurement + + +def ceil_decimal(value: float, precision: float = 0) -> float: + """Return the ceiling of f with d decimals. + + This is a simple implementation which ignores floating point inexactness. + """ + factor = 10**precision + return ceil(value * factor) / factor + + +def floor_decimal(value: float, precision: float = 0) -> float: + """Return the floor of f with d decimals. + + This is a simple implementation which ignores floating point inexactness. + """ + factor = 10**precision + return floor(value * factor) / factor + class NumberEntity(Entity): """Representation of a Number entity.""" @@ -106,6 +179,12 @@ class NumberEntity(Entity): _attr_step: float _attr_mode: NumberMode = NumberMode.AUTO _attr_value: float + _attr_native_max_value: float + _attr_native_min_value: float + _attr_native_step: float + _attr_native_value: float + _attr_native_unit_of_measurement: str | None + _deprecated_number_entity_reported = False @property def capability_attributes(self) -> dict[str, Any]: @@ -117,40 +196,84 @@ class NumberEntity(Entity): ATTR_MODE: self.mode, } + @property + def native_min_value(self) -> float: + """Return the minimum value.""" + if hasattr(self, "_attr_native_min_value"): + return self._attr_native_min_value + if ( + hasattr(self, "entity_description") + and self.entity_description.native_min_value is not None + ): + return self.entity_description.native_min_value + return DEFAULT_MIN_VALUE + @property def min_value(self) -> float: """Return the minimum value.""" if hasattr(self, "_attr_min_value"): + self._report_deprecated_number_entity() return self._attr_min_value if ( hasattr(self, "entity_description") and self.entity_description.min_value is not None ): + self._report_deprecated_number_entity() return self.entity_description.min_value - return DEFAULT_MIN_VALUE + return self._convert_to_state_value(self.native_min_value, floor_decimal) + + @property + def native_max_value(self) -> float: + """Return the maximum value.""" + if hasattr(self, "_attr_native_max_value"): + return self._attr_native_max_value + if ( + hasattr(self, "entity_description") + and self.entity_description.native_max_value is not None + ): + return self.entity_description.native_max_value + return DEFAULT_MAX_VALUE @property def max_value(self) -> float: """Return the maximum value.""" if hasattr(self, "_attr_max_value"): + self._report_deprecated_number_entity() return self._attr_max_value if ( hasattr(self, "entity_description") and self.entity_description.max_value is not None ): + self._report_deprecated_number_entity() return self.entity_description.max_value - return DEFAULT_MAX_VALUE + return self._convert_to_state_value(self.native_max_value, ceil_decimal) + + @property + def native_step(self) -> float | None: + """Return the increment/decrement step.""" + if hasattr(self, "_attr_native_step"): + return self._attr_native_step + if ( + hasattr(self, "entity_description") + and self.entity_description.native_step is not None + ): + return self.entity_description.native_step + return None @property def step(self) -> float: """Return the increment/decrement step.""" if hasattr(self, "_attr_step"): + self._report_deprecated_number_entity() return self._attr_step if ( hasattr(self, "entity_description") and self.entity_description.step is not None ): + self._report_deprecated_number_entity() return self.entity_description.step + if (native_step := self.native_step) is not None: + return native_step step = DEFAULT_STEP value_range = abs(self.max_value - self.min_value) if value_range != 0: @@ -169,10 +292,59 @@ class NumberEntity(Entity): """Return the entity state.""" return self.value + @property + def native_unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, if any.""" + if hasattr(self, "_attr_native_unit_of_measurement"): + return self._attr_native_unit_of_measurement + if hasattr(self, "entity_description"): + return self.entity_description.native_unit_of_measurement + return None + + @property + def unit_of_measurement(self) -> str | None: + """Return the unit of measurement of the entity, after unit conversion.""" + if hasattr(self, "_attr_unit_of_measurement"): + return self._attr_unit_of_measurement + if ( + hasattr(self, "entity_description") + and self.entity_description.unit_of_measurement is not None + ): + return self.entity_description.unit_of_measurement + + native_unit_of_measurement = self.native_unit_of_measurement + + if ( + self.device_class == NumberDeviceClass.TEMPERATURE + and native_unit_of_measurement in (TEMP_CELSIUS, TEMP_FAHRENHEIT) + ): + return self.hass.config.units.temperature_unit + + return native_unit_of_measurement + + @property + def native_value(self) -> float | None: + """Return the value reported by the sensor.""" + return self._attr_native_value + @property def value(self) -> float | None: """Return the entity value to represent the entity state.""" - return self._attr_value + if hasattr(self, "_attr_value"): + self._report_deprecated_number_entity() + return self._attr_value + + if (native_value := self.native_value) is None: + return native_value + return self._convert_to_state_value(native_value, round) + + def set_native_value(self, value: float) -> None: + """Set new value.""" + raise NotImplementedError() + + async def async_set_native_value(self, value: float) -> None: + """Set new value.""" + await self.hass.async_add_executor_job(self.set_native_value, value) def set_value(self, value: float) -> None: """Set new value.""" @@ -181,3 +353,69 @@ class NumberEntity(Entity): async def async_set_value(self, value: float) -> None: """Set new value.""" await self.hass.async_add_executor_job(self.set_value, value) + + def _convert_to_state_value(self, value: float, method: Callable) -> float: + """Convert a value in the number's native unit to the configured unit.""" + + native_unit_of_measurement = self.native_unit_of_measurement + unit_of_measurement = self.unit_of_measurement + device_class = self.device_class + + if ( + native_unit_of_measurement != unit_of_measurement + and device_class in UNIT_CONVERSIONS + ): + assert native_unit_of_measurement + assert unit_of_measurement + + value_s = str(value) + prec = len(value_s) - value_s.index(".") - 1 if "." in value_s else 0 + + # Suppress ValueError (Could not convert value to float) + with suppress(ValueError): + value_new: float = UNIT_CONVERSIONS[device_class]( + value, + native_unit_of_measurement, + unit_of_measurement, + ) + + # Round to the wanted precision + value = method(value_new, prec) + + return value + + def convert_to_native_value(self, value: float) -> float: + """Convert a value to the number's native unit.""" + + native_unit_of_measurement = self.native_unit_of_measurement + unit_of_measurement = self.unit_of_measurement + device_class = self.device_class + + if ( + value is not None + and native_unit_of_measurement != unit_of_measurement + and device_class in UNIT_CONVERSIONS + ): + assert native_unit_of_measurement + assert unit_of_measurement + + value = UNIT_CONVERSIONS[device_class]( + value, + unit_of_measurement, + native_unit_of_measurement, + ) + + return value + + def _report_deprecated_number_entity(self) -> None: + """Report that the number entity has not been upgraded.""" + if not self._deprecated_number_entity_reported: + self._deprecated_number_entity_reported = True + report_issue = self._suggest_report_issue() + _LOGGER.warning( + "Entity %s (%s) is using deprecated NumberEntity features which will " + "be unsupported from Home Assistant Core 2022.10, please %s", + self.entity_id, + type(self), + report_issue, + ) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 8fdf03a7d7b..ccc6f0da0c5 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -4,19 +4,138 @@ from unittest.mock import MagicMock import pytest from homeassistant.components.number import ( + ATTR_MAX, + ATTR_MIN, ATTR_STEP, ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE, + NumberDeviceClass, NumberEntity, + NumberEntityDescription, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_UNIT_OF_MEASUREMENT, + CONF_PLATFORM, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, ) -from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM class MockDefaultNumberEntity(NumberEntity): - """Mock NumberEntity device to use in tests.""" + """Mock NumberEntity device to use in tests. + + This class falls back on defaults for min_value, max_value, step. + """ + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntity(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value as overridden methods. + Step is calculated based on the smaller max_value and min_value. + """ + + @property + def native_max_value(self) -> float: + """Return the max value.""" + return 0.5 + + @property + def native_min_value(self) -> float: + """Return the min value.""" + return -0.5 + + @property + def native_unit_of_measurement(self): + """Return the current value.""" + return "native_cats" + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntityAttr(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by setting _attr members. + Step is calculated based on the smaller max_value and min_value. + """ + + _attr_native_max_value = 1000.0 + _attr_native_min_value = -1000.0 + _attr_native_step = 100.0 + _attr_native_unit_of_measurement = "native_dogs" + _attr_native_value = 500.0 + + +class MockNumberEntityDescr(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by entity description. + Step is calculated based on the smaller max_value and min_value. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + native_max_value=10.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement="native_rabbits", + ) + + @property + def native_value(self): + """Return the current value.""" + return None + + +class MockDefaultNumberEntityDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class falls back on defaults for min_value, max_value, step. + """ + + @property + def native_value(self): + """Return the current value.""" + return 0.5 + + +class MockNumberEntityDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value as overridden methods. + Step is calculated based on the smaller max_value and min_value. + """ + + @property + def max_value(self) -> float: + """Return the max value.""" + return 0.5 + + @property + def min_value(self) -> float: + """Return the min value.""" + return -0.5 + + @property + def unit_of_measurement(self): + """Return the current value.""" + return "cats" @property def value(self): @@ -24,13 +143,36 @@ class MockDefaultNumberEntity(NumberEntity): return 0.5 -class MockNumberEntity(NumberEntity): - """Mock NumberEntity device to use in tests.""" +class MockNumberEntityAttrDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. - @property - def max_value(self) -> float: - """Return the max value.""" - return 1.0 + This class customizes min_value, max_value by setting _attr members. + Step is calculated based on the smaller max_value and min_value. + """ + + _attr_max_value = 1000.0 + _attr_min_value = -1000.0 + _attr_step = 100.0 + _attr_unit_of_measurement = "dogs" + _attr_value = 500.0 + + +class MockNumberEntityDescrDeprecated(NumberEntity): + """Mock NumberEntity device to use in tests. + + This class customizes min_value, max_value by entity description. + Step is calculated based on the smaller max_value and min_value. + """ + + def __init__(self): + """Initialize the clas instance.""" + self.entity_description = NumberEntityDescription( + "test", + max_value=10.0, + min_value=-10.0, + step=2.0, + unit_of_measurement="rabbits", + ) @property def value(self): @@ -41,12 +183,97 @@ class MockNumberEntity(NumberEntity): async def test_step(hass: HomeAssistant) -> None: """Test the step calculation.""" number = MockDefaultNumberEntity() + number.hass = hass assert number.step == 1.0 number_2 = MockNumberEntity() + number_2.hass = hass assert number_2.step == 0.1 +async def test_attributes(hass: HomeAssistant) -> None: + """Test the attributes.""" + number = MockDefaultNumberEntity() + number.hass = hass + assert number.max_value == 100.0 + assert number.min_value == 0.0 + assert number.step == 1.0 + assert number.unit_of_measurement is None + assert number.value == 0.5 + + number_2 = MockNumberEntity() + number_2.hass = hass + assert number_2.max_value == 0.5 + assert number_2.min_value == -0.5 + assert number_2.step == 0.1 + assert number_2.unit_of_measurement == "native_cats" + assert number_2.value == 0.5 + + number_3 = MockNumberEntityAttr() + number_3.hass = hass + assert number_3.max_value == 1000.0 + assert number_3.min_value == -1000.0 + assert number_3.step == 100.0 + assert number_3.unit_of_measurement == "native_dogs" + assert number_3.value == 500.0 + + number_4 = MockNumberEntityDescr() + number_4.hass = hass + assert number_4.max_value == 10.0 + assert number_4.min_value == -10.0 + assert number_4.step == 2.0 + assert number_4.unit_of_measurement == "native_rabbits" + assert number_4.value is None + + +async def test_attributes_deprecated(hass: HomeAssistant, caplog) -> None: + """Test overriding the deprecated attributes.""" + number = MockDefaultNumberEntityDeprecated() + number.hass = hass + assert number.max_value == 100.0 + assert number.min_value == 0.0 + assert number.step == 1.0 + assert number.unit_of_measurement is None + assert number.value == 0.5 + + number_2 = MockNumberEntityDeprecated() + number_2.hass = hass + assert number_2.max_value == 0.5 + assert number_2.min_value == -0.5 + assert number_2.step == 0.1 + assert number_2.unit_of_measurement == "cats" + assert number_2.value == 0.5 + + number_3 = MockNumberEntityAttrDeprecated() + number_3.hass = hass + assert number_3.max_value == 1000.0 + assert number_3.min_value == -1000.0 + assert number_3.step == 100.0 + assert number_3.unit_of_measurement == "dogs" + assert number_3.value == 500.0 + + number_4 = MockNumberEntityDescrDeprecated() + number_4.hass = hass + assert number_4.max_value == 10.0 + assert number_4.min_value == -10.0 + assert number_4.step == 2.0 + assert number_4.unit_of_measurement == "rabbits" + assert number_4.value == 0.5 + + assert ( + "Entity None () " + "is using deprecated NumberEntity features" in caplog.text + ) + assert ( + "Entity None () " + "is using deprecated NumberEntity features" in caplog.text + ) + assert ( + "tests.components.number.test_init is setting deprecated attributes on an " + "instance of NumberEntityDescription" in caplog.text + ) + + async def test_sync_set_value(hass: HomeAssistant) -> None: """Test if async set_value calls sync set_value.""" number = MockDefaultNumberEntity() @@ -59,9 +286,7 @@ async def test_sync_set_value(hass: HomeAssistant) -> None: assert number.set_value.call_args[0][0] == 42 -async def test_custom_integration_and_validation( - hass: HomeAssistant, enable_custom_integrations: None -) -> None: +async def test_set_value(hass: HomeAssistant, enable_custom_integrations: None) -> None: """Test we can only set valid values.""" platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() @@ -79,9 +304,8 @@ async def test_custom_integration_and_validation( {ATTR_VALUE: 60.0, ATTR_ENTITY_ID: "number.test"}, blocking=True, ) - - hass.states.async_set("number.test", 60.0) await hass.async_block_till_done() + state = hass.states.get("number.test") assert state.state == "60.0" @@ -97,3 +321,252 @@ async def test_custom_integration_and_validation( await hass.async_block_till_done() state = hass.states.get("number.test") assert state.state == "60.0" + + +async def test_deprecated_attributes( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test entity using deprecated attributes.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append(platform.LegacyMockNumberEntity()) + entity = platform.ENTITIES[0] + entity._attr_name = "Test" + entity._attr_max_value = 25 + entity._attr_min_value = -25 + entity._attr_step = 2.5 + entity._attr_value = 51.0 + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "51.0" + assert state.attributes.get(ATTR_MAX) == 25.0 + assert state.attributes.get(ATTR_MIN) == -25.0 + assert state.attributes.get(ATTR_STEP) == 2.5 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "0.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "0.0" + + +async def test_deprecated_methods( + hass: HomeAssistant, enable_custom_integrations: None +) -> None: + """Test entity using deprecated methods.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append( + platform.LegacyMockNumberEntity( + name="Test", + max_value=25.0, + min_value=-25.0, + step=2.5, + value=51.0, + ) + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "51.0" + assert state.attributes.get(ATTR_MAX) == 25.0 + assert state.attributes.get(ATTR_MIN) == -25.0 + assert state.attributes.get(ATTR_STEP) == 2.5 + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 0.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("number.test") + assert state.state == "0.0" + + # test ValueError trigger + with pytest.raises(ValueError): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: 110.0, ATTR_ENTITY_ID: "number.test"}, + blocking=True, + ) + + await hass.async_block_till_done() + state = hass.states.get("number.test") + assert state.state == "0.0" + + +@pytest.mark.parametrize( + "unit_system, native_unit, state_unit, initial_native_value, initial_state_value, " + "updated_native_value, updated_state_value, native_max_value, state_max_value, " + "native_min_value, state_min_value, native_step, state_step", + [ + ( + IMPERIAL_SYSTEM, + TEMP_FAHRENHEIT, + TEMP_FAHRENHEIT, + 100, + 100, + 50, + 50, + 140, + 140, + -9, + -9, + 3, + 3, + ), + ( + IMPERIAL_SYSTEM, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + 38, + 100, + 10, + 50, + 60, + 140, + -23, + -10, + 3, + 3, + ), + ( + METRIC_SYSTEM, + TEMP_FAHRENHEIT, + TEMP_CELSIUS, + 100, + 38, + 50, + 10, + 140, + 60, + -9, + -23, + 3, + 3, + ), + ( + METRIC_SYSTEM, + TEMP_CELSIUS, + TEMP_CELSIUS, + 38, + 38, + 10, + 10, + 60, + 60, + -23, + -23, + 3, + 3, + ), + ], +) +async def test_temperature_conversion( + hass, + enable_custom_integrations, + unit_system, + native_unit, + state_unit, + initial_native_value, + initial_state_value, + updated_native_value, + updated_state_value, + native_max_value, + state_max_value, + native_min_value, + state_min_value, + native_step, + state_step, +): + """Test temperature conversion.""" + hass.config.units = unit_system + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockNumberEntity( + name="Test", + native_max_value=native_max_value, + native_min_value=native_min_value, + native_step=native_step, + native_unit_of_measurement=native_unit, + native_value=initial_native_value, + device_class=NumberDeviceClass.TEMPERATURE, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(initial_state_value)) + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == state_unit + assert state.attributes[ATTR_MAX] == state_max_value + assert state.attributes[ATTR_MIN] == state_min_value + assert state.attributes[ATTR_STEP] == state_step + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: updated_state_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(updated_state_value)) + assert entity0._values["native_value"] == updated_native_value + + # Set to the minimum value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: state_min_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(state_min_value), rel=0.1) + + # Set to the maximum value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_VALUE: state_max_value, ATTR_ENTITY_ID: entity0.entity_id}, + blocking=True, + ) + + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + assert float(state.state) == pytest.approx(float(state_max_value), rel=0.1) diff --git a/tests/testing_config/custom_components/test/number.py b/tests/testing_config/custom_components/test/number.py index 93d7783d684..ac397a4d42b 100644 --- a/tests/testing_config/custom_components/test/number.py +++ b/tests/testing_config/custom_components/test/number.py @@ -13,10 +13,55 @@ ENTITIES = [] class MockNumberEntity(MockEntity, NumberEntity): - """Mock Select class.""" + """Mock number class.""" - _attr_value = 50.0 - _attr_step = 1.0 + @property + def native_max_value(self): + """Return the native native_max_value.""" + return self._handle("native_max_value") + + @property + def native_min_value(self): + """Return the native native_min_value.""" + return self._handle("native_min_value") + + @property + def native_step(self): + """Return the native native_step.""" + return self._handle("native_step") + + @property + def native_unit_of_measurement(self): + """Return the native unit_of_measurement.""" + return self._handle("native_unit_of_measurement") + + @property + def native_value(self): + """Return the native value of this sensor.""" + return self._handle("native_value") + + def set_native_value(self, value: float) -> None: + """Change the selected option.""" + self._values["native_value"] = value + + +class LegacyMockNumberEntity(MockEntity, NumberEntity): + """Mock Number class using deprecated features.""" + + @property + def max_value(self): + """Return the native max_value.""" + return self._handle("max_value") + + @property + def min_value(self): + """Return the native min_value.""" + return self._handle("min_value") + + @property + def step(self): + """Return the native step.""" + return self._handle("step") @property def value(self): @@ -25,7 +70,7 @@ class MockNumberEntity(MockEntity, NumberEntity): def set_value(self, value: float) -> None: """Change the selected option.""" - self._attr_value = value + self._values["value"] = value def init(empty=False): @@ -39,6 +84,7 @@ def init(empty=False): MockNumberEntity( name="test", unique_id=UNIQUE_NUMBER, + native_value=50.0, ), ] ) From 65378f19c88679239d5f9d8251fc66d14e6c44b1 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 14 Jun 2022 12:21:02 +0200 Subject: [PATCH 403/947] Update caldav to 0.9.1 (#73472) --- homeassistant/components/caldav/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json index e6945effca4..dc34542dffa 100644 --- a/homeassistant/components/caldav/manifest.json +++ b/homeassistant/components/caldav/manifest.json @@ -2,7 +2,7 @@ "domain": "caldav", "name": "CalDAV", "documentation": "https://www.home-assistant.io/integrations/caldav", - "requirements": ["caldav==0.9.0"], + "requirements": ["caldav==0.9.1"], "codeowners": [], "iot_class": "cloud_polling", "loggers": ["caldav", "vobject"] diff --git a/requirements_all.txt b/requirements_all.txt index 424a15040a8..0be8541de46 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -460,7 +460,7 @@ btsmarthub_devicelist==0.2.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==0.9.0 +caldav==0.9.1 # homeassistant.components.circuit circuit-webhook==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f1028446c4..bebbafa0eaa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -345,7 +345,7 @@ bsblan==0.5.0 buienradar==1.0.5 # homeassistant.components.caldav -caldav==0.9.0 +caldav==0.9.1 # homeassistant.components.co2signal co2signal==0.4.2 From 99db2a5afec7697b5bf33ce541de16592a2008d9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 14 Jun 2022 12:21:32 +0200 Subject: [PATCH 404/947] Update requests to 2.28.0 (#73406) * Update requests to 2.28.0 * Fix mypy warning * Fix Facebook messenger tests --- homeassistant/components/facebook/notify.py | 3 +-- homeassistant/components/neato/hub.py | 2 +- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_test.txt | 2 +- 6 files changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index ea8848a5af2..e205e1a66cc 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -3,7 +3,6 @@ from http import HTTPStatus import json import logging -from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol @@ -74,7 +73,7 @@ class FacebookNotificationService(BaseNotificationService): BASE_URL, data=json.dumps(body), params=payload, - headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, + headers={"Content-Type": CONTENT_TYPE_JSON}, timeout=10, ) if resp.status_code != HTTPStatus.OK: diff --git a/homeassistant/components/neato/hub.py b/homeassistant/components/neato/hub.py index cb639de4acb..6ee00b2a8b4 100644 --- a/homeassistant/components/neato/hub.py +++ b/homeassistant/components/neato/hub.py @@ -32,7 +32,7 @@ class NeatoHub: def download_map(self, url: str) -> HTTPResponse: """Download a new map image.""" - map_image_data = self.my_neato.get_map_image(url) + map_image_data: HTTPResponse = self.my_neato.get_map_image(url) return map_image_data async def async_update_entry_unique_id(self, entry: ConfigEntry) -> str: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bec79680e0d..5939a513d00 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -27,7 +27,7 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.22.0 pyyaml==6.0 -requests==2.27.1 +requests==2.28.0 scapy==2.4.5 sqlalchemy==1.4.37 typing-extensions>=3.10.0.2,<5.0 diff --git a/pyproject.toml b/pyproject.toml index cf5ac7a37c2..f17015cc9ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "pip>=21.0,<22.2", "python-slugify==4.0.1", "pyyaml==6.0", - "requests==2.27.1", + "requests==2.28.0", "typing-extensions>=3.10.0.2,<5.0", "voluptuous==0.13.1", "voluptuous-serialize==2.5.0", diff --git a/requirements.txt b/requirements.txt index fe2bf87ad25..ba7c9e4dd13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ cryptography==36.0.2 pip>=21.0,<22.2 python-slugify==4.0.1 pyyaml==6.0 -requests==2.27.1 +requests==2.28.0 typing-extensions>=3.10.0.2,<5.0 voluptuous==0.13.1 voluptuous-serialize==2.5.0 diff --git a/requirements_test.txt b/requirements_test.txt index a3986b8a754..2ccbd6ab440 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -42,6 +42,6 @@ types-pkg-resources==0.1.3 types-python-slugify==0.1.2 types-pytz==2021.1.2 types-PyYAML==5.4.6 -types-requests==2.25.1 +types-requests==2.27.30 types-toml==0.1.5 types-ujson==0.1.1 From 1ef0102f12dc8894903c6941f2b049b570a79300 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 14 Jun 2022 13:21:35 +0200 Subject: [PATCH 405/947] Add active alarm zones as select entity to Overkiz integration (#68997) * Add active zones as select entity * Clean up for PR --- homeassistant/components/overkiz/select.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/homeassistant/components/overkiz/select.py b/homeassistant/components/overkiz/select.py index 74d3b3ba282..8482932e2e4 100644 --- a/homeassistant/components/overkiz/select.py +++ b/homeassistant/components/overkiz/select.py @@ -50,6 +50,17 @@ def _select_option_memorized_simple_volume( return execute_command(OverkizCommand.SET_MEMORIZED_SIMPLE_VOLUME, option) +def _select_option_active_zone( + option: str, execute_command: Callable[..., Awaitable[None]] +) -> Awaitable[None]: + """Change the selected option for Active Zone(s).""" + # Turn alarm off when empty zone is selected + if option == "": + return execute_command(OverkizCommand.ALARM_OFF) + + return execute_command(OverkizCommand.ALARM_ZONE_ON, option) + + SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ OverkizSelectDescription( key=OverkizState.CORE_OPEN_CLOSED_PEDESTRIAN, @@ -83,6 +94,14 @@ SELECT_DESCRIPTIONS: list[OverkizSelectDescription] = [ ), entity_category=EntityCategory.CONFIG, ), + # StatefulAlarmController + OverkizSelectDescription( + key=OverkizState.CORE_ACTIVE_ZONES, + name="Active Zones", + icon="mdi:shield-lock", + options=["", "A", "B", "C", "A,B", "B,C", "A,C", "A,B,C"], + select_option=_select_option_active_zone, + ), ] SUPPORTED_STATES = {description.key: description for description in SELECT_DESCRIPTIONS} From 04c60d218327340a1bb71647f1c6a01a38a2cd97 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 14 Jun 2022 13:27:58 +0200 Subject: [PATCH 406/947] Add support for AtlanticPassAPCZoneControl to Overkiz integration (#72384) * Add support for AtlanticPassAPCZoneControl (overkiz) * Remove unneeded comments * Remove supported features * Fix new standards --- .../overkiz/climate_entities/__init__.py | 2 + .../atlantic_pass_apc_zone_control.py | 40 +++++++++++++++++++ homeassistant/components/overkiz/const.py | 1 + 3 files changed, 43 insertions(+) create mode 100644 homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index 0e98b7c7e21..e38a92b755c 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -2,7 +2,9 @@ from pyoverkiz.enums.ui import UIWidget from .atlantic_electrical_heater import AtlanticElectricalHeater +from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, + UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, } diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py new file mode 100644 index 00000000000..fc1d909390b --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/atlantic_pass_apc_zone_control.py @@ -0,0 +1,40 @@ +"""Support for Atlantic Pass APC Zone Control.""" +from typing import cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import HVACMode +from homeassistant.components.overkiz.entity import OverkizEntity +from homeassistant.const import TEMP_CELSIUS + +OVERKIZ_TO_HVAC_MODE: dict[str, str] = { + OverkizCommandParam.HEATING: HVACMode.HEAT, + OverkizCommandParam.DRYING: HVACMode.DRY, + OverkizCommandParam.COOLING: HVACMode.COOL, + OverkizCommandParam.STOP: HVACMode.OFF, +} + +HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} + + +class AtlanticPassAPCZoneControl(OverkizEntity, ClimateEntity): + """Representation of Atlantic Pass APC Zone Control.""" + + _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + _attr_temperature_unit = TEMP_CELSIUS + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return OVERKIZ_TO_HVAC_MODE[ + cast( + str, self.executor.select_state(OverkizState.IO_PASS_APC_OPERATING_MODE) + ) + ] + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_PASS_APC_OPERATING_MODE, HVAC_MODE_TO_OVERKIZ[hvac_mode] + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 8488103a238..dedf5c6d4a6 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -62,6 +62,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIClass.WINDOW: Platform.COVER, UIWidget.ALARM_PANEL_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.ATLANTIC_ELECTRICAL_HEATER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.MY_FOX_SECURITY_CAMERA: Platform.SWITCH, # widgetName, uiClass is Camera (not supported) From e08465fe8cc5d44ef5b43e96d27ef76e2f94aee9 Mon Sep 17 00:00:00 2001 From: Thibaut Date: Tue, 14 Jun 2022 15:17:40 +0200 Subject: [PATCH 407/947] =?UTF-8?q?Fix=20max=5Fvalue=20access=20for=20numb?= =?UTF-8?q?er=20platform=20in=E2=80=AFOverkiz=20(#73479)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix wrong property name --- homeassistant/components/overkiz/number.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 741c666a42a..167065e9015 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -134,7 +134,7 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): """Return the entity value to represent the entity state.""" if state := self.device.states.get(self.entity_description.key): if self.entity_description.inverted: - return self._attr_max_value - cast(float, state.value) + return self.max_value - cast(float, state.value) return cast(float, state.value) @@ -143,7 +143,7 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): async def async_set_value(self, value: float) -> None: """Set new value.""" if self.entity_description.inverted: - value = self._attr_max_value - value + value = self.max_value - value await self.executor.async_execute_command( self.entity_description.command, value From 0b7a030bd4b3f6821684cb4731bd8b77dbdef1bb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 14 Jun 2022 06:19:22 -0700 Subject: [PATCH 408/947] Fix fan support in nest, removing FAN_ONLY which isn't supported (#73422) * Fix fan support in nest, removing FAN_ONLY which isn't supported * Revert change to make supported features dynamic --- homeassistant/components/nest/climate_sdm.py | 37 ++-- tests/components/nest/test_climate_sdm.py | 195 ++++++++++--------- 2 files changed, 119 insertions(+), 113 deletions(-) diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index 8a56f78028b..6ee988b714f 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -70,6 +70,7 @@ FAN_MODE_MAP = { "OFF": FAN_OFF, } FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} +FAN_INV_MODES = list(FAN_INV_MODE_MAP) MAX_FAN_DURATION = 43200 # 15 hours is the max in the SDM API MIN_TEMP = 10 @@ -99,7 +100,7 @@ class ThermostatEntity(ClimateEntity): """Initialize ThermostatEntity.""" self._device = device self._device_info = NestDeviceInfo(device) - self._supported_features = 0 + self._attr_supported_features = 0 @property def should_poll(self) -> bool: @@ -124,7 +125,7 @@ class ThermostatEntity(ClimateEntity): async def async_added_to_hass(self) -> None: """Run when entity is added to register update signal handler.""" - self._supported_features = self._get_supported_features() + self._attr_supported_features = self._get_supported_features() self.async_on_remove( self._device.add_update_listener(self.async_write_ha_state) ) @@ -198,8 +199,6 @@ class ThermostatEntity(ClimateEntity): trait = self._device.traits[ThermostatModeTrait.NAME] if trait.mode in THERMOSTAT_MODE_MAP: hvac_mode = THERMOSTAT_MODE_MAP[trait.mode] - if hvac_mode == HVACMode.OFF and self.fan_mode == FAN_ON: - hvac_mode = HVACMode.FAN_ONLY return hvac_mode @property @@ -209,8 +208,6 @@ class ThermostatEntity(ClimateEntity): for mode in self._get_device_hvac_modes: if mode in THERMOSTAT_MODE_MAP: supported_modes.append(THERMOSTAT_MODE_MAP[mode]) - if self.supported_features & ClimateEntityFeature.FAN_MODE: - supported_modes.append(HVACMode.FAN_ONLY) return supported_modes @property @@ -252,7 +249,10 @@ class ThermostatEntity(ClimateEntity): @property def fan_mode(self) -> str: """Return the current fan mode.""" - if FanTrait.NAME in self._device.traits: + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): trait = self._device.traits[FanTrait.NAME] return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF) return FAN_OFF @@ -260,15 +260,12 @@ class ThermostatEntity(ClimateEntity): @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" - modes = [] - if FanTrait.NAME in self._device.traits: - modes = list(FAN_INV_MODE_MAP) - return modes - - @property - def supported_features(self) -> int: - """Bitmap of supported features.""" - return self._supported_features + if ( + self.supported_features & ClimateEntityFeature.FAN_MODE + and FanTrait.NAME in self._device.traits + ): + return FAN_INV_MODES + return [] def _get_supported_features(self) -> int: """Compute the bitmap of supported features from the current state.""" @@ -290,10 +287,6 @@ class ThermostatEntity(ClimateEntity): """Set new target hvac mode.""" if hvac_mode not in self.hvac_modes: raise ValueError(f"Unsupported hvac_mode '{hvac_mode}'") - if hvac_mode == HVACMode.FAN_ONLY: - # Turn the fan on but also turn off the hvac if it is on - await self.async_set_fan_mode(FAN_ON) - hvac_mode = HVACMode.OFF api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] trait = self._device.traits[ThermostatModeTrait.NAME] try: @@ -338,6 +331,10 @@ class ThermostatEntity(ClimateEntity): """Set new target fan mode.""" if fan_mode not in self.fan_modes: raise ValueError(f"Unsupported fan_mode '{fan_mode}'") + if fan_mode == FAN_ON and self.hvac_mode == HVACMode.OFF: + raise ValueError( + "Cannot turn on fan, please set an HVAC mode (e.g. heat/cool) first" + ) trait = self._device.traits[FanTrait.NAME] duration = None if fan_mode != FAN_OFF: diff --git a/tests/components/nest/test_climate_sdm.py b/tests/components/nest/test_climate_sdm.py index 123742607ad..c271687a348 100644 --- a/tests/components/nest/test_climate_sdm.py +++ b/tests/components/nest/test_climate_sdm.py @@ -33,15 +33,15 @@ from homeassistant.components.climate.const import ( FAN_ON, HVAC_MODE_COOL, HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_ECO, PRESET_NONE, PRESET_SLEEP, + ClimateEntityFeature, ) -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -794,7 +794,7 @@ async def test_thermostat_fan_off( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -806,18 +806,22 @@ async def test_thermostat_fan_off( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.state == HVAC_MODE_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_fan_on( @@ -837,7 +841,7 @@ async def test_thermostat_fan_on( }, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -849,18 +853,22 @@ async def test_thermostat_fan_on( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_cool_with_fan( @@ -895,11 +903,15 @@ async def test_thermostat_cool_with_fan( HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) async def test_thermostat_set_fan( @@ -907,6 +919,68 @@ async def test_thermostat_set_fan( setup_platform: PlatformSetup, auth: FakeAuth, create_device: CreateDevice, +) -> None: + """Test a thermostat enabling the fan.""" + create_device.create( + { + "sdm.devices.traits.Fan": { + "timerMode": "ON", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatHvac": { + "status": "OFF", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", + }, + } + ) + await setup_platform() + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON + assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) + + # Turn off fan mode + await common.async_set_fan_mode(hass, FAN_OFF) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": {"timerMode": "OFF"}, + } + + # Turn on fan mode + await common.async_set_fan_mode(hass, FAN_ON) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == DEVICE_COMMAND + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": { + "duration": "43200s", + "timerMode": "ON", + }, + } + + +async def test_thermostat_set_fan_when_off( + hass: HomeAssistant, + setup_platform: PlatformSetup, + auth: FakeAuth, + create_device: CreateDevice, ) -> None: """Test a thermostat enabling the fan.""" create_device.create( @@ -929,34 +1003,18 @@ async def test_thermostat_set_fan( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_OFF assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + | ClimateEntityFeature.FAN_MODE + ) - # Turn off fan mode - await common.async_set_fan_mode(hass, FAN_OFF) - await hass.async_block_till_done() - - assert auth.method == "post" - assert auth.url == DEVICE_COMMAND - assert auth.json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": {"timerMode": "OFF"}, - } - - # Turn on fan mode - await common.async_set_fan_mode(hass, FAN_ON) - await hass.async_block_till_done() - - assert auth.method == "post" - assert auth.url == DEVICE_COMMAND - assert auth.json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": { - "duration": "43200s", - "timerMode": "ON", - }, - } + # Fan cannot be turned on when HVAC is off + with pytest.raises(ValueError): + await common.async_set_fan_mode(hass, FAN_ON, entity_id="climate.my_thermostat") async def test_thermostat_fan_empty( @@ -994,6 +1052,10 @@ async def test_thermostat_fan_empty( } assert ATTR_FAN_MODE not in thermostat.attributes assert ATTR_FAN_MODES not in thermostat.attributes + assert thermostat.attributes[ATTR_SUPPORTED_FEATURES] == ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) # Ignores set_fan_mode since it is lacking SUPPORT_FAN_MODE await common.async_set_fan_mode(hass, FAN_ON) @@ -1018,7 +1080,7 @@ async def test_thermostat_invalid_fan_mode( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Temperature": { "ambientTemperatureCelsius": 16.2, @@ -1030,14 +1092,13 @@ async def test_thermostat_invalid_fan_mode( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_FAN_ONLY + assert thermostat.state == HVAC_MODE_COOL assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { HVAC_MODE_HEAT, HVAC_MODE_COOL, HVAC_MODE_HEAT_COOL, - HVAC_MODE_FAN_ONLY, HVAC_MODE_OFF, } assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON @@ -1048,58 +1109,6 @@ async def test_thermostat_invalid_fan_mode( await hass.async_block_till_done() -async def test_thermostat_set_hvac_fan_only( - hass: HomeAssistant, - setup_platform: PlatformSetup, - auth: FakeAuth, - create_device: CreateDevice, -) -> None: - """Test a thermostat enabling the fan via hvac_mode.""" - create_device.create( - { - "sdm.devices.traits.Fan": { - "timerMode": "OFF", - "timerTimeout": "2019-05-10T03:22:54Z", - }, - "sdm.devices.traits.ThermostatHvac": { - "status": "OFF", - }, - "sdm.devices.traits.ThermostatMode": { - "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", - }, - } - ) - await setup_platform() - - assert len(hass.states.async_all()) == 1 - thermostat = hass.states.get("climate.my_thermostat") - assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF - assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] - - await common.async_set_hvac_mode(hass, HVAC_MODE_FAN_ONLY) - await hass.async_block_till_done() - - assert len(auth.captured_requests) == 2 - - (method, url, json, headers) = auth.captured_requests.pop(0) - assert method == "post" - assert url == DEVICE_COMMAND - assert json == { - "command": "sdm.devices.commands.Fan.SetTimer", - "params": {"duration": "43200s", "timerMode": "ON"}, - } - (method, url, json, headers) = auth.captured_requests.pop(0) - assert method == "post" - assert url == DEVICE_COMMAND - assert json == { - "command": "sdm.devices.commands.ThermostatMode.SetMode", - "params": {"mode": "OFF"}, - } - - async def test_thermostat_target_temp( hass: HomeAssistant, setup_platform: PlatformSetup, @@ -1397,7 +1406,7 @@ async def test_thermostat_hvac_mode_failure( "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, "sdm.devices.traits.ThermostatMode": { "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], - "mode": "OFF", + "mode": "COOL", }, "sdm.devices.traits.Fan": { "timerMode": "OFF", @@ -1416,8 +1425,8 @@ async def test_thermostat_hvac_mode_failure( assert len(hass.states.async_all()) == 1 thermostat = hass.states.get("climate.my_thermostat") assert thermostat is not None - assert thermostat.state == HVAC_MODE_OFF - assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.state == HVAC_MODE_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_IDLE auth.responses = [aiohttp.web.Response(status=HTTPStatus.BAD_REQUEST)] with pytest.raises(HomeAssistantError): From f69ea6017d67e4937ee0570a5241be4fafb2690b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 14 Jun 2022 16:00:07 +0200 Subject: [PATCH 409/947] Add device class support to Tuya number entities (#73483) --- homeassistant/components/tuya/number.py | 49 +++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 35efd78871f..a342fe58bd2 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -3,7 +3,11 @@ from __future__ import annotations from tuya_iot import TuyaDevice, TuyaDeviceManager -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -12,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HomeAssistantTuyaData from .base import IntegerTypeData, TuyaEntity -from .const import DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import DEVICE_CLASS_UNITS, DOMAIN, TUYA_DISCOVERY_NEW, DPCode, DPType # All descriptions can be found here. Mostly the Integer data types in the # default instructions set of each category end up being a number. @@ -33,24 +37,28 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP_SET, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_SET_F, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_C, name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( key=DPCode.TEMP_BOILING_F, name="Temperature After Boiling", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), @@ -108,6 +116,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP_SET, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, ), @@ -256,6 +265,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), ), @@ -265,11 +275,13 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { NumberEntityDescription( key=DPCode.TEMP_SET, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), NumberEntityDescription( key=DPCode.TEMP_SET_F, name="Temperature", + device_class=NumberDeviceClass.TEMPERATURE, icon="mdi:thermometer-lines", ), ), @@ -329,8 +341,37 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): self._attr_max_value = self._number.max_scaled self._attr_min_value = self._number.min_scaled self._attr_step = self._number.step_scaled - if description.unit_of_measurement is None: - self._attr_unit_of_measurement = self._number.unit + + # Logic to ensure the set device class and API received Unit Of Measurement + # match Home Assistants requirements. + if ( + self.device_class is not None + and not self.device_class.startswith(DOMAIN) + and description.native_unit_of_measurement is None + ): + + # We cannot have a device class, if the UOM isn't set or the + # device class cannot be found in the validation mapping. + if ( + self.native_unit_of_measurement is None + or self.device_class not in DEVICE_CLASS_UNITS + ): + self._attr_device_class = None + return + + uoms = DEVICE_CLASS_UNITS[self.device_class] + self._uom = uoms.get(self.native_unit_of_measurement) or uoms.get( + self.native_unit_of_measurement.lower() + ) + + # Unknown unit of measurement, device class should not be used. + if self._uom is None: + self._attr_device_class = None + return + + # If we still have a device class, we should not use an icon + if self.device_class: + self._attr_icon = None @property def value(self) -> float | None: From 9b157f974d80b4d5e98562b85f846d5f9ea4ea73 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 07:46:00 -1000 Subject: [PATCH 410/947] Reduce overhead to refire events from async_track_point_in_utc_time when an asyncio timer fires early (#73295) * Reduce overhead to refire events - asyncio timers can fire early for a varity of reasons including poor clock resolution and performance. To solve this problem we re-arm async_track_point_in_utc_time and try again later when this happens. - On some platforms this means the async_track_point_in_utc_time can end up trying many times to prevent firing the timer early since as soon as it rearms it fires again and this repeats until we reach the appointed time. While there is not much we can do to prevent asyncio from firing the timer callback early, we can reduce the overhead when this happens by using avoiding creating datetime objects * tweak mocking * -vvv * fix time freeze being too broad in litterrobot * adjust --- homeassistant/helpers/event.py | 7 +++---- tests/common.py | 12 +++++++++--- tests/components/litterrobot/test_button.py | 14 +++++++------- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c9b569c6601..85cd684fca1 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1286,6 +1286,7 @@ def async_track_point_in_utc_time( """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC utc_point_in_time = dt_util.as_utc(point_in_time) + expected_fire_timestamp = dt_util.utc_to_timestamp(utc_point_in_time) # Since this is called once, we accept a HassJob so we can avoid # having to figure out how to call the action every time its called. @@ -1295,15 +1296,12 @@ def async_track_point_in_utc_time( def run_action(job: HassJob[[datetime], Awaitable[None] | None]) -> None: """Call the action.""" nonlocal cancel_callback - - now = time_tracker_utcnow() - # Depending on the available clock support (including timer hardware # and the OS kernel) it can happen that we fire a little bit too early # as measured by utcnow(). That is bad when callbacks have assumptions # about the current time. Thus, we rearm the timer for the remaining # time. - if (delta := (utc_point_in_time - now).total_seconds()) > 0: + if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) cancel_callback = hass.loop.call_later(delta, run_action, job) @@ -1474,6 +1472,7 @@ track_sunset = threaded_listener_factory(async_track_sunset) # For targeted patching in tests time_tracker_utcnow = dt_util.utcnow +time_tracker_timestamp = time.time @callback diff --git a/tests/common.py b/tests/common.py index bd0b828737b..1a29d0d6dc4 100644 --- a/tests/common.py +++ b/tests/common.py @@ -378,7 +378,10 @@ def async_fire_time_changed( ) -> None: """Fire a time changed event.""" if datetime_ is None: - datetime_ = date_util.utcnow() + utc_datetime = date_util.utcnow() + else: + utc_datetime = date_util.as_utc(datetime_) + timestamp = date_util.utc_to_timestamp(utc_datetime) for task in list(hass.loop._scheduled): if not isinstance(task, asyncio.TimerHandle): @@ -386,13 +389,16 @@ def async_fire_time_changed( if task.cancelled(): continue - mock_seconds_into_future = datetime_.timestamp() - time.time() + mock_seconds_into_future = timestamp - time.time() future_seconds = task.when() - hass.loop.time() if fire_all or mock_seconds_into_future >= future_seconds: with patch( "homeassistant.helpers.event.time_tracker_utcnow", - return_value=date_util.as_utc(datetime_), + return_value=utc_datetime, + ), patch( + "homeassistant.helpers.event.time_tracker_timestamp", + return_value=timestamp, ): task._run() task.cancel() diff --git a/tests/components/litterrobot/test_button.py b/tests/components/litterrobot/test_button.py index 3f802d0e6b2..6291558c832 100644 --- a/tests/components/litterrobot/test_button.py +++ b/tests/components/litterrobot/test_button.py @@ -14,7 +14,6 @@ from .conftest import setup_integration BUTTON_ENTITY = "button.test_reset_waste_drawer" -@freeze_time("2021-11-15 17:37:00", tz_offset=-7) async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: """Test the creation and values of the Litter-Robot button.""" await setup_integration(hass, mock_account, BUTTON_DOMAIN) @@ -29,12 +28,13 @@ async def test_button(hass: HomeAssistant, mock_account: MagicMock) -> None: assert entry assert entry.entity_category is EntityCategory.CONFIG - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: BUTTON_ENTITY}, - blocking=True, - ) + with freeze_time("2021-11-15 17:37:00", tz_offset=-7): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: BUTTON_ENTITY}, + blocking=True, + ) await hass.async_block_till_done() assert mock_account.robots[0].reset_waste_drawer.call_count == 1 mock_account.robots[0].reset_waste_drawer.assert_called_with() From 61e4b56e1905102e92de9b620751807b45657785 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 14 Jun 2022 10:55:58 -0700 Subject: [PATCH 411/947] Guard withings accessing hass.data without it being set (#73454) Co-authored-by: Martin Hjelmare --- homeassistant/components/withings/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 47702090cc0..6e8dee9a774 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -72,11 +72,12 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Withings component.""" - conf = config.get(DOMAIN, {}) - if not (conf := config.get(DOMAIN, {})): + if not (conf := config.get(DOMAIN)): + # Apply the defaults. + conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN] + hass.data[DOMAIN] = {const.CONFIG: conf} return True - # Make the config available to the oauth2 config flow. hass.data[DOMAIN] = {const.CONFIG: conf} # Setup the oauth2 config flow. From 23fa19b75a398d89fe585e8c1d26c175f2024e47 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jun 2022 19:56:27 +0200 Subject: [PATCH 412/947] Support restoring NumberEntity native_value (#73475) --- homeassistant/components/number/__init__.py | 57 ++++++++- tests/components/number/test_init.py | 117 +++++++++++++++++- .../custom_components/test/number.py | 21 +++- 3 files changed, 189 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 75f98447865..5a0bf9947f3 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress -from dataclasses import dataclass +import dataclasses from datetime import timedelta import inspect import logging @@ -22,6 +22,7 @@ from homeassistant.helpers.config_validation import ( # noqa: F401 ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity from homeassistant.helpers.typing import ConfigType from homeassistant.util import temperature as temperature_util @@ -112,7 +113,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await component.async_unload_entry(entry) -@dataclass +@dataclasses.dataclass class NumberEntityDescription(EntityDescription): """A class that describes number entities.""" @@ -324,7 +325,7 @@ class NumberEntity(Entity): @property def native_value(self) -> float | None: - """Return the value reported by the sensor.""" + """Return the value reported by the number.""" return self._attr_native_value @property @@ -419,3 +420,53 @@ class NumberEntity(Entity): type(self), report_issue, ) + + +@dataclasses.dataclass +class NumberExtraStoredData(ExtraStoredData): + """Object to hold extra stored data.""" + + native_max_value: float | None + native_min_value: float | None + native_step: float | None + native_unit_of_measurement: str | None + native_value: float | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the number data.""" + return dataclasses.asdict(self) + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> NumberExtraStoredData | None: + """Initialize a stored number state from a dict.""" + try: + return cls( + restored["native_max_value"], + restored["native_min_value"], + restored["native_step"], + restored["native_unit_of_measurement"], + restored["native_value"], + ) + except KeyError: + return None + + +class RestoreNumber(NumberEntity, RestoreEntity): + """Mixin class for restoring previous number state.""" + + @property + def extra_restore_state_data(self) -> NumberExtraStoredData: + """Return number specific state data to be restored.""" + return NumberExtraStoredData( + self.native_max_value, + self.native_min_value, + self.native_step, + self.native_unit_of_measurement, + self.native_value, + ) + + async def async_get_last_number_data(self) -> NumberExtraStoredData | None: + """Restore native_*.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + return NumberExtraStoredData.from_dict(restored_last_extra_data.as_dict()) diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index ccc6f0da0c5..0df7f79e4a4 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -21,10 +21,13 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from tests.common import mock_restore_cache_with_extra_data + class MockDefaultNumberEntity(NumberEntity): """Mock NumberEntity device to use in tests. @@ -570,3 +573,115 @@ async def test_temperature_conversion( state = hass.states.get(entity0.entity_id) assert float(state.state) == pytest.approx(float(state_max_value), rel=0.1) + + +RESTORE_DATA = { + "native_max_value": 200.0, + "native_min_value": -10.0, + "native_step": 2.0, + "native_unit_of_measurement": "°F", + "native_value": 123.0, +} + + +async def test_restore_number_save_state( + hass, + hass_storage, + enable_custom_integrations, +): + """Test RestoreNumber.""" + platform = getattr(hass.components, "test.number") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockRestoreNumber( + name="Test", + native_max_value=200.0, + native_min_value=-10.0, + native_step=2.0, + native_unit_of_measurement=TEMP_FAHRENHEIT, + native_value=123.0, + device_class=NumberDeviceClass.TEMPERATURE, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + await hass.async_block_till_done() + + # Trigger saving state + await hass.async_stop() + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity0.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == RESTORE_DATA + assert type(extra_data["native_value"]) == float + + +@pytest.mark.parametrize( + "native_max_value, native_min_value, native_step, native_value, native_value_type, extra_data, device_class, uom", + [ + ( + 200.0, + -10.0, + 2.0, + 123.0, + float, + RESTORE_DATA, + NumberDeviceClass.TEMPERATURE, + "°F", + ), + (100.0, 0.0, None, None, type(None), None, None, None), + (100.0, 0.0, None, None, type(None), {}, None, None), + (100.0, 0.0, None, None, type(None), {"beer": 123}, None, None), + ( + 100.0, + 0.0, + None, + None, + type(None), + {"native_unit_of_measurement": "°F", "native_value": {}}, + None, + None, + ), + ], +) +async def test_restore_number_restore_state( + hass, + enable_custom_integrations, + hass_storage, + native_max_value, + native_min_value, + native_step, + native_value, + native_value_type, + extra_data, + device_class, + uom, +): + """Test RestoreNumber.""" + mock_restore_cache_with_extra_data(hass, ((State("number.test", ""), extra_data),)) + + platform = getattr(hass.components, "test.number") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockRestoreNumber( + device_class=device_class, + name="Test", + native_value=None, + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component(hass, "number", {"number": {"platform": "test"}}) + await hass.async_block_till_done() + + assert hass.states.get(entity0.entity_id) + + assert entity0.native_max_value == native_max_value + assert entity0.native_min_value == native_min_value + assert entity0.native_step == native_step + assert entity0.native_value == native_value + assert type(entity0.native_value) == native_value_type + assert entity0.native_unit_of_measurement == uom diff --git a/tests/testing_config/custom_components/test/number.py b/tests/testing_config/custom_components/test/number.py index ac397a4d42b..094698923f4 100644 --- a/tests/testing_config/custom_components/test/number.py +++ b/tests/testing_config/custom_components/test/number.py @@ -3,7 +3,7 @@ Provide a mock number platform. Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, RestoreNumber from tests.common import MockEntity @@ -37,7 +37,7 @@ class MockNumberEntity(MockEntity, NumberEntity): @property def native_value(self): - """Return the native value of this sensor.""" + """Return the native value of this number.""" return self._handle("native_value") def set_native_value(self, value: float) -> None: @@ -45,6 +45,23 @@ class MockNumberEntity(MockEntity, NumberEntity): self._values["native_value"] = value +class MockRestoreNumber(MockNumberEntity, RestoreNumber): + """Mock RestoreNumber class.""" + + async def async_added_to_hass(self) -> None: + """Restore native_*.""" + await super().async_added_to_hass() + if (last_number_data := await self.async_get_last_number_data()) is None: + return + self._values["native_max_value"] = last_number_data.native_max_value + self._values["native_min_value"] = last_number_data.native_min_value + self._values["native_step"] = last_number_data.native_step + self._values[ + "native_unit_of_measurement" + ] = last_number_data.native_unit_of_measurement + self._values["native_value"] = last_number_data.native_value + + class LegacyMockNumberEntity(MockEntity, NumberEntity): """Mock Number class using deprecated features.""" From 576de9ac4052c90b8737e41110d05f06f41d000e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jun 2022 20:15:56 +0200 Subject: [PATCH 413/947] Migrate NumberEntity u-z to native_value (#73488) --- .../components/unifiprotect/number.py | 10 +-- homeassistant/components/wallbox/number.py | 8 +-- homeassistant/components/wiz/number.py | 16 ++--- homeassistant/components/wled/number.py | 16 ++--- .../components/xiaomi_miio/number.py | 70 +++++++++---------- .../components/yamaha_musiccast/number.py | 10 +-- homeassistant/components/zha/number.py | 14 ++-- homeassistant/components/zwave_js/number.py | 20 +++--- homeassistant/components/zwave_me/number.py | 4 +- 9 files changed, 84 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 4ebdd17f5c9..3ac5b673ea5 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -198,15 +198,15 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): ) -> None: """Initialize the Number Entities.""" super().__init__(data, device, description) - self._attr_max_value = self.entity_description.ufp_max - self._attr_min_value = self.entity_description.ufp_min - self._attr_step = self.entity_description.ufp_step + self._attr_native_max_value = self.entity_description.ufp_max + self._attr_native_min_value = self.entity_description.ufp_min + self._attr_native_step = self.entity_description.ufp_step @callback def _async_update_device_from_protect(self) -> None: super()._async_update_device_from_protect() - self._attr_value = self.entity_description.get_ufp_value(self.device) + self._attr_native_value = self.entity_description.get_ufp_value(self.device) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) diff --git a/homeassistant/components/wallbox/number.py b/homeassistant/components/wallbox/number.py index 1ad06145ed5..1db791fd389 100644 --- a/homeassistant/components/wallbox/number.py +++ b/homeassistant/components/wallbox/number.py @@ -28,7 +28,7 @@ NUMBER_TYPES: dict[str, WallboxNumberEntityDescription] = { CHARGER_MAX_CHARGING_CURRENT_KEY: WallboxNumberEntityDescription( key=CHARGER_MAX_CHARGING_CURRENT_KEY, name="Max. Charging Current", - min_value=6, + native_min_value=6, ), } @@ -74,17 +74,17 @@ class WallboxNumber(WallboxEntity, NumberEntity): self._attr_unique_id = f"{description.key}-{coordinator.data[CHARGER_DATA_KEY][CHARGER_SERIAL_NUMBER_KEY]}" @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum available current.""" return cast(float, self._coordinator.data[CHARGER_MAX_AVAILABLE_POWER_KEY]) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the sensor.""" return cast( Optional[float], self._coordinator.data[CHARGER_MAX_CHARGING_CURRENT_KEY] ) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the value of the entity.""" await self._coordinator.async_set_charging_current(value) diff --git a/homeassistant/components/wiz/number.py b/homeassistant/components/wiz/number.py index f7d827534b3..d2f68fcf7c3 100644 --- a/homeassistant/components/wiz/number.py +++ b/homeassistant/components/wiz/number.py @@ -48,9 +48,9 @@ async def _async_set_ratio(device: wizlight, ratio: int) -> None: NUMBERS: tuple[WizNumberEntityDescription, ...] = ( WizNumberEntityDescription( key="effect_speed", - min_value=10, - max_value=200, - step=1, + native_min_value=10, + native_max_value=200, + native_step=1, icon="mdi:speedometer", name="Effect Speed", value_fn=lambda device: cast(Optional[int], device.state.get_speed()), @@ -59,9 +59,9 @@ NUMBERS: tuple[WizNumberEntityDescription, ...] = ( ), WizNumberEntityDescription( key="dual_head_ratio", - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, icon="mdi:floor-lamp-dual", name="Dual Head Ratio", value_fn=lambda device: cast(Optional[int], device.state.get_ratio()), @@ -113,9 +113,9 @@ class WizSpeedNumber(WizEntity, NumberEntity): def _async_update_attrs(self) -> None: """Handle updating _attr values.""" if (value := self.entity_description.value_fn(self._device)) is not None: - self._attr_value = float(value) + self._attr_native_value = float(value) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the speed value.""" await self.entity_description.set_value_fn(self._device, int(value)) await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/wled/number.py b/homeassistant/components/wled/number.py index d551072e452..6c426cc44c5 100644 --- a/homeassistant/components/wled/number.py +++ b/homeassistant/components/wled/number.py @@ -41,17 +41,17 @@ NUMBERS = [ name="Speed", icon="mdi:speedometer", entity_category=EntityCategory.CONFIG, - step=1, - min_value=0, - max_value=255, + native_step=1, + native_min_value=0, + native_max_value=255, ), NumberEntityDescription( key=ATTR_INTENSITY, name="Intensity", entity_category=EntityCategory.CONFIG, - step=1, - min_value=0, - max_value=255, + native_step=1, + native_min_value=0, + native_max_value=255, ), ] @@ -93,7 +93,7 @@ class WLEDNumber(WLEDEntity, NumberEntity): return super().available @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the current WLED segment number value.""" return getattr( self.coordinator.data.state.segments[self._segment], @@ -101,7 +101,7 @@ class WLEDNumber(WLEDEntity, NumberEntity): ) @wled_exception_handler - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the WLED segment value.""" key = self.entity_description.key if key == ATTR_SPEED: diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index f47d80ead17..02855a89c1f 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -108,10 +108,10 @@ NUMBER_TYPES = { key=ATTR_MOTOR_SPEED, name="Motor Speed", icon="mdi:fast-forward-outline", - unit_of_measurement="rpm", - min_value=200, - max_value=2000, - step=10, + native_unit_of_measurement="rpm", + native_min_value=200, + native_max_value=2000, + native_step=10, available_with_device_off=False, method="async_set_motor_speed", entity_category=EntityCategory.CONFIG, @@ -120,9 +120,9 @@ NUMBER_TYPES = { key=ATTR_FAVORITE_LEVEL, name="Favorite Level", icon="mdi:star-cog", - min_value=0, - max_value=17, - step=1, + native_min_value=0, + native_max_value=17, + native_step=1, method="async_set_favorite_level", entity_category=EntityCategory.CONFIG, ), @@ -130,9 +130,9 @@ NUMBER_TYPES = { key=ATTR_FAN_LEVEL, name="Fan Level", icon="mdi:fan", - min_value=1, - max_value=3, - step=1, + native_min_value=1, + native_max_value=3, + native_step=1, method="async_set_fan_level", entity_category=EntityCategory.CONFIG, ), @@ -140,9 +140,9 @@ NUMBER_TYPES = { key=ATTR_VOLUME, name="Volume", icon="mdi:volume-high", - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, method="async_set_volume", entity_category=EntityCategory.CONFIG, ), @@ -150,10 +150,10 @@ NUMBER_TYPES = { key=ATTR_OSCILLATION_ANGLE, name="Oscillation Angle", icon="mdi:angle-acute", - unit_of_measurement=DEGREE, - min_value=1, - max_value=120, - step=1, + native_unit_of_measurement=DEGREE, + native_min_value=1, + native_max_value=120, + native_step=1, method="async_set_oscillation_angle", entity_category=EntityCategory.CONFIG, ), @@ -161,10 +161,10 @@ NUMBER_TYPES = { key=ATTR_DELAY_OFF_COUNTDOWN, name="Delay Off Countdown", icon="mdi:fan-off", - unit_of_measurement=TIME_MINUTES, - min_value=0, - max_value=480, - step=1, + native_unit_of_measurement=TIME_MINUTES, + native_min_value=0, + native_max_value=480, + native_step=1, method="async_set_delay_off_countdown", entity_category=EntityCategory.CONFIG, ), @@ -172,9 +172,9 @@ NUMBER_TYPES = { key=ATTR_LED_BRIGHTNESS, name="Led Brightness", icon="mdi:brightness-6", - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, method="async_set_led_brightness", entity_category=EntityCategory.CONFIG, ), @@ -182,9 +182,9 @@ NUMBER_TYPES = { key=ATTR_LED_BRIGHTNESS_LEVEL, name="Led Brightness", icon="mdi:brightness-6", - min_value=0, - max_value=8, - step=1, + native_min_value=0, + native_max_value=8, + native_step=1, method="async_set_led_brightness_level", entity_category=EntityCategory.CONFIG, ), @@ -192,10 +192,10 @@ NUMBER_TYPES = { key=ATTR_FAVORITE_RPM, name="Favorite Motor Speed", icon="mdi:star-cog", - unit_of_measurement="rpm", - min_value=300, - max_value=2200, - step=10, + native_unit_of_measurement="rpm", + native_min_value=300, + native_max_value=2200, + native_step=10, method="async_set_favorite_rpm", entity_category=EntityCategory.CONFIG, ), @@ -298,7 +298,7 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): """Initialize the generic Xiaomi attribute selector.""" super().__init__(name, device, entry, unique_id, coordinator) - self._attr_value = self._extract_value_from_attribute( + self._attr_native_value = self._extract_value_from_attribute( coordinator.data, description.key ) self.entity_description = description @@ -314,18 +314,18 @@ class XiaomiNumberEntity(XiaomiCoordinatedMiioEntity, NumberEntity): return False return super().available - async def async_set_value(self, value): + async def async_set_native_value(self, value): """Set an option of the miio device.""" method = getattr(self, self.entity_description.method) if await method(int(value)): - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() @callback def _handle_coordinator_update(self): """Fetch state from the device.""" # On state change the device doesn't provide the new state immediately. - self._attr_value = self._extract_value_from_attribute( + self._attr_native_value = self._extract_value_from_attribute( self.coordinator.data, self.entity_description.key ) self.async_write_ha_state() diff --git a/homeassistant/components/yamaha_musiccast/number.py b/homeassistant/components/yamaha_musiccast/number.py index 2648359f768..b05c47ce279 100644 --- a/homeassistant/components/yamaha_musiccast/number.py +++ b/homeassistant/components/yamaha_musiccast/number.py @@ -45,15 +45,15 @@ class NumberCapability(MusicCastCapabilityEntity, NumberEntity): ) -> None: """Initialize the number entity.""" super().__init__(coordinator, capability, zone_id) - self._attr_min_value = capability.value_range.minimum - self._attr_max_value = capability.value_range.maximum - self._attr_step = capability.value_range.step + self._attr_native_min_value = capability.value_range.minimum + self._attr_native_max_value = capability.value_range.maximum + self._attr_native_step = capability.value_range.step @property - def value(self): + def native_value(self): """Return the current value.""" return self.capability.current - async def async_set_value(self, value: float): + async def async_set_native_value(self, value: float): """Set a new value.""" await self.capability.set(value) diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 216b9974df6..2f674df168e 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -287,12 +287,12 @@ class ZhaNumber(ZhaEntity, NumberEntity): ) @property - def value(self): + def native_value(self): """Return the current value.""" return self._analog_output_channel.present_value @property - def min_value(self): + def native_min_value(self): """Return the minimum value.""" min_present_value = self._analog_output_channel.min_present_value if min_present_value is not None: @@ -300,7 +300,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): return 0 @property - def max_value(self): + def native_max_value(self): """Return the maximum value.""" max_present_value = self._analog_output_channel.max_present_value if max_present_value is not None: @@ -308,12 +308,12 @@ class ZhaNumber(ZhaEntity, NumberEntity): return 1023 @property - def step(self): + def native_step(self): """Return the value step.""" resolution = self._analog_output_channel.resolution if resolution is not None: return resolution - return super().step + return super().native_step @property def name(self): @@ -332,7 +332,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): return super().icon @property - def unit_of_measurement(self): + def native_unit_of_measurement(self): """Return the unit the value is expressed in.""" engineering_units = self._analog_output_channel.engineering_units return UNITS.get(engineering_units) @@ -342,7 +342,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): """Handle value update from channel.""" self.async_write_ha_state() - async def async_set_value(self, value): + async def async_set_native_value(self, value): """Update the current value from HA.""" num_value = float(value) if await self._analog_output_channel.async_set_present_value(num_value): diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index 737b872b7bc..1b17fa35024 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -71,34 +71,34 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): ) @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" if self.info.primary_value.metadata.min is None: return 0 return float(self.info.primary_value.metadata.min) @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" if self.info.primary_value.metadata.max is None: return 255 return float(self.info.primary_value.metadata.max) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value.""" if self.info.primary_value.value is None: return None return float(self.info.primary_value.value) @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement of this entity, if any.""" if self.info.primary_value.metadata.unit is None: return None return str(self.info.primary_value.metadata.unit) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" if (target_value := self._target_value) is None: raise HomeAssistantError("Missing target value on device.") @@ -121,19 +121,19 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): self.correction_factor = 1 # Entity class attributes - self._attr_min_value = 0 - self._attr_max_value = 1 - self._attr_step = 0.01 + self._attr_native_min_value = 0 + self._attr_native_max_value = 1 + self._attr_native_step = 0.01 self._attr_name = self.generate_name(include_value_name=True) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value.""" if self.info.primary_value.value is None: return None return float(self.info.primary_value.value) / self.correction_factor - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.info.node.async_set_value( self.info.primary_value, round(value * self.correction_factor) diff --git a/homeassistant/components/zwave_me/number.py b/homeassistant/components/zwave_me/number.py index efa7ceb8603..2fa82514626 100644 --- a/homeassistant/components/zwave_me/number.py +++ b/homeassistant/components/zwave_me/number.py @@ -40,13 +40,13 @@ class ZWaveMeNumber(ZWaveMeEntity, NumberEntity): """Representation of a ZWaveMe Multilevel Switch.""" @property - def value(self): + def native_value(self): """Return the unit of measurement.""" if self.device.level == 99: # Scale max value return 100 return self.device.level - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Update the current value.""" self.controller.zwave_api.send_command( self.device.id, f"exact?level={str(round(value))}" From 1f7340313acf9617fc6035fd44704c64c535d072 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 14 Jun 2022 20:16:36 +0200 Subject: [PATCH 414/947] Migrate NumberEntity a-j to native_value (#73486) --- homeassistant/components/baf/number.py | 40 ++++++++--------- homeassistant/components/deconz/number.py | 10 ++--- homeassistant/components/demo/number.py | 24 +++++----- homeassistant/components/esphome/number.py | 12 ++--- .../components/fjaraskupan/number.py | 12 ++--- homeassistant/components/flux_led/number.py | 44 +++++++++---------- homeassistant/components/goodwe/number.py | 22 +++++----- .../components/homekit_controller/number.py | 20 ++++----- homeassistant/components/juicenet/number.py | 22 +++++----- 9 files changed, 103 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 32a3ea5e693..73d60fa6c03 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -40,8 +40,8 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_min_speed", name="Auto Comfort Minimum Speed", - min_value=0, - max_value=SPEED_RANGE[1] - 1, + native_min_value=0, + native_max_value=SPEED_RANGE[1] - 1, entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(Optional[int], device.comfort_min_speed), mode=NumberMode.BOX, @@ -49,8 +49,8 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_max_speed", name="Auto Comfort Maximum Speed", - min_value=1, - max_value=SPEED_RANGE[1], + native_min_value=1, + native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(Optional[int], device.comfort_max_speed), mode=NumberMode.BOX, @@ -58,8 +58,8 @@ AUTO_COMFORT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="comfort_heat_assist_speed", name="Auto Comfort Heat Assist Speed", - min_value=SPEED_RANGE[0], - max_value=SPEED_RANGE[1], + native_min_value=SPEED_RANGE[0], + native_max_value=SPEED_RANGE[1], entity_category=EntityCategory.CONFIG, value_fn=lambda device: cast(Optional[int], device.comfort_heat_assist_speed), mode=NumberMode.BOX, @@ -70,20 +70,20 @@ FAN_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="return_to_auto_timeout", name="Return to Auto Timeout", - min_value=ONE_MIN_SECS, - max_value=HALF_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast(Optional[int], device.return_to_auto_timeout), mode=NumberMode.SLIDER, ), BAFNumberDescription( key="motion_sense_timeout", name="Motion Sense Timeout", - min_value=ONE_MIN_SECS, - max_value=ONE_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast(Optional[int], device.motion_sense_timeout), mode=NumberMode.SLIDER, ), @@ -93,10 +93,10 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_return_to_auto_timeout", name="Light Return to Auto Timeout", - min_value=ONE_MIN_SECS, - max_value=HALF_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=HALF_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast( Optional[int], device.light_return_to_auto_timeout ), @@ -105,10 +105,10 @@ LIGHT_NUMBER_DESCRIPTIONS = ( BAFNumberDescription( key="light_auto_motion_timeout", name="Light Motion Sense Timeout", - min_value=ONE_MIN_SECS, - max_value=ONE_DAY_SECS, + native_min_value=ONE_MIN_SECS, + native_max_value=ONE_DAY_SECS, entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, value_fn=lambda device: cast(Optional[int], device.light_auto_motion_timeout), mode=NumberMode.SLIDER, ), @@ -149,8 +149,8 @@ class BAFNumber(BAFEntity, NumberEntity): def _async_update_attrs(self) -> None: """Update attrs from device.""" if (value := self.entity_description.value_fn(self._device)) is not None: - self._attr_value = float(value) + self._attr_native_value = float(value) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the value.""" setattr(self._device, self.entity_description.key, int(value)) diff --git a/homeassistant/components/deconz/number.py b/homeassistant/components/deconz/number.py index a7bb014d76a..81f3e434007 100644 --- a/homeassistant/components/deconz/number.py +++ b/homeassistant/components/deconz/number.py @@ -43,9 +43,9 @@ ENTITY_DESCRIPTIONS = { value_fn=lambda device: device.delay, suffix="Delay", update_key=PRESENCE_DELAY, - max_value=65535, - min_value=0, - step=1, + native_max_value=65535, + native_min_value=0, + native_step=1, entity_category=EntityCategory.CONFIG, ) ] @@ -107,11 +107,11 @@ class DeconzNumber(DeconzDevice, NumberEntity): super().async_update_callback() @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the value of the sensor property.""" return self.entity_description.value_fn(self._device) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set sensor config.""" data = {self.entity_description.key: int(value)} await self._device.set_config(**data) diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 8660604af9e..7a9baf045e5 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -85,9 +85,9 @@ class DemoNumber(NumberEntity): state: float, icon: str, assumed: bool, - min_value: float | None = None, - max_value: float | None = None, - step: float | None = None, + native_min_value: float | None = None, + native_max_value: float | None = None, + native_step: float | None = None, mode: NumberMode = NumberMode.AUTO, ) -> None: """Initialize the Demo Number entity.""" @@ -95,15 +95,15 @@ class DemoNumber(NumberEntity): self._attr_icon = icon self._attr_name = name or DEVICE_DEFAULT_NAME self._attr_unique_id = unique_id - self._attr_value = state + self._attr_native_value = state self._attr_mode = mode - if min_value is not None: - self._attr_min_value = min_value - if max_value is not None: - self._attr_max_value = max_value - if step is not None: - self._attr_step = step + if native_min_value is not None: + self._attr_min_value = native_min_value + if native_max_value is not None: + self._attr_max_value = native_max_value + if native_step is not None: + self._attr_step = native_step self._attr_device_info = DeviceInfo( identifiers={ @@ -113,7 +113,7 @@ class DemoNumber(NumberEntity): name=self.name, ) - async def async_set_value(self, value): + async def async_set_native_value(self, value): """Update the current value.""" - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/esphome/number.py b/homeassistant/components/esphome/number.py index be27779437d..bbca463a908 100644 --- a/homeassistant/components/esphome/number.py +++ b/homeassistant/components/esphome/number.py @@ -52,22 +52,22 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): """A number implementation for esphome.""" @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return super()._static_info.min_value @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return super()._static_info.max_value @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return super()._static_info.step @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return super()._static_info.unit_of_measurement @@ -79,7 +79,7 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): return NumberMode.AUTO @esphome_state_property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the state of the entity.""" if math.isnan(self._state.state): return None @@ -87,6 +87,6 @@ class EsphomeNumber(EsphomeEntity[NumberInfo, NumberState], NumberEntity): return None return self._state.state - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await self._client.number_command(self._static_info.key, value) diff --git a/homeassistant/components/fjaraskupan/number.py b/homeassistant/components/fjaraskupan/number.py index 6314b9c9cc1..511d97cbed8 100644 --- a/homeassistant/components/fjaraskupan/number.py +++ b/homeassistant/components/fjaraskupan/number.py @@ -34,11 +34,11 @@ async def async_setup_entry( class PeriodicVentingTime(CoordinatorEntity[Coordinator], NumberEntity): """Periodic Venting.""" - _attr_max_value: float = 59 - _attr_min_value: float = 0 - _attr_step: float = 1 + _attr_native_max_value: float = 59 + _attr_native_min_value: float = 0 + _attr_native_step: float = 1 _attr_entity_category = EntityCategory.CONFIG - _attr_unit_of_measurement = TIME_MINUTES + _attr_native_unit_of_measurement = TIME_MINUTES def __init__( self, @@ -54,13 +54,13 @@ class PeriodicVentingTime(CoordinatorEntity[Coordinator], NumberEntity): self._attr_name = f"{device_info['name']} Periodic Venting" @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" if data := self.coordinator.data: return data.periodic_venting return None - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self._device.send_periodic_venting(int(value)) self.coordinator.async_set_updated_data(self._device.state) diff --git a/homeassistant/components/flux_led/number.py b/homeassistant/components/flux_led/number.py index 06f706aee21..65c8a955dcf 100644 --- a/homeassistant/components/flux_led/number.py +++ b/homeassistant/components/flux_led/number.py @@ -97,18 +97,18 @@ class FluxSpeedNumber( ): """Defines a flux_led speed number.""" - _attr_min_value = 1 - _attr_max_value = 100 - _attr_step = 1 + _attr_native_min_value = 1 + _attr_native_max_value = 100 + _attr_native_step = 1 _attr_mode = NumberMode.SLIDER _attr_icon = "mdi:speedometer" @property - def value(self) -> float: + def native_value(self) -> float: """Return the effect speed.""" return cast(float, self._device.speed) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the flux speed value.""" current_effect = self._device.effect new_speed = int(value) @@ -130,8 +130,8 @@ class FluxConfigNumber( """Base class for flux config numbers.""" _attr_entity_category = EntityCategory.CONFIG - _attr_min_value = 1 - _attr_step = 1 + _attr_native_min_value = 1 + _attr_native_step = 1 _attr_mode = NumberMode.BOX def __init__( @@ -153,18 +153,18 @@ class FluxConfigNumber( logger=_LOGGER, cooldown=DEBOUNCE_TIME, immediate=False, - function=self._async_set_value, + function=self._async_set_native_value, ) await super().async_added_to_hass() - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the value.""" self._pending_value = int(value) assert self._debouncer is not None await self._debouncer.async_call() @abstractmethod - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Call on debounce to set the value.""" def _pixels_and_segments_fit_in_music_mode(self) -> bool: @@ -189,19 +189,19 @@ class FluxPixelsPerSegmentNumber(FluxConfigNumber): _attr_icon = "mdi:dots-grid" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" return min( PIXELS_PER_SEGMENT_MAX, int(PIXELS_MAX / (self._device.segments or 1)) ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the pixels per segment.""" assert self._device.pixels_per_segment is not None return self._device.pixels_per_segment - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the pixels per segment.""" assert self._pending_value is not None await self._device.async_set_device_config( @@ -215,7 +215,7 @@ class FluxSegmentsNumber(FluxConfigNumber): _attr_icon = "mdi:segment" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" assert self._device.pixels_per_segment is not None return min( @@ -223,12 +223,12 @@ class FluxSegmentsNumber(FluxConfigNumber): ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the segments.""" assert self._device.segments is not None return self._device.segments - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the segments.""" assert self._pending_value is not None await self._device.async_set_device_config(segments=self._pending_value) @@ -249,7 +249,7 @@ class FluxMusicPixelsPerSegmentNumber(FluxMusicNumber): _attr_icon = "mdi:dots-grid" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" assert self._device.music_segments is not None return min( @@ -258,12 +258,12 @@ class FluxMusicPixelsPerSegmentNumber(FluxMusicNumber): ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the music pixels per segment.""" assert self._device.music_pixels_per_segment is not None return self._device.music_pixels_per_segment - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the music pixels per segment.""" assert self._pending_value is not None await self._device.async_set_device_config( @@ -277,7 +277,7 @@ class FluxMusicSegmentsNumber(FluxMusicNumber): _attr_icon = "mdi:segment" @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the max value.""" assert self._device.pixels_per_segment is not None return min( @@ -286,12 +286,12 @@ class FluxMusicSegmentsNumber(FluxMusicNumber): ) @property - def value(self) -> int: + def native_value(self) -> int: """Return the music segments.""" assert self._device.music_segments is not None return self._device.music_segments - async def _async_set_value(self) -> None: + async def _async_set_native_value(self) -> None: """Set the music segments.""" assert self._pending_value is not None await self._device.async_set_device_config(music_segments=self._pending_value) diff --git a/homeassistant/components/goodwe/number.py b/homeassistant/components/goodwe/number.py index 80c7885f26c..00d8d9d0cae 100644 --- a/homeassistant/components/goodwe/number.py +++ b/homeassistant/components/goodwe/number.py @@ -40,24 +40,24 @@ NUMBERS = ( name="Grid export limit", icon="mdi:transmission-tower", entity_category=EntityCategory.CONFIG, - unit_of_measurement=POWER_WATT, + native_unit_of_measurement=POWER_WATT, getter=lambda inv: inv.get_grid_export_limit(), setter=lambda inv, val: inv.set_grid_export_limit(val), - step=100, - min_value=0, - max_value=10000, + native_step=100, + native_min_value=0, + native_max_value=10000, ), GoodweNumberEntityDescription( key="battery_discharge_depth", name="Depth of discharge (on-grid)", icon="mdi:battery-arrow-down", entity_category=EntityCategory.CONFIG, - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, getter=lambda inv: inv.get_ongrid_battery_dod(), setter=lambda inv, val: inv.set_ongrid_battery_dod(val), - step=1, - min_value=0, - max_value=99, + native_step=1, + native_min_value=0, + native_max_value=99, ), ) @@ -105,12 +105,12 @@ class InverterNumberEntity(NumberEntity): self.entity_description = description self._attr_unique_id = f"{DOMAIN}-{description.key}-{inverter.serial_number}" self._attr_device_info = device_info - self._attr_value = float(current_value) + self._attr_native_value = float(current_value) self._inverter: Inverter = inverter - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" if self.entity_description.setter: await self.entity_description.setter(self._inverter, int(value)) - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() diff --git a/homeassistant/components/homekit_controller/number.py b/homeassistant/components/homekit_controller/number.py index b994bc80f4a..4e0f5cfa077 100644 --- a/homeassistant/components/homekit_controller/number.py +++ b/homeassistant/components/homekit_controller/number.py @@ -104,26 +104,26 @@ class HomeKitNumber(CharacteristicEntity, NumberEntity): return [self._char.type] @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return self._char.minValue or DEFAULT_MIN_VALUE @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return self._char.maxValue or DEFAULT_MAX_VALUE @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return self._char.minStep or DEFAULT_STEP @property - def value(self) -> float: + def native_value(self) -> float: """Return the current characteristic value.""" return self._char.value - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the characteristic to this value.""" await self.async_put_characteristics( { @@ -148,26 +148,26 @@ class HomeKitEcobeeFanModeNumber(CharacteristicEntity, NumberEntity): return f"{prefix} Fan Mode" @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return self._char.minValue or DEFAULT_MIN_VALUE @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return self._char.maxValue or DEFAULT_MAX_VALUE @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return self._char.minStep or DEFAULT_STEP @property - def value(self) -> float: + def native_value(self) -> float: """Return the current characteristic value.""" return self._char.value - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the characteristic to this value.""" # Sending the fan mode request sometimes ends up getting ignored by ecobee diff --git a/homeassistant/components/juicenet/number.py b/homeassistant/components/juicenet/number.py index 24b0ba4f42b..c7f444b83e2 100644 --- a/homeassistant/components/juicenet/number.py +++ b/homeassistant/components/juicenet/number.py @@ -29,16 +29,16 @@ class JuiceNetNumberEntityDescription( ): """An entity description for a JuiceNetNumber.""" - max_value_key: str | None = None + native_max_value_key: str | None = None NUMBER_TYPES: tuple[JuiceNetNumberEntityDescription, ...] = ( JuiceNetNumberEntityDescription( name="Amperage Limit", key="current_charging_amperage_limit", - min_value=6, - max_value_key="max_charging_amperage", - step=1, + native_min_value=6, + native_max_value_key="max_charging_amperage", + native_step=1, setter_key="set_charging_amperage_limit", ), ) @@ -80,19 +80,19 @@ class JuiceNetNumber(JuiceNetDevice, NumberEntity): self._attr_name = f"{self.device.name} {description.name}" @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the value of the entity.""" return getattr(self.device, self.entity_description.key, None) @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" - if self.entity_description.max_value_key is not None: - return getattr(self.device, self.entity_description.max_value_key) - if self.entity_description.max_value is not None: - return self.entity_description.max_value + if self.entity_description.native_max_value_key is not None: + return getattr(self.device, self.entity_description.native_max_value_key) + if self.entity_description.native_max_value is not None: + return self.entity_description.native_max_value return DEFAULT_MAX_VALUE - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" await getattr(self.device, self.entity_description.setter_key)(value) From 8e6fa54e0a297a8d933747d3025647f1006bce0f Mon Sep 17 00:00:00 2001 From: IceBotYT <34712694+IceBotYT@users.noreply.github.com> Date: Tue, 14 Jun 2022 15:08:47 -0400 Subject: [PATCH 415/947] Improve PECO integration (#73460) --- homeassistant/components/peco/__init__.py | 3 --- homeassistant/components/peco/sensor.py | 4 +--- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/peco/__init__.py b/homeassistant/components/peco/__init__.py index 6f88bf36c50..86f69213a1c 100644 --- a/homeassistant/components/peco/__init__.py +++ b/homeassistant/components/peco/__init__.py @@ -1,7 +1,6 @@ """The PECO Outage Counter integration.""" from __future__ import annotations -import asyncio from dataclasses import dataclass from datetime import timedelta from typing import Final @@ -48,8 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise UpdateFailed(f"Error fetching data: {err}") from err except BadJSONError as err: raise UpdateFailed(f"Error parsing data: {err}") from err - except asyncio.TimeoutError as err: - raise UpdateFailed(f"Timeout fetching data: {err}") from err return data coordinator = DataUpdateCoordinator( diff --git a/homeassistant/components/peco/sensor.py b/homeassistant/components/peco/sensor.py index dfd354f5c03..5afc300bfa8 100644 --- a/homeassistant/components/peco/sensor.py +++ b/homeassistant/components/peco/sensor.py @@ -93,10 +93,8 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( - [PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST], - True, + PecoSensor(sensor, county, coordinator) for sensor in SENSOR_LIST ) - return class PecoSensor( From a0ed54465f25b4c6a4b78495174f8b18128a43d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 10:02:45 -1000 Subject: [PATCH 416/947] Migrate lutron caseta occupancygroup unique ids so they are actually unique (#73378) --- .../components/lutron_caseta/__init__.py | 35 +++++++++++++++++-- .../components/lutron_caseta/binary_sensor.py | 13 +++---- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index d915c2a45cb..27d8ad87861 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -6,6 +6,7 @@ import contextlib from itertools import chain import logging import ssl +from typing import Any import async_timeout from pylutron_caseta import BUTTON_STATUS_PRESSED @@ -16,7 +17,7 @@ from homeassistant import config_entries from homeassistant.const import ATTR_DEVICE_ID, ATTR_SUGGESTED_AREA, CONF_HOST, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import device_registry as dr, entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.typing import ConfigType @@ -106,6 +107,33 @@ async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool: return True +async def _async_migrate_unique_ids( + hass: HomeAssistant, entry: config_entries.ConfigEntry +) -> None: + """Migrate entities since the occupancygroup were not actually unique.""" + + dev_reg = dr.async_get(hass) + bridge_unique_id = entry.unique_id + + @callback + def _async_migrator(entity_entry: er.RegistryEntry) -> dict[str, Any] | None: + if not (unique_id := entity_entry.unique_id): + return None + if not unique_id.startswith("occupancygroup_") or unique_id.startswith( + f"occupancygroup_{bridge_unique_id}" + ): + return None + sensor_id = unique_id.split("_")[1] + new_unique_id = f"occupancygroup_{bridge_unique_id}_{sensor_id}" + if dev_entry := dev_reg.async_get_device({(DOMAIN, unique_id)}): + dev_reg.async_update_device( + dev_entry.id, new_identifiers={(DOMAIN, new_unique_id)} + ) + return {"new_unique_id": f"occupancygroup_{bridge_unique_id}_{sensor_id}"} + + await er.async_migrate_entries(hass, entry.entry_id, _async_migrator) + + async def async_setup_entry( hass: HomeAssistant, config_entry: config_entries.ConfigEntry ) -> bool: @@ -117,6 +145,8 @@ async def async_setup_entry( ca_certs = hass.config.path(config_entry.data[CONF_CA_CERTS]) bridge = None + await _async_migrate_unique_ids(hass, config_entry) + try: bridge = Smartbridge.create_tls( hostname=host, keyfile=keyfile, certfile=certfile, ca_certs=ca_certs @@ -144,7 +174,7 @@ async def async_setup_entry( bridge_device = devices[BRIDGE_DEVICE_ID] if not config_entry.unique_id: hass.config_entries.async_update_entry( - config_entry, unique_id=hex(bridge_device["serial"])[2:].zfill(8) + config_entry, unique_id=serial_to_unique_id(bridge_device["serial"]) ) buttons = bridge.buttons @@ -312,6 +342,7 @@ class LutronCasetaDevice(Entity): self._device = device self._smartbridge = bridge self._bridge_device = bridge_device + self._bridge_unique_id = serial_to_unique_id(bridge_device["serial"]) if "serial" not in self._device: return area, name = _area_and_name_from_name(device["name"]) diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 6a6e3853280..56a770c0b2e 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -6,14 +6,13 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_SUGGESTED_AREA from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice, _area_and_name_from_name -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, CONFIG_URL, MANUFACTURER, UNASSIGNED_AREA +from .const import BRIDGE_DEVICE, BRIDGE_LEAP, CONFIG_URL, MANUFACTURER async def async_setup_entry( @@ -44,7 +43,9 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): def __init__(self, device, bridge, bridge_device): """Init an occupancy sensor.""" super().__init__(device, bridge, bridge_device) - info = DeviceInfo( + _, name = _area_and_name_from_name(device["name"]) + self._attr_name = name + self._attr_device_info = DeviceInfo( identifiers={(CASETA_DOMAIN, self.unique_id)}, manufacturer=MANUFACTURER, model="Lutron Occupancy", @@ -53,10 +54,6 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): configuration_url=CONFIG_URL, entry_type=DeviceEntryType.SERVICE, ) - area, _ = _area_and_name_from_name(device["name"]) - if area != UNASSIGNED_AREA: - info[ATTR_SUGGESTED_AREA] = area - self._attr_device_info = info @property def is_on(self): @@ -77,7 +74,7 @@ class LutronOccupancySensor(LutronCasetaDevice, BinarySensorEntity): @property def unique_id(self): """Return a unique identifier.""" - return f"occupancygroup_{self.device_id}" + return f"occupancygroup_{self._bridge_unique_id}_{self.device_id}" @property def extra_state_attributes(self): From 3bbb4c052c92ee4608fb8b6881ef0c0bbece6132 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 14 Jun 2022 22:49:06 +0200 Subject: [PATCH 417/947] Add camera diagnostics to Synology DSM (#73391) --- .../components/synology_dsm/diagnostics.py | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/synology_dsm/diagnostics.py b/homeassistant/components/synology_dsm/diagnostics.py index 485a44b290a..30af7f94282 100644 --- a/homeassistant/components/synology_dsm/diagnostics.py +++ b/homeassistant/components/synology_dsm/diagnostics.py @@ -1,8 +1,11 @@ """Diagnostics support for Synology DSM.""" from __future__ import annotations +from typing import Any + from synology_dsm.api.surveillance_station.camera import SynoCamera +from homeassistant.components.camera import diagnostics as camera_diagnostics from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME @@ -16,13 +19,13 @@ TO_REDACT = {CONF_USERNAME, CONF_PASSWORD, CONF_DEVICE_TOKEN} async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry -) -> dict: +) -> dict[str, Any]: """Return diagnostics for a config entry.""" data: SynologyDSMData = hass.data[DOMAIN][entry.unique_id] syno_api = data.api dsm_info = syno_api.dsm.information - diag_data = { + diag_data: dict[str, Any] = { "entry": async_redact_data(entry.as_dict(), TO_REDACT), "device_info": { "model": dsm_info.model, @@ -33,7 +36,7 @@ async def async_get_config_entry_diagnostics( }, "network": {"interfaces": {}}, "storage": {"disks": {}, "volumes": {}}, - "surveillance_station": {"cameras": {}}, + "surveillance_station": {"cameras": {}, "camera_diagnostics": {}}, "upgrade": {}, "utilisation": {}, "is_system_loaded": True, @@ -45,7 +48,7 @@ async def async_get_config_entry_diagnostics( if syno_api.network is not None: intf: dict for intf in syno_api.network.interfaces: - diag_data["network"]["interfaces"][intf["id"]] = { # type: ignore[index] + diag_data["network"]["interfaces"][intf["id"]] = { "type": intf["type"], "ip": intf["ip"], } @@ -53,7 +56,7 @@ async def async_get_config_entry_diagnostics( if syno_api.storage is not None: disk: dict for disk in syno_api.storage.disks: - diag_data["storage"]["disks"][disk["id"]] = { # type: ignore[index] + diag_data["storage"]["disks"][disk["id"]] = { "name": disk["name"], "vendor": disk["vendor"], "model": disk["model"], @@ -64,7 +67,7 @@ async def async_get_config_entry_diagnostics( volume: dict for volume in syno_api.storage.volumes: - diag_data["storage"]["volumes"][volume["id"]] = { # type: ignore[index] + diag_data["storage"]["volumes"][volume["id"]] = { "name": volume["fs_type"], "size": volume["size"], } @@ -72,13 +75,17 @@ async def async_get_config_entry_diagnostics( if syno_api.surveillance_station is not None: camera: SynoCamera for camera in syno_api.surveillance_station.get_all_cameras(): - diag_data["surveillance_station"]["cameras"][camera.id] = { # type: ignore[index] + diag_data["surveillance_station"]["cameras"][camera.id] = { "name": camera.name, "is_enabled": camera.is_enabled, "is_motion_detection_enabled": camera.is_motion_detection_enabled, "model": camera.model, "resolution": camera.resolution, } + if camera_data := await camera_diagnostics.async_get_config_entry_diagnostics( + hass, entry + ): + diag_data["surveillance_station"]["camera_diagnostics"] = camera_data if syno_api.upgrade is not None: diag_data["upgrade"] = { From 103a6266a24e4e8ac7a9cacdffa83ea031709715 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 14 Jun 2022 23:18:59 +0200 Subject: [PATCH 418/947] Fix fetching upgrade data during setup of Synology DSM (#73507) --- .../components/synology_dsm/common.py | 57 +++++++++++-------- 1 file changed, 33 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/synology_dsm/common.py b/homeassistant/components/synology_dsm/common.py index 088686660e4..12bad2954dd 100644 --- a/homeassistant/components/synology_dsm/common.py +++ b/homeassistant/components/synology_dsm/common.py @@ -16,7 +16,6 @@ from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.exceptions import ( SynologyDSMAPIErrorException, SynologyDSMException, - SynologyDSMLoginFailedException, SynologyDSMRequestException, ) @@ -32,7 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback -from .const import CONF_DEVICE_TOKEN +from .const import CONF_DEVICE_TOKEN, SYNOLOGY_CONNECTION_EXCEPTIONS LOGGER = logging.getLogger(__name__) @@ -72,6 +71,10 @@ class SynoApi: async def async_setup(self) -> None: """Start interacting with the NAS.""" + await self._hass.async_add_executor_job(self._setup) + + def _setup(self) -> None: + """Start interacting with the NAS in the executor.""" self.dsm = SynologyDSM( self._entry.data[CONF_HOST], self._entry.data[CONF_PORT], @@ -82,7 +85,7 @@ class SynoApi: timeout=self._entry.options.get(CONF_TIMEOUT), device_token=self._entry.data.get(CONF_DEVICE_TOKEN), ) - await self._hass.async_add_executor_job(self.dsm.login) + self.dsm.login() # check if surveillance station is used self._with_surveillance_station = bool( @@ -94,10 +97,24 @@ class SynoApi: self._with_surveillance_station, ) - self._async_setup_api_requests() + # check if upgrade is available + try: + self.dsm.upgrade.update() + except SynologyDSMAPIErrorException as ex: + self._with_upgrade = False + LOGGER.debug("Disabled fetching upgrade data during setup: %s", ex) - await self._hass.async_add_executor_job(self._fetch_device_configuration) - await self.async_update(first_setup=True) + self._fetch_device_configuration() + + try: + self._update() + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: + LOGGER.debug( + "Connection error during setup of '%s' with exception: %s", + self._entry.unique_id, + err, + ) + raise err @callback def subscribe(self, api_key: str, unique_id: str) -> Callable[[], None]: @@ -117,8 +134,7 @@ class SynoApi: return unsubscribe - @callback - def _async_setup_api_requests(self) -> None: + def _setup_api_requests(self) -> None: """Determine if we should fetch each API, if one entity needs it.""" # Entities not added yet, fetch all if not self._fetching_entities: @@ -243,30 +259,23 @@ class SynoApi: # ignore API errors during logout pass - async def async_update(self, first_setup: bool = False) -> None: + async def async_update(self) -> None: """Update function for updating API information.""" - LOGGER.debug("Start data update for '%s'", self._entry.unique_id) - self._async_setup_api_requests() try: - await self._hass.async_add_executor_job( - self.dsm.update, self._with_information - ) - except ( - SynologyDSMLoginFailedException, - SynologyDSMRequestException, - SynologyDSMAPIErrorException, - ) as err: + await self._hass.async_add_executor_job(self._update) + except SYNOLOGY_CONNECTION_EXCEPTIONS as err: LOGGER.debug( "Connection error during update of '%s' with exception: %s", self._entry.unique_id, err, ) - - if first_setup: - raise err - LOGGER.warning( "Connection error during update, fallback by reloading the entry" ) await self._hass.config_entries.async_reload(self._entry.entry_id) - return + + def _update(self) -> None: + """Update function for updating API information.""" + LOGGER.debug("Start data update for '%s'", self._entry.unique_id) + self._setup_api_requests() + self.dsm.update(self._with_information) From d25a5f3836f72b10381ca74c8413bc86f18a8c79 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 12:16:30 -1000 Subject: [PATCH 419/947] Bump zeroconf to 0.38.7 (#73497) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 8cfc0698dc4..8061be2cf8a 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.38.6"], + "requirements": ["zeroconf==0.38.7"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5939a513d00..f423c96dbac 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -34,7 +34,7 @@ typing-extensions>=3.10.0.2,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.13.1 yarl==1.7.2 -zeroconf==0.38.6 +zeroconf==0.38.7 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 diff --git a/requirements_all.txt b/requirements_all.txt index 0be8541de46..8e8ff73462d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2495,7 +2495,7 @@ youtube_dl==2021.12.17 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.38.6 +zeroconf==0.38.7 # homeassistant.components.zha zha-quirks==0.0.75 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bebbafa0eaa..81d7e1a30c9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1650,7 +1650,7 @@ yolink-api==0.0.8 youless-api==0.16 # homeassistant.components.zeroconf -zeroconf==0.38.6 +zeroconf==0.38.7 # homeassistant.components.zha zha-quirks==0.0.75 From 32b61e15a19b02906c4fee8a84321cca7c392e03 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 15 Jun 2022 01:35:29 +0200 Subject: [PATCH 420/947] Strict typing Trafikverket Ferry (#72459) --- .strict-typing | 1 + .../components/trafikverket_ferry/sensor.py | 14 +++++++------- mypy.ini | 11 +++++++++++ 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index f81b6249fed..77f7b6f50c2 100644 --- a/.strict-typing +++ b/.strict-typing @@ -226,6 +226,7 @@ homeassistant.components.tplink.* homeassistant.components.tolo.* homeassistant.components.tractive.* homeassistant.components.tradfri.* +homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* homeassistant.components.tts.* diff --git a/homeassistant/components/trafikverket_ferry/sensor.py b/homeassistant/components/trafikverket_ferry/sensor.py index bab73d72210..256341a7132 100644 --- a/homeassistant/components/trafikverket_ferry/sensor.py +++ b/homeassistant/components/trafikverket_ferry/sensor.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any +from typing import Any, cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -55,21 +55,21 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_time"]), - info_fn=lambda data: data["departure_information"], + info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_from", name="Departure From", icon="mdi:ferry", - value_fn=lambda data: data["departure_from"], - info_fn=lambda data: data["departure_information"], + value_fn=lambda data: cast(str, data["departure_from"]), + info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_to", name="Departure To", icon="mdi:ferry", - value_fn=lambda data: data["departure_to"], - info_fn=lambda data: data["departure_information"], + value_fn=lambda data: cast(str, data["departure_to"]), + info_fn=lambda data: cast(list[str], data["departure_information"]), ), TrafikverketSensorEntityDescription( key="departure_modified", @@ -77,7 +77,7 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( icon="mdi:clock", device_class=SensorDeviceClass.TIMESTAMP, value_fn=lambda data: as_utc(data["departure_modified"]), - info_fn=lambda data: data["departure_information"], + info_fn=lambda data: cast(list[str], data["departure_information"]), entity_registry_enabled_default=False, ), TrafikverketSensorEntityDescription( diff --git a/mypy.ini b/mypy.ini index 11a2d83ec40..9e27addae89 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2250,6 +2250,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trafikverket_ferry.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.trafikverket_train.*] check_untyped_defs = true disallow_incomplete_defs = true From 188b1670a30f0f27386943b90cffd178378ba936 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 15 Jun 2022 00:25:37 +0000 Subject: [PATCH 421/947] [ci skip] Translation update --- .../eight_sleep/translations/cs.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 homeassistant/components/eight_sleep/translations/cs.json diff --git a/homeassistant/components/eight_sleep/translations/cs.json b/homeassistant/components/eight_sleep/translations/cs.json new file mode 100644 index 00000000000..86766978310 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/cs.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nastaveno", + "cannot_connect": "Nemohu se p\u0159ipojit k Eight Sleep cloudu: {error}" + }, + "error": { + "cannot_connect": "Nemohu se p\u0159ipojit k Eight Sleep cloudu: {error}" + }, + "step": { + "user": { + "data": { + "password": "Heslo", + "username": "U\u017eivatelsk\u00e9 jm\u00e9no" + } + } + } + } +} \ No newline at end of file From c64b10878997d0abda032138241ac3bcfdaf2260 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jean-Fran=C3=A7ois=20Roy?= Date: Tue, 14 Jun 2022 20:11:37 -0700 Subject: [PATCH 422/947] Bump aiobafi6 to 0.6.0 to fix logging performance (#73517) --- homeassistant/components/baf/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index 8143c35410e..821ad1a21cb 100644 --- a/homeassistant/components/baf/manifest.json +++ b/homeassistant/components/baf/manifest.json @@ -3,7 +3,7 @@ "name": "Big Ass Fans", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/baf", - "requirements": ["aiobafi6==0.5.0"], + "requirements": ["aiobafi6==0.6.0"], "codeowners": ["@bdraco", "@jfroy"], "iot_class": "local_push", "zeroconf": [ diff --git a/requirements_all.txt b/requirements_all.txt index 8e8ff73462d..03359ed2a0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -122,7 +122,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.5.0 +aiobafi6==0.6.0 # homeassistant.components.aws aiobotocore==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81d7e1a30c9..2d8f44b9da9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.5.0 +aiobafi6==0.6.0 # homeassistant.components.aws aiobotocore==2.1.0 From 1e956bc52f08f5f203b47e380bad4fbae8e92a06 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 20:30:59 -1000 Subject: [PATCH 423/947] Reduce bond startup time (#73506) --- homeassistant/components/bond/button.py | 4 ++-- homeassistant/components/bond/cover.py | 11 ++++------- homeassistant/components/bond/entity.py | 8 +++++--- homeassistant/components/bond/fan.py | 20 ++++++++------------ homeassistant/components/bond/light.py | 13 ++++++++----- homeassistant/components/bond/switch.py | 18 +++++++----------- homeassistant/components/bond/utils.py | 21 +++++++++++++++++---- tests/components/bond/test_config_flow.py | 5 +++-- 8 files changed, 54 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 0465e4c51fe..ffdb01b9d88 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -287,10 +287,10 @@ class BondButtonEntity(BondEntity, ButtonEntity): description: BondButtonEntityDescription, ) -> None: """Init Bond button.""" + self.entity_description = description super().__init__( hub, device, bpup_subs, description.name, description.key.lower() ) - self.entity_description = description async def async_press(self, **kwargs: Any) -> None: """Press the button.""" @@ -302,5 +302,5 @@ class BondButtonEntity(BondEntity, ButtonEntity): action = Action(self.entity_description.key) await self._hub.bond.action(self._device.device_id, action) - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: """Apply the state.""" diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index 3938de0d4bd..efe72f947f4 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -13,7 +13,6 @@ from homeassistant.components.cover import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import BPUP_SUBS, DOMAIN, HUB @@ -40,14 +39,11 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] - - covers: list[Entity] = [ + async_add_entities( BondCover(hub, device, bpup_subs) for device in hub.devices if device.type == DeviceType.MOTORIZED_SHADES - ] - - async_add_entities(covers, True) + ) class BondCover(BondEntity, CoverEntity): @@ -78,7 +74,8 @@ class BondCover(BondEntity, CoverEntity): supported_features |= CoverEntityFeature.STOP_TILT self._attr_supported_features = supported_features - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state cover_open = state.get("open") self._attr_is_closed = None if cover_open is None else cover_open == 0 if (bond_position := state.get("position")) is not None: diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 832e9b5d464..f9f09cfe3cb 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -64,6 +64,8 @@ class BondEntity(Entity): self._attr_name = f"{device.name} {sub_device_name}" else: self._attr_name = device.name + self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state + self._apply_state() @property def device_info(self) -> DeviceInfo: @@ -137,10 +139,9 @@ class BondEntity(Entity): self._attr_available = False else: self._async_state_callback(state) - self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state @abstractmethod - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: raise NotImplementedError @callback @@ -153,7 +154,8 @@ class BondEntity(Entity): _LOGGER.debug( "Device state for %s (%s) is:\n%s", self.name, self.entity_id, state ) - self._apply_state(state) + self._device.state = state + self._apply_state() @callback def _async_bpup_callback(self, json_msg: dict) -> None: diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index f2f6b15f923..12eef9c44b0 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -19,7 +19,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( int_states_in_range, @@ -46,20 +45,17 @@ async def async_setup_entry( hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] platform = entity_platform.async_get_current_platform() - - fans: list[Entity] = [ - BondFan(hub, device, bpup_subs) - for device in hub.devices - if DeviceType.is_fan(device.type) - ] - platform.async_register_entity_service( SERVICE_SET_FAN_SPEED_TRACKED_STATE, {vol.Required("speed"): vol.All(vol.Number(scale=0), vol.Range(0, 100))}, "async_set_speed_belief", ) - async_add_entities(fans, True) + async_add_entities( + BondFan(hub, device, bpup_subs) + for device in hub.devices + if DeviceType.is_fan(device.type) + ) class BondFan(BondEntity, FanEntity): @@ -69,15 +65,15 @@ class BondFan(BondEntity, FanEntity): self, hub: BondHub, device: BondDevice, bpup_subs: BPUPSubscriptions ) -> None: """Create HA entity representing Bond fan.""" - super().__init__(hub, device, bpup_subs) - self._power: bool | None = None self._speed: int | None = None self._direction: int | None = None + super().__init__(hub, device, bpup_subs) if self._device.has_action(Action.BREEZE_ON): self._attr_preset_modes = [PRESET_MODE_BREEZE] - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._power = state.get("power") self._speed = state.get("speed") self._direction = state.get("direction") diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 55084f37b03..5a76ea6a13e 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -115,7 +115,6 @@ async def async_setup_entry( async_add_entities( fan_lights + fan_up_lights + fan_down_lights + fireplaces + fp_lights + lights, - True, ) @@ -170,7 +169,8 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._attr_is_on = state.get("light") == 1 brightness = state.get("brightness") self._attr_brightness = round(brightness * 255 / 100) if brightness else None @@ -227,7 +227,8 @@ class BondLight(BondBaseLight, BondEntity, LightEntity): class BondDownLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._attr_is_on = bool(state.get("down_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: @@ -246,7 +247,8 @@ class BondDownLight(BondBaseLight, BondEntity, LightEntity): class BondUpLight(BondBaseLight, BondEntity, LightEntity): """Representation of a Bond light.""" - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state self._attr_is_on = bool(state.get("up_light") and state.get("light")) async def async_turn_on(self, **kwargs: Any) -> None: @@ -268,7 +270,8 @@ class BondFireplace(BondEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def _apply_state(self, state: dict) -> None: + def _apply_state(self) -> None: + state = self._device.state power = state.get("power") flame = state.get("flame") self._attr_is_on = power == 1 diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index da0b19dd9ff..a88be924610 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -12,7 +12,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform -from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -36,27 +35,24 @@ async def async_setup_entry( hub: BondHub = data[HUB] bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] platform = entity_platform.async_get_current_platform() - - switches: list[Entity] = [ - BondSwitch(hub, device, bpup_subs) - for device in hub.devices - if DeviceType.is_generic(device.type) - ] - platform.async_register_entity_service( SERVICE_SET_POWER_TRACKED_STATE, {vol.Required(ATTR_POWER_STATE): cv.boolean}, "async_set_power_belief", ) - async_add_entities(switches, True) + async_add_entities( + BondSwitch(hub, device, bpup_subs) + for device in hub.devices + if DeviceType.is_generic(device.type) + ) class BondSwitch(BondEntity, SwitchEntity): """Representation of a Bond generic device.""" - def _apply_state(self, state: dict) -> None: - self._attr_is_on = state.get("power") == 1 + def _apply_state(self) -> None: + self._attr_is_on = self._device.state.get("power") == 1 async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" diff --git a/homeassistant/components/bond/utils.py b/homeassistant/components/bond/utils.py index c426bf64577..3a161a74bc5 100644 --- a/homeassistant/components/bond/utils.py +++ b/homeassistant/components/bond/utils.py @@ -20,11 +20,16 @@ class BondDevice: """Helper device class to hold ID and attributes together.""" def __init__( - self, device_id: str, attrs: dict[str, Any], props: dict[str, Any] + self, + device_id: str, + attrs: dict[str, Any], + props: dict[str, Any], + state: dict[str, Any], ) -> None: """Create a helper device from ID and attributes returned by API.""" self.device_id = device_id self.props = props + self.state = state self._attrs = attrs or {} self._supported_actions: set[str] = set(self._attrs.get("actions", [])) @@ -34,6 +39,7 @@ class BondDevice: "device_id": self.device_id, "props": self.props, "attrs": self._attrs, + "state": self.state, }.__repr__() @property @@ -150,7 +156,11 @@ class BondHub: break setup_device_ids.append(device_id) tasks.extend( - [self.bond.device(device_id), self.bond.device_properties(device_id)] + [ + self.bond.device(device_id), + self.bond.device_properties(device_id), + self.bond.device_state(device_id), + ] ) responses = await gather_with_concurrency(MAX_REQUESTS, *tasks) @@ -158,10 +168,13 @@ class BondHub: for device_id in setup_device_ids: self._devices.append( BondDevice( - device_id, responses[response_idx], responses[response_idx + 1] + device_id, + responses[response_idx], + responses[response_idx + 1], + responses[response_idx + 2], ) ) - response_idx += 2 + response_idx += 3 _LOGGER.debug("Discovered Bond devices: %s", self._devices) try: diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index 4f1e313a34a..15aa643abaf 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -18,6 +18,7 @@ from .common import ( patch_bond_device, patch_bond_device_ids, patch_bond_device_properties, + patch_bond_device_state, patch_bond_token, patch_bond_version, ) @@ -38,7 +39,7 @@ async def test_user_form(hass: core.HomeAssistant): return_value={"bondid": "ZXXX12345"} ), patch_bond_device_ids( return_value=["f6776c11", "f6776c12"] - ), patch_bond_bridge(), patch_bond_device_properties(), patch_bond_device(), _patch_async_setup_entry() as mock_setup_entry: + ), patch_bond_bridge(), patch_bond_device_properties(), patch_bond_device(), patch_bond_device_state(), _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, @@ -73,7 +74,7 @@ async def test_user_form_with_non_bridge(hass: core.HomeAssistant): } ), patch_bond_bridge( return_value={} - ), _patch_async_setup_entry() as mock_setup_entry: + ), patch_bond_device_state(), _patch_async_setup_entry() as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"}, From 77c92b0b77cb246a3c67fabb3c1ff816102a7820 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 15 Jun 2022 01:32:13 -0500 Subject: [PATCH 424/947] Mark Sonos speaker as offline when switching to bluetooth (#73519) --- homeassistant/components/sonos/speaker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index bd217cc9029..f9decf1c27e 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -77,7 +77,7 @@ SUBSCRIPTION_SERVICES = [ "renderingControl", "zoneGroupTopology", ] -SUPPORTED_VANISH_REASONS = ("sleeping", "upgrade") +SUPPORTED_VANISH_REASONS = ("sleeping", "switch to bluetooth", "upgrade") UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"] From 16dd70ba994b5f9cb50a7d229e97ea8629740ef9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 14 Jun 2022 20:32:38 -1000 Subject: [PATCH 425/947] Switch to a dataclass for lutron_caseta entry data (#73500) --- .../components/lutron_caseta/__init__.py | 20 +++++++----------- .../components/lutron_caseta/binary_sensor.py | 9 ++++---- .../components/lutron_caseta/const.py | 3 --- .../components/lutron_caseta/cover.py | 9 ++++---- .../lutron_caseta/device_trigger.py | 8 +++---- .../components/lutron_caseta/diagnostics.py | 8 +++---- homeassistant/components/lutron_caseta/fan.py | 9 ++++---- .../components/lutron_caseta/light.py | 9 ++++---- .../components/lutron_caseta/models.py | 18 ++++++++++++++++ .../components/lutron_caseta/scene.py | 9 ++++---- .../components/lutron_caseta/switch.py | 9 ++++---- .../lutron_caseta/test_device_trigger.py | 21 ++++++++++++------- 12 files changed, 78 insertions(+), 54 deletions(-) create mode 100644 homeassistant/components/lutron_caseta/models.py diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index 27d8ad87861..b4ce82a36c6 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -32,11 +32,8 @@ from .const import ( ATTR_LEAP_BUTTON_NUMBER, ATTR_SERIAL, ATTR_TYPE, - BRIDGE_DEVICE, BRIDGE_DEVICE_ID, - BRIDGE_LEAP, BRIDGE_TIMEOUT, - BUTTON_DEVICES, CONF_CA_CERTS, CONF_CERTFILE, CONF_KEYFILE, @@ -50,6 +47,7 @@ from .device_trigger import ( DEVICE_TYPE_SUBTYPE_MAP_TO_LIP, LEAP_TO_DEVICE_TYPE_SUBTYPE_MAP, ) +from .models import LutronCasetaData from .util import serial_to_unique_id _LOGGER = logging.getLogger(__name__) @@ -186,11 +184,9 @@ async def async_setup_entry( # Store this bridge (keyed by entry_id) so it can be retrieved by the # platforms we're setting up. - hass.data[DOMAIN][entry_id] = { - BRIDGE_LEAP: bridge, - BRIDGE_DEVICE: bridge_device, - BUTTON_DEVICES: button_devices, - } + hass.data[DOMAIN][entry_id] = LutronCasetaData( + bridge, bridge_device, button_devices + ) hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) @@ -319,9 +315,8 @@ async def async_unload_entry( hass: HomeAssistant, entry: config_entries.ConfigEntry ) -> bool: """Unload the bridge bridge from a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] - smartbridge: Smartbridge = data[BRIDGE_LEAP] - await smartbridge.close() + data: LutronCasetaData = hass.data[DOMAIN][entry.entry_id] + await data.bridge.close() if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -402,7 +397,8 @@ async def async_remove_config_entry_device( hass: HomeAssistant, entry: config_entries.ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove lutron_caseta config entry from a device.""" - bridge: Smartbridge = hass.data[DOMAIN][entry.entry_id][BRIDGE_LEAP] + data: LutronCasetaData = hass.data[DOMAIN][entry.entry_id] + bridge = data.bridge devices = bridge.get_devices() buttons = bridge.buttons occupancy_groups = bridge.occupancy_groups diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 56a770c0b2e..4b1c53d194b 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -12,7 +12,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN as CASETA_DOMAIN, LutronCasetaDevice, _area_and_name_from_name -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, CONFIG_URL, MANUFACTURER +from .const import CONFIG_URL, MANUFACTURER +from .models import LutronCasetaData async def async_setup_entry( @@ -25,9 +26,9 @@ async def async_setup_entry( Adds occupancy groups from the Caseta bridge associated with the config_entry as binary_sensor entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device occupancy_groups = bridge.occupancy_groups async_add_entities( LutronOccupancySensor(occupancy_group, bridge, bridge_device) diff --git a/homeassistant/components/lutron_caseta/const.py b/homeassistant/components/lutron_caseta/const.py index 71d686ba2c8..ae8dc0a505a 100644 --- a/homeassistant/components/lutron_caseta/const.py +++ b/homeassistant/components/lutron_caseta/const.py @@ -10,9 +10,6 @@ STEP_IMPORT_FAILED = "import_failed" ERROR_CANNOT_CONNECT = "cannot_connect" ABORT_REASON_CANNOT_CONNECT = "cannot_connect" -BRIDGE_LEAP = "leap" -BRIDGE_DEVICE = "bridge_device" -BUTTON_DEVICES = "button_devices" LUTRON_CASETA_BUTTON_EVENT = "lutron_caseta_button_event" BRIDGE_DEVICE_ID = "1" diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index afddb2677a7..724dc4258da 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -12,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData async def async_setup_entry( @@ -25,9 +26,9 @@ async def async_setup_entry( Adds shades from the Caseta bridge associated with the config_entry as cover entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device cover_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaCover(cover_device, bridge, bridge_device) diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index 68394667764..e762e79a8d7 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -29,11 +29,11 @@ from .const import ( ATTR_ACTION, ATTR_BUTTON_NUMBER, ATTR_SERIAL, - BUTTON_DEVICES, CONF_SUBTYPE, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, ) +from .models import LutronCasetaData SUPPORTED_INPUTS_EVENTS_TYPES = [ACTION_PRESS, ACTION_RELEASE] @@ -411,9 +411,9 @@ def get_button_device_by_dr_id(hass: HomeAssistant, device_id: str): if DOMAIN not in hass.data: return None - for config_entry in hass.data[DOMAIN]: - button_devices = hass.data[DOMAIN][config_entry][BUTTON_DEVICES] - if device := button_devices.get(device_id): + for entry_id in hass.data[DOMAIN]: + data: LutronCasetaData = hass.data[DOMAIN][entry_id] + if device := data.button_devices.get(device_id): return device return None diff --git a/homeassistant/components/lutron_caseta/diagnostics.py b/homeassistant/components/lutron_caseta/diagnostics.py index 7ae0b5c40a9..afe69b813f9 100644 --- a/homeassistant/components/lutron_caseta/diagnostics.py +++ b/homeassistant/components/lutron_caseta/diagnostics.py @@ -3,19 +3,19 @@ from __future__ import annotations from typing import Any -from pylutron_caseta.smartbridge import Smartbridge - from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import BRIDGE_LEAP, DOMAIN +from .const import DOMAIN +from .models import LutronCasetaData async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - bridge: Smartbridge = hass.data[DOMAIN][entry.entry_id][BRIDGE_LEAP] + data: LutronCasetaData = hass.data[DOMAIN][entry.entry_id] + bridge = data.bridge return { "entry": { "title": entry.title, diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index cdf00959ed1..e08a5278572 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -13,7 +13,8 @@ from homeassistant.util.percentage import ( ) from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData DEFAULT_ON_PERCENTAGE = 50 ORDERED_NAMED_FAN_SPEEDS = [FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_HIGH] @@ -29,9 +30,9 @@ async def async_setup_entry( Adds fan controllers from the Caseta bridge associated with the config_entry as fan entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device fan_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaFan(fan_device, bridge, bridge_device) for fan_device in fan_devices diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py index a58fc21aadf..9fbb80284f5 100644 --- a/homeassistant/components/lutron_caseta/light.py +++ b/homeassistant/components/lutron_caseta/light.py @@ -14,7 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData def to_lutron_level(level): @@ -37,9 +38,9 @@ async def async_setup_entry( Adds dimmers from the Caseta bridge associated with the config_entry as light entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device light_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaLight(light_device, bridge, bridge_device) diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py new file mode 100644 index 00000000000..5845c888a2e --- /dev/null +++ b/homeassistant/components/lutron_caseta/models.py @@ -0,0 +1,18 @@ +"""The lutron_caseta integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from pylutron_caseta.smartbridge import Smartbridge + +from homeassistant.helpers.device_registry import DeviceEntry + + +@dataclass +class LutronCasetaData: + """Data for the lutron_caseta integration.""" + + bridge: Smartbridge + bridge_device: dict[str, Any] + button_devices: dict[str, DeviceEntry] diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py index 1bbd69615e1..2870d6ee96a 100644 --- a/homeassistant/components/lutron_caseta/scene.py +++ b/homeassistant/components/lutron_caseta/scene.py @@ -10,7 +10,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import _area_and_name_from_name -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData from .util import serial_to_unique_id @@ -24,9 +25,9 @@ async def async_setup_entry( Adds scenes from the Caseta bridge associated with the config_entry as scene entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge: Smartbridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device scenes = bridge.get_scenes() async_add_entities( LutronCasetaScene(scenes[scene], bridge, bridge_device) for scene in scenes diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py index 7e963352264..062c8891672 100644 --- a/homeassistant/components/lutron_caseta/switch.py +++ b/homeassistant/components/lutron_caseta/switch.py @@ -6,7 +6,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import LutronCasetaDeviceUpdatableEntity -from .const import BRIDGE_DEVICE, BRIDGE_LEAP, DOMAIN as CASETA_DOMAIN +from .const import DOMAIN as CASETA_DOMAIN +from .models import LutronCasetaData async def async_setup_entry( @@ -19,9 +20,9 @@ async def async_setup_entry( Adds switches from the Caseta bridge associated with the config_entry as switch entities. """ - data = hass.data[CASETA_DOMAIN][config_entry.entry_id] - bridge = data[BRIDGE_LEAP] - bridge_device = data[BRIDGE_DEVICE] + data: LutronCasetaData = hass.data[CASETA_DOMAIN][config_entry.entry_id] + bridge = data.bridge + bridge_device = data.bridge_device switch_devices = bridge.get_devices_by_domain(DOMAIN) async_add_entities( LutronCasetaLight(switch_device, bridge, bridge_device) diff --git a/tests/components/lutron_caseta/test_device_trigger.py b/tests/components/lutron_caseta/test_device_trigger.py index cb38df6a381..bdf1e359673 100644 --- a/tests/components/lutron_caseta/test_device_trigger.py +++ b/tests/components/lutron_caseta/test_device_trigger.py @@ -1,4 +1,6 @@ """The tests for Lutron Caséta device triggers.""" +from unittest.mock import MagicMock + import pytest from homeassistant.components import automation @@ -15,12 +17,12 @@ from homeassistant.components.lutron_caseta import ( ATTR_TYPE, ) from homeassistant.components.lutron_caseta.const import ( - BUTTON_DEVICES, DOMAIN, LUTRON_CASETA_BUTTON_EVENT, MANUFACTURER, ) from homeassistant.components.lutron_caseta.device_trigger import CONF_SUBTYPE +from homeassistant.components.lutron_caseta.models import LutronCasetaData from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -83,15 +85,17 @@ async def _async_setup_lutron_with_picos(hass, device_reg): ) dr_button_devices[dr_device.id] = device - hass.data[DOMAIN][config_entry.entry_id] = {BUTTON_DEVICES: dr_button_devices} - + hass.data[DOMAIN][config_entry.entry_id] = LutronCasetaData( + MagicMock(), MagicMock(), dr_button_devices + ) return config_entry.entry_id async def test_get_triggers(hass, device_reg): """Test we get the expected triggers from a lutron pico.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] expected_triggers = [ @@ -142,7 +146,8 @@ async def test_if_fires_on_button_event(hass, calls, device_reg): """Test for press trigger firing.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] device = dr_button_devices[device_id] assert await async_setup_component( @@ -224,7 +229,8 @@ async def test_validate_trigger_config_unknown_device(hass, calls, device_reg): """Test for no press with an unknown device.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] device = dr_button_devices[device_id] device["type"] = "unknown" @@ -267,7 +273,8 @@ async def test_validate_trigger_config_unknown_device(hass, calls, device_reg): async def test_validate_trigger_invalid_triggers(hass, device_reg): """Test for click_event with invalid triggers.""" config_entry_id = await _async_setup_lutron_with_picos(hass, device_reg) - dr_button_devices = hass.data[DOMAIN][config_entry_id][BUTTON_DEVICES] + data: LutronCasetaData = hass.data[DOMAIN][config_entry_id] + dr_button_devices = data.button_devices device_id = list(dr_button_devices)[0] assert await async_setup_component( hass, From a77ea1c39003d2be959c8e73fc8f4e7521a09e84 Mon Sep 17 00:00:00 2001 From: Corbeno Date: Wed, 15 Jun 2022 01:49:55 -0500 Subject: [PATCH 426/947] Add device class to proxmoxve binary sensor (#73465) * add device class property to binary sensor * add newline --- homeassistant/components/proxmoxve/binary_sensor.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index e52b91d4cba..780e7240267 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -1,7 +1,10 @@ """Binary sensor to read Proxmox VE data.""" from __future__ import annotations -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -66,7 +69,7 @@ def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): return ProxmoxBinarySensor( coordinator=coordinator, unique_id=f"proxmox_{node_name}_{vm_id}_running", - name=f"{node_name}_{name}_running", + name=f"{node_name}_{name}", icon="", host_name=host_name, node_name=node_name, @@ -77,6 +80,8 @@ def create_binary_sensor(coordinator, host_name, node_name, vm_id, name): class ProxmoxBinarySensor(ProxmoxEntity, BinarySensorEntity): """A binary sensor for reading Proxmox VE data.""" + _attr_device_class = BinarySensorDeviceClass.RUNNING + def __init__( self, coordinator: DataUpdateCoordinator, From 17eb8c95ddeb40b09bc4082ed1492e72040d6f27 Mon Sep 17 00:00:00 2001 From: Bram Goolaerts Date: Wed, 15 Jun 2022 10:33:53 +0200 Subject: [PATCH 427/947] Fix De Lijn 'tzinfo' error (#73502) * Fix De Lijn component tzinfo error This fix should update the issue "Error:'str' object has no attribute 'tzinfo'" (issue #67455) * fix Black and isort errors fixing errors from Black and isort CI validation * Fix black and flake8 issues Fixing black and flake8 issues to pass CI --- homeassistant/components/delijn/sensor.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/delijn/sensor.py b/homeassistant/components/delijn/sensor.py index e04385dcf3d..ee58a4f21c7 100644 --- a/homeassistant/components/delijn/sensor.py +++ b/homeassistant/components/delijn/sensor.py @@ -1,6 +1,7 @@ """Support for De Lijn (Flemish public transport) information.""" from __future__ import annotations +from datetime import datetime import logging from pydelijn.api import Passages @@ -111,7 +112,9 @@ class DeLijnPublicTransportSensor(SensorEntity): first = self.line.passages[0] if (first_passage := first["due_at_realtime"]) is None: first_passage = first["due_at_schedule"] - self._attr_native_value = first_passage + self._attr_native_value = datetime.strptime( + first_passage, "%Y-%m-%dT%H:%M:%S%z" + ) for key in AUTO_ATTRIBUTES: self._attr_extra_state_attributes[key] = first[key] From 94a8fe0052635299834b8006904609b2e983490e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jun 2022 10:45:47 +0200 Subject: [PATCH 428/947] Remove xiaomi_aqara from mypy ignore list (#73526) --- homeassistant/components/xiaomi_aqara/__init__.py | 9 +++++---- .../components/xiaomi_aqara/binary_sensor.py | 2 +- homeassistant/components/xiaomi_aqara/lock.py | 7 +++++-- homeassistant/components/xiaomi_aqara/sensor.py | 2 +- mypy.ini | 12 ------------ script/hassfest/mypy_config.py | 4 ---- 6 files changed, 12 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 1857b6e83b2..0c9696a42ef 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -82,7 +82,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def play_ringtone_service(call: ServiceCall) -> None: """Service to play ringtone through Gateway.""" ring_id = call.data.get(ATTR_RINGTONE_ID) - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] kwargs = {"mid": ring_id} @@ -93,12 +93,12 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def stop_ringtone_service(call: ServiceCall) -> None: """Service to stop playing ringtone on Gateway.""" - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] gateway.write_to_hub(gateway.sid, mid=10000) def add_device_service(call: ServiceCall) -> None: """Service to add a new sub-device within the next 30 seconds.""" - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] gateway.write_to_hub(gateway.sid, join_permission="yes") persistent_notification.async_create( hass, @@ -110,7 +110,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: def remove_device_service(call: ServiceCall) -> None: """Service to remove a sub-device from the gateway.""" device_id = call.data.get(ATTR_DEVICE_ID) - gateway = call.data.get(ATTR_GW_MAC) + gateway: XiaomiGateway = call.data[ATTR_GW_MAC] gateway.write_to_hub(gateway.sid, remove_device=device_id) gateway_only_schema = _add_gateway_to_schema(hass, vol.Schema({})) @@ -181,6 +181,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.data[CONF_HOST], ) + assert entry.unique_id device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=entry.entry_id, diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py index ef773805849..04e3945e7a2 100644 --- a/homeassistant/components/xiaomi_aqara/binary_sensor.py +++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py @@ -34,7 +34,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" - entities = [] + entities: list[XiaomiBinarySensor] = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for entity in gateway.devices["binary_sensor"]: model = entity["model"] diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index e21967a9f06..1885c1aef85 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -1,4 +1,6 @@ """Support for Xiaomi Aqara locks.""" +from __future__ import annotations + from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED @@ -44,13 +46,14 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): super().__init__(device, name, xiaomi_hub, config_entry) @property - def is_locked(self) -> bool: + def is_locked(self) -> bool | None: """Return true if lock is locked.""" if self._state is not None: return self._state == STATE_LOCKED + return None @property - def changed_by(self) -> int: + def changed_by(self) -> str: """Last change triggered by.""" return self._changed_by diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 3e0d49d628f..5deed77d775 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Xiaomi devices.""" - entities = [] + entities: list[XiaomiSensor | XiaomiBatterySensor] = [] gateway = hass.data[DOMAIN][GATEWAYS_KEY][config_entry.entry_id] for device in gateway.devices["sensor"]: if device["model"] == "sensor_ht": diff --git a/mypy.ini b/mypy.ini index 9e27addae89..8a9c5b0478a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2976,18 +2976,6 @@ ignore_errors = true [mypy-homeassistant.components.xbox.sensor] ignore_errors = true -[mypy-homeassistant.components.xiaomi_aqara] -ignore_errors = true - -[mypy-homeassistant.components.xiaomi_aqara.binary_sensor] -ignore_errors = true - -[mypy-homeassistant.components.xiaomi_aqara.lock] -ignore_errors = true - -[mypy-homeassistant.components.xiaomi_aqara.sensor] -ignore_errors = true - [mypy-homeassistant.components.xiaomi_miio] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 0b705fab983..b7fb778cfee 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -141,10 +141,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xbox.browse_media", "homeassistant.components.xbox.media_source", "homeassistant.components.xbox.sensor", - "homeassistant.components.xiaomi_aqara", - "homeassistant.components.xiaomi_aqara.binary_sensor", - "homeassistant.components.xiaomi_aqara.lock", - "homeassistant.components.xiaomi_aqara.sensor", "homeassistant.components.xiaomi_miio", "homeassistant.components.xiaomi_miio.air_quality", "homeassistant.components.xiaomi_miio.binary_sensor", From 4ace2c4d3ad92860dab5036f15197d5578d0352e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Jun 2022 10:49:40 +0200 Subject: [PATCH 429/947] Migrate overkiz NumberEntity to native_value (#73493) --- homeassistant/components/overkiz/number.py | 45 ++++++++++++++-------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/overkiz/number.py b/homeassistant/components/overkiz/number.py index 167065e9015..8e7d2a93ee9 100644 --- a/homeassistant/components/overkiz/number.py +++ b/homeassistant/components/overkiz/number.py @@ -6,8 +6,13 @@ from typing import cast from pyoverkiz.enums import OverkizCommand, OverkizState -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -38,8 +43,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="My Position", icon="mdi:content-save-cog", command=OverkizCommand.SET_MEMORIZED_1_POSITION, - min_value=0, - max_value=100, + native_min_value=0, + native_max_value=100, entity_category=EntityCategory.CONFIG, ), # WaterHeater: Expected Number Of Shower (2 - 4) @@ -48,8 +53,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Expected Number Of Shower", icon="mdi:shower-head", command=OverkizCommand.SET_EXPECTED_NUMBER_OF_SHOWER, - min_value=2, - max_value=4, + native_min_value=2, + native_max_value=4, entity_category=EntityCategory.CONFIG, ), # SomfyHeatingTemperatureInterface @@ -58,8 +63,10 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Eco Room Temperature", icon="mdi:thermometer", command=OverkizCommand.SET_ECO_TEMPERATURE, - min_value=6, - max_value=29, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=6, + native_max_value=29, + native_unit_of_measurement=TEMP_CELSIUS, entity_category=EntityCategory.CONFIG, ), OverkizNumberDescription( @@ -67,8 +74,10 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Comfort Room Temperature", icon="mdi:home-thermometer-outline", command=OverkizCommand.SET_COMFORT_TEMPERATURE, - min_value=7, - max_value=30, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=7, + native_max_value=30, + native_unit_of_measurement=TEMP_CELSIUS, entity_category=EntityCategory.CONFIG, ), OverkizNumberDescription( @@ -76,8 +85,10 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ name="Freeze Protection Temperature", icon="mdi:sun-thermometer-outline", command=OverkizCommand.SET_SECURED_POSITION_TEMPERATURE, - min_value=5, - max_value=15, + device_class=NumberDeviceClass.TEMPERATURE, + native_min_value=5, + native_max_value=15, + native_unit_of_measurement=TEMP_CELSIUS, entity_category=EntityCategory.CONFIG, ), # DimmerExteriorHeating (Somfy Terrace Heater) (0 - 100) @@ -86,8 +97,8 @@ NUMBER_DESCRIPTIONS: list[OverkizNumberDescription] = [ key=OverkizState.CORE_LEVEL, icon="mdi:patio-heater", command=OverkizCommand.SET_LEVEL, - min_value=0, - max_value=100, + native_min_value=0, + native_max_value=100, inverted=True, ), ] @@ -130,20 +141,20 @@ class OverkizNumber(OverkizDescriptiveEntity, NumberEntity): entity_description: OverkizNumberDescription @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" if state := self.device.states.get(self.entity_description.key): if self.entity_description.inverted: - return self.max_value - cast(float, state.value) + return self.native_max_value - cast(float, state.value) return cast(float, state.value) return None - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" if self.entity_description.inverted: - value = self.max_value - value + value = self.native_max_value - value await self.executor.async_execute_command( self.entity_description.command, value From e05e79e53d7ca9aec842d1e6ac85efa773553ea9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Jun 2022 10:56:41 +0200 Subject: [PATCH 430/947] Migrate NumberEntity r-t to native_value (#73485) --- .../rituals_perfume_genie/number.py | 8 ++++---- .../components/screenlogic/number.py | 8 ++++---- homeassistant/components/sensibo/number.py | 16 +++++++-------- homeassistant/components/shelly/number.py | 20 +++++++++---------- homeassistant/components/sleepiq/number.py | 16 +++++++-------- homeassistant/components/sonos/number.py | 6 +++--- homeassistant/components/tolo/number.py | 20 +++++++++---------- 7 files changed, 47 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 1bcadf9aa88..778b70753cb 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -38,8 +38,8 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): """Representation of a diffuser perfume amount number.""" _attr_icon = "mdi:gauge" - _attr_max_value = MAX_PERFUME_AMOUNT - _attr_min_value = MIN_PERFUME_AMOUNT + _attr_native_max_value = MAX_PERFUME_AMOUNT + _attr_native_min_value = MIN_PERFUME_AMOUNT def __init__( self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator @@ -48,11 +48,11 @@ class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): super().__init__(diffuser, coordinator, PERFUME_AMOUNT_SUFFIX) @property - def value(self) -> int: + def native_value(self) -> int: """Return the current perfume amount.""" return self._diffuser.perfume_amount - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the perfume amount.""" if not value.is_integer(): raise ValueError( diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index 253b5c9641e..75dee907cc1 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -46,16 +46,16 @@ class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): """Initialize of the entity.""" super().__init__(coordinator, data_key, enabled) self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) - self._attr_max_value = SCG.LIMIT_FOR_BODY[self._body_type] + self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type] self._attr_name = f"{self.gateway_name} {self.sensor['name']}" - self._attr_unit_of_measurement = self.sensor["unit"] + self._attr_native_unit_of_measurement = self.sensor["unit"] @property - def value(self) -> float: + def native_value(self) -> float: """Return the current value.""" return self.sensor["value"] - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" # Need to set both levels at the same time, so we gather # both existing level values and override the one that changed. diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 89bd9b270a9..183c4db4b87 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -39,9 +39,9 @@ DEVICE_NUMBER_TYPES = ( icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - min_value=-10, - max_value=10, - step=0.1, + native_min_value=-10, + native_max_value=10, + native_step=0.1, ), SensiboNumberEntityDescription( key="calibration_hum", @@ -50,9 +50,9 @@ DEVICE_NUMBER_TYPES = ( icon="mdi:water", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, - min_value=-10, - max_value=10, - step=0.1, + native_min_value=-10, + native_max_value=10, + native_step=0.1, ), ) @@ -89,12 +89,12 @@ class SensiboNumber(SensiboDeviceBaseEntity, NumberEntity): self._attr_name = f"{self.device_data.name} {entity_description.name}" @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the value from coordinator data.""" value: float | None = getattr(self.device_data, self.entity_description.key) return value - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value for calibration.""" data = {self.entity_description.remote_key: value} result = await self.async_send_command("set_calibration", {"data": data}) diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index dcedc32602d..6658daf674f 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -42,12 +42,12 @@ NUMBERS: Final = { key="device|valvepos", icon="mdi:pipe-valve", name="Valve Position", - unit_of_measurement=PERCENTAGE, + native_unit_of_measurement=PERCENTAGE, available=lambda block: cast(int, block.valveError) != 1, entity_category=EntityCategory.CONFIG, - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, mode=NumberMode("slider"), rest_path="thermostat/0", rest_arg="pos", @@ -62,11 +62,11 @@ def _build_block_description(entry: RegistryEntry) -> BlockNumberDescription: key="", name="", icon=entry.original_icon, - unit_of_measurement=entry.unit_of_measurement, + native_unit_of_measurement=entry.unit_of_measurement, device_class=entry.original_device_class, - min_value=cast(float, entry.capabilities.get("min")), - max_value=cast(float, entry.capabilities.get("max")), - step=cast(float, entry.capabilities.get("step")), + native_min_value=cast(float, entry.capabilities.get("min")), + native_max_value=cast(float, entry.capabilities.get("max")), + native_step=cast(float, entry.capabilities.get("step")), mode=cast(NumberMode, entry.capabilities.get("mode")), ) @@ -97,14 +97,14 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, NumberEntity): entity_description: BlockNumberDescription @property - def value(self) -> float: + def native_value(self) -> float: """Return value of number.""" if self.block is not None: return cast(float, self.attribute_value) return cast(float, self.last_state) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value.""" # Example for Shelly Valve: http://192.168.188.187/thermostat/0?pos=13.0 await self._set_state_full_path( diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index b9aca69b3f4..a0ade257335 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -70,9 +70,9 @@ def _get_sleeper_unique_id(bed: SleepIQBed, sleeper: SleepIQSleeper) -> str: NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { FIRMNESS: SleepIQNumberEntityDescription( key=FIRMNESS, - min_value=5, - max_value=100, - step=5, + native_min_value=5, + native_max_value=100, + native_step=5, name=ENTITY_TYPES[FIRMNESS], icon=ICON_OCCUPIED, value_fn=lambda sleeper: cast(float, sleeper.sleep_number), @@ -82,9 +82,9 @@ NUMBER_DESCRIPTIONS: dict[str, SleepIQNumberEntityDescription] = { ), ACTUATOR: SleepIQNumberEntityDescription( key=ACTUATOR, - min_value=0, - max_value=100, - step=1, + native_min_value=0, + native_max_value=100, + native_step=1, name=ENTITY_TYPES[ACTUATOR], icon=ICON_OCCUPIED, value_fn=lambda actuator: cast(float, actuator.position), @@ -152,9 +152,9 @@ class SleepIQNumberEntity(SleepIQBedEntity, NumberEntity): @callback def _async_update_attrs(self) -> None: """Update number attributes.""" - self._attr_value = float(self.entity_description.value_fn(self.device)) + self._attr_native_value = float(self.entity_description.value_fn(self.device)) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set the number value.""" await self.entity_description.set_value_fn(self.device, int(value)) self._attr_value = value diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index b0a10690ce6..202df1cb6f1 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -73,7 +73,7 @@ class SonosLevelEntity(SonosEntity, NumberEntity): name_suffix = level_type.replace("_", " ").title() self._attr_name = f"{self.speaker.zone_name} {name_suffix}" self.level_type = level_type - self._attr_min_value, self._attr_max_value = valid_range + self._attr_native_min_value, self._attr_native_max_value = valid_range async def _async_fallback_poll(self) -> None: """Poll the value if subscriptions are not working.""" @@ -86,11 +86,11 @@ class SonosLevelEntity(SonosEntity, NumberEntity): setattr(self.speaker, self.level_type, state) @soco_error() - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Set a new value.""" setattr(self.soco, self.level_type, value) @property - def value(self) -> float: + def native_value(self) -> float: """Return the current value.""" return getattr(self.speaker, self.level_type) diff --git a/homeassistant/components/tolo/number.py b/homeassistant/components/tolo/number.py index 85d80756020..a6767a50814 100644 --- a/homeassistant/components/tolo/number.py +++ b/homeassistant/components/tolo/number.py @@ -34,8 +34,8 @@ class ToloNumberEntityDescription( """Class describing TOLO Number entities.""" entity_category = EntityCategory.CONFIG - min_value = 0 - step = 1 + native_min_value = 0 + native_step = 1 NUMBERS = ( @@ -43,8 +43,8 @@ NUMBERS = ( key="power_timer", icon="mdi:power-settings", name="Power Timer", - unit_of_measurement=TIME_MINUTES, - max_value=POWER_TIMER_MAX, + native_unit_of_measurement=TIME_MINUTES, + native_max_value=POWER_TIMER_MAX, getter=lambda settings: settings.power_timer, setter=lambda client, value: client.set_power_timer(value), ), @@ -52,8 +52,8 @@ NUMBERS = ( key="salt_bath_timer", icon="mdi:shaker-outline", name="Salt Bath Timer", - unit_of_measurement=TIME_MINUTES, - max_value=SALT_BATH_TIMER_MAX, + native_unit_of_measurement=TIME_MINUTES, + native_max_value=SALT_BATH_TIMER_MAX, getter=lambda settings: settings.salt_bath_timer, setter=lambda client, value: client.set_salt_bath_timer(value), ), @@ -61,8 +61,8 @@ NUMBERS = ( key="fan_timer", icon="mdi:fan-auto", name="Fan Timer", - unit_of_measurement=TIME_MINUTES, - max_value=FAN_TIMER_MAX, + native_unit_of_measurement=TIME_MINUTES, + native_max_value=FAN_TIMER_MAX, getter=lambda settings: settings.fan_timer, setter=lambda client, value: client.set_fan_timer(value), ), @@ -98,11 +98,11 @@ class ToloNumberEntity(ToloSaunaCoordinatorEntity, NumberEntity): self._attr_unique_id = f"{entry.entry_id}_{entity_description.key}" @property - def value(self) -> float: + def native_value(self) -> float: """Return the value of this TOLO Number entity.""" return self.entity_description.getter(self.coordinator.data.settings) or 0 - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Set the value of this TOLO Number entity.""" int_value = int(value) if int_value == 0: From 05d7d31dfd4d4eb4a8b10a84319d7f2778e65298 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Jun 2022 12:12:07 +0200 Subject: [PATCH 431/947] Improve Elgato error handling (#73444) --- homeassistant/components/elgato/button.py | 7 ++-- homeassistant/components/elgato/light.py | 28 ++++++++++------ tests/components/elgato/test_button.py | 21 +++++++----- tests/components/elgato/test_light.py | 41 +++++++++++++---------- 4 files changed, 58 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/elgato/button.py b/homeassistant/components/elgato/button.py index f2cfc2b8673..c846c42c653 100644 --- a/homeassistant/components/elgato/button.py +++ b/homeassistant/components/elgato/button.py @@ -9,6 +9,7 @@ from homeassistant.components.button import ButtonEntity, ButtonEntityDescriptio from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -49,5 +50,7 @@ class ElgatoIdentifyButton(ElgatoEntity, ButtonEntity): """Identify the light, will make it blink.""" try: await self.client.identify() - except ElgatoError: - _LOGGER.exception("An error occurred while identifying the Elgato Light") + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while identifying the Elgato Light" + ) from error diff --git a/homeassistant/components/elgato/light.py b/homeassistant/components/elgato/light.py index 8b9a7bce7e1..e13119c2887 100644 --- a/homeassistant/components/elgato/light.py +++ b/homeassistant/components/elgato/light.py @@ -15,6 +15,7 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -25,7 +26,7 @@ from homeassistant.helpers.update_coordinator import ( ) from . import HomeAssistantElgatoData -from .const import DOMAIN, LOGGER, SERVICE_IDENTIFY +from .const import DOMAIN, SERVICE_IDENTIFY from .entity import ElgatoEntity PARALLEL_UPDATES = 1 @@ -121,9 +122,12 @@ class ElgatoLight( """Turn off the light.""" try: await self.client.light(on=False) - except ElgatoError: - LOGGER.error("An error occurred while updating the Elgato Light") - await self.coordinator.async_refresh() + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while updating the Elgato Light" + ) from error + finally: + await self.coordinator.async_refresh() async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -159,14 +163,18 @@ class ElgatoLight( saturation=saturation, temperature=temperature, ) - except ElgatoError: - LOGGER.error("An error occurred while updating the Elgato Light") - await self.coordinator.async_refresh() + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while updating the Elgato Light" + ) from error + finally: + await self.coordinator.async_refresh() async def async_identify(self) -> None: """Identify the light, will make it blink.""" try: await self.client.identify() - except ElgatoError: - LOGGER.exception("An error occurred while identifying the Elgato Light") - await self.coordinator.async_refresh() + except ElgatoError as error: + raise HomeAssistantError( + "An error occurred while identifying the Elgato Light" + ) from error diff --git a/tests/components/elgato/test_button.py b/tests/components/elgato/test_button.py index 6f182ee191c..2ab3d7ee7c4 100644 --- a/tests/components/elgato/test_button.py +++ b/tests/components/elgato/test_button.py @@ -8,6 +8,7 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRE from homeassistant.components.elgato.const import DOMAIN from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityCategory @@ -68,17 +69,19 @@ async def test_button_identify_error( hass: HomeAssistant, init_integration: MockConfigEntry, mock_elgato: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test an error occurs with the Elgato identify button.""" mock_elgato.identify.side_effect = ElgatoError - await hass.services.async_call( - BUTTON_DOMAIN, - SERVICE_PRESS, - {ATTR_ENTITY_ID: "button.identify"}, - blocking=True, - ) - await hass.async_block_till_done() + + with pytest.raises( + HomeAssistantError, match="An error occurred while identifying the Elgato Light" + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: "button.identify"}, + blocking=True, + ) + await hass.async_block_till_done() assert len(mock_elgato.identify.mock_calls) == 1 - assert "An error occurred while identifying the Elgato Light" in caplog.text diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py index 743abc1ad49..9cd4f9bd326 100644 --- a/tests/components/elgato/test_light.py +++ b/tests/components/elgato/test_light.py @@ -24,6 +24,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er from tests.common import MockConfigEntry @@ -183,13 +184,15 @@ async def test_light_unavailable( mock_elgato.state.side_effect = ElgatoError mock_elgato.light.side_effect = ElgatoError - await hass.services.async_call( - LIGHT_DOMAIN, - service, - {ATTR_ENTITY_ID: "light.frenck"}, - blocking=True, - ) - await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: "light.frenck"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("light.frenck") assert state assert state.state == STATE_UNAVAILABLE @@ -218,18 +221,20 @@ async def test_light_identify_error( hass: HomeAssistant, init_integration: MockConfigEntry, mock_elgato: MagicMock, - caplog: pytest.LogCaptureFixture, ) -> None: """Test error occurred during identifying an Elgato Light.""" mock_elgato.identify.side_effect = ElgatoError - await hass.services.async_call( - DOMAIN, - SERVICE_IDENTIFY, - { - ATTR_ENTITY_ID: "light.frenck", - }, - blocking=True, - ) - await hass.async_block_till_done() + with pytest.raises( + HomeAssistantError, match="An error occurred while identifying the Elgato Light" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_IDENTIFY, + { + ATTR_ENTITY_ID: "light.frenck", + }, + blocking=True, + ) + await hass.async_block_till_done() + assert len(mock_elgato.identify.mock_calls) == 1 - assert "An error occurred while identifying the Elgato Light" in caplog.text From 658ce9d4f23311ff415406c6cc3ef014ce3b635d Mon Sep 17 00:00:00 2001 From: Thibaut Date: Wed, 15 Jun 2022 12:18:15 +0200 Subject: [PATCH 432/947] Remove Somfy integration (#73527) * Remove somfy * Remove somfy --- .coveragerc | 6 - CODEOWNERS | 2 - homeassistant/components/somfy/__init__.py | 143 ------------ homeassistant/components/somfy/api.py | 37 ---- homeassistant/components/somfy/climate.py | 177 --------------- homeassistant/components/somfy/config_flow.py | 26 --- homeassistant/components/somfy/const.py | 4 - homeassistant/components/somfy/coordinator.py | 71 ------ homeassistant/components/somfy/cover.py | 205 ------------------ homeassistant/components/somfy/entity.py | 73 ------- homeassistant/components/somfy/manifest.json | 17 -- homeassistant/components/somfy/sensor.py | 54 ----- homeassistant/components/somfy/strings.json | 18 -- homeassistant/components/somfy/switch.py | 55 ----- .../components/somfy/translations/bg.json | 17 -- .../components/somfy/translations/ca.json | 18 -- .../components/somfy/translations/cs.json | 18 -- .../components/somfy/translations/da.json | 16 -- .../components/somfy/translations/de.json | 18 -- .../components/somfy/translations/el.json | 18 -- .../components/somfy/translations/en.json | 18 -- .../components/somfy/translations/en_GB.json | 7 - .../components/somfy/translations/es-419.json | 16 -- .../components/somfy/translations/es.json | 18 -- .../components/somfy/translations/et.json | 18 -- .../components/somfy/translations/fr.json | 18 -- .../components/somfy/translations/he.json | 18 -- .../components/somfy/translations/hr.json | 7 - .../components/somfy/translations/hu.json | 18 -- .../components/somfy/translations/id.json | 18 -- .../components/somfy/translations/it.json | 18 -- .../components/somfy/translations/ja.json | 18 -- .../components/somfy/translations/ko.json | 18 -- .../components/somfy/translations/lb.json | 18 -- .../components/somfy/translations/nl.json | 18 -- .../components/somfy/translations/no.json | 18 -- .../components/somfy/translations/pl.json | 18 -- .../components/somfy/translations/pt-BR.json | 18 -- .../components/somfy/translations/pt.json | 18 -- .../components/somfy/translations/ru.json | 18 -- .../components/somfy/translations/sk.json | 7 - .../components/somfy/translations/sl.json | 16 -- .../components/somfy/translations/sv.json | 16 -- .../components/somfy/translations/tr.json | 18 -- .../components/somfy/translations/uk.json | 18 -- .../somfy/translations/zh-Hant.json | 18 -- homeassistant/generated/config_flows.py | 1 - homeassistant/generated/zeroconf.py | 4 - requirements_all.txt | 3 - requirements_test_all.txt | 3 - tests/components/somfy/__init__.py | 1 - tests/components/somfy/test_config_flow.py | 127 ----------- 52 files changed, 1561 deletions(-) delete mode 100644 homeassistant/components/somfy/__init__.py delete mode 100644 homeassistant/components/somfy/api.py delete mode 100644 homeassistant/components/somfy/climate.py delete mode 100644 homeassistant/components/somfy/config_flow.py delete mode 100644 homeassistant/components/somfy/const.py delete mode 100644 homeassistant/components/somfy/coordinator.py delete mode 100644 homeassistant/components/somfy/cover.py delete mode 100644 homeassistant/components/somfy/entity.py delete mode 100644 homeassistant/components/somfy/manifest.json delete mode 100644 homeassistant/components/somfy/sensor.py delete mode 100644 homeassistant/components/somfy/strings.json delete mode 100644 homeassistant/components/somfy/switch.py delete mode 100644 homeassistant/components/somfy/translations/bg.json delete mode 100644 homeassistant/components/somfy/translations/ca.json delete mode 100644 homeassistant/components/somfy/translations/cs.json delete mode 100644 homeassistant/components/somfy/translations/da.json delete mode 100644 homeassistant/components/somfy/translations/de.json delete mode 100644 homeassistant/components/somfy/translations/el.json delete mode 100644 homeassistant/components/somfy/translations/en.json delete mode 100644 homeassistant/components/somfy/translations/en_GB.json delete mode 100644 homeassistant/components/somfy/translations/es-419.json delete mode 100644 homeassistant/components/somfy/translations/es.json delete mode 100644 homeassistant/components/somfy/translations/et.json delete mode 100644 homeassistant/components/somfy/translations/fr.json delete mode 100644 homeassistant/components/somfy/translations/he.json delete mode 100644 homeassistant/components/somfy/translations/hr.json delete mode 100644 homeassistant/components/somfy/translations/hu.json delete mode 100644 homeassistant/components/somfy/translations/id.json delete mode 100644 homeassistant/components/somfy/translations/it.json delete mode 100644 homeassistant/components/somfy/translations/ja.json delete mode 100644 homeassistant/components/somfy/translations/ko.json delete mode 100644 homeassistant/components/somfy/translations/lb.json delete mode 100644 homeassistant/components/somfy/translations/nl.json delete mode 100644 homeassistant/components/somfy/translations/no.json delete mode 100644 homeassistant/components/somfy/translations/pl.json delete mode 100644 homeassistant/components/somfy/translations/pt-BR.json delete mode 100644 homeassistant/components/somfy/translations/pt.json delete mode 100644 homeassistant/components/somfy/translations/ru.json delete mode 100644 homeassistant/components/somfy/translations/sk.json delete mode 100644 homeassistant/components/somfy/translations/sl.json delete mode 100644 homeassistant/components/somfy/translations/sv.json delete mode 100644 homeassistant/components/somfy/translations/tr.json delete mode 100644 homeassistant/components/somfy/translations/uk.json delete mode 100644 homeassistant/components/somfy/translations/zh-Hant.json delete mode 100644 tests/components/somfy/__init__.py delete mode 100644 tests/components/somfy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 455bc7fe8a8..fc40f0f35b9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1122,12 +1122,6 @@ omit = homeassistant/components/soma/cover.py homeassistant/components/soma/sensor.py homeassistant/components/soma/utils.py - homeassistant/components/somfy/__init__.py - homeassistant/components/somfy/api.py - homeassistant/components/somfy/climate.py - homeassistant/components/somfy/cover.py - homeassistant/components/somfy/sensor.py - homeassistant/components/somfy/switch.py homeassistant/components/somfy_mylink/__init__.py homeassistant/components/somfy_mylink/cover.py homeassistant/components/sonos/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 2d4fb5ceebf..d94e8866633 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -966,8 +966,6 @@ build.json @home-assistant/supervisor /tests/components/solax/ @squishykid /homeassistant/components/soma/ @ratsept @sebfortier2288 /tests/components/soma/ @ratsept @sebfortier2288 -/homeassistant/components/somfy/ @tetienne -/tests/components/somfy/ @tetienne /homeassistant/components/sonarr/ @ctalkington /tests/components/sonarr/ @ctalkington /homeassistant/components/songpal/ @rytilahti @shenxn diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py deleted file mode 100644 index ed6c58bc0a0..00000000000 --- a/homeassistant/components/somfy/__init__.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Support for Somfy hubs.""" -from datetime import timedelta -import logging - -from pymfy.api.devices.category import Category -import voluptuous as vol - -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_OPTIMISTIC, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import ( - config_entry_oauth2_flow, - config_validation as cv, - device_registry as dr, -) -from homeassistant.helpers.typing import ConfigType - -from . import api, config_flow -from .const import COORDINATOR, DOMAIN -from .coordinator import SomfyDataUpdateCoordinator - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=1) -SCAN_INTERVAL_ALL_ASSUMED_STATE = timedelta(minutes=60) - -SOMFY_AUTH_CALLBACK_PATH = "/auth/somfy/callback" -SOMFY_AUTH_START = "/auth/somfy" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Inclusive(CONF_CLIENT_ID, "oauth"): cv.string, - vol.Inclusive(CONF_CLIENT_SECRET, "oauth"): cv.string, - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -PLATFORMS = [ - Platform.CLIMATE, - Platform.COVER, - Platform.SENSOR, - Platform.SWITCH, -] - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Somfy component.""" - hass.data[DOMAIN] = {} - domain_config = config.get(DOMAIN, {}) - hass.data[DOMAIN][CONF_OPTIMISTIC] = domain_config.get(CONF_OPTIMISTIC, False) - - if CONF_CLIENT_ID in domain_config: - config_flow.SomfyFlowHandler.async_register_implementation( - hass, - config_entry_oauth2_flow.LocalOAuth2Implementation( - hass, - DOMAIN, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - "https://accounts.somfy.com/oauth/oauth/v2/auth", - "https://accounts.somfy.com/oauth/oauth/v2/token", - ), - ) - - return True - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Somfy from a config entry.""" - - _LOGGER.warning( - "The Somfy integration is deprecated and will be removed " - "in Home Assistant Core 2022.7; due to the Somfy Open API deprecation." - "The Somfy Open API will shutdown June 21st 2022, migrate to the " - "Overkiz integration to control your Somfy devices" - ) - - # Backwards compat - if "auth_implementation" not in entry.data: - hass.config_entries.async_update_entry( - entry, data={**entry.data, "auth_implementation": DOMAIN} - ) - - implementation = ( - await config_entry_oauth2_flow.async_get_config_entry_implementation( - hass, entry - ) - ) - - data = hass.data[DOMAIN] - coordinator = SomfyDataUpdateCoordinator( - hass, - _LOGGER, - name="somfy device update", - client=api.ConfigEntrySomfyApi(hass, entry, implementation), - update_interval=SCAN_INTERVAL, - ) - data[COORDINATOR] = coordinator - - await coordinator.async_config_entry_first_refresh() - - if all(not bool(device.states) for device in coordinator.data.values()): - _LOGGER.debug( - "All devices have assumed state. Update interval has been reduced to: %s", - SCAN_INTERVAL_ALL_ASSUMED_STATE, - ) - coordinator.update_interval = SCAN_INTERVAL_ALL_ASSUMED_STATE - - device_registry = dr.async_get(hass) - - hubs = [ - device - for device in coordinator.data.values() - if Category.HUB.value in device.categories - ] - - for hub in hubs: - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, hub.id)}, - manufacturer="Somfy", - name=hub.name, - model=hub.type, - ) - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/somfy/api.py b/homeassistant/components/somfy/api.py deleted file mode 100644 index bfa834cddb2..00000000000 --- a/homeassistant/components/somfy/api.py +++ /dev/null @@ -1,37 +0,0 @@ -"""API for Somfy bound to Home Assistant OAuth.""" -from __future__ import annotations - -from asyncio import run_coroutine_threadsafe - -from pymfy.api import somfy_api - -from homeassistant import config_entries, core -from homeassistant.helpers import config_entry_oauth2_flow - - -class ConfigEntrySomfyApi(somfy_api.SomfyApi): - """Provide a Somfy API tied into an OAuth2 based config entry.""" - - def __init__( - self, - hass: core.HomeAssistant, - config_entry: config_entries.ConfigEntry, - implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation, - ) -> None: - """Initialize the Config Entry Somfy API.""" - self.hass = hass - self.config_entry = config_entry - self.session = config_entry_oauth2_flow.OAuth2Session( - hass, config_entry, implementation - ) - super().__init__(None, None, token=self.session.token) - - def refresh_tokens( - self, - ) -> dict[str, str | int]: - """Refresh and return new Somfy tokens using Home Assistant OAuth2 session.""" - run_coroutine_threadsafe( - self.session.async_ensure_token_valid(), self.hass.loop - ).result() - - return self.session.token diff --git a/homeassistant/components/somfy/climate.py b/homeassistant/components/somfy/climate.py deleted file mode 100644 index 1384574c3d3..00000000000 --- a/homeassistant/components/somfy/climate.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Support for Somfy Thermostat.""" -from __future__ import annotations - -from pymfy.api.devices.category import Category -from pymfy.api.devices.thermostat import ( - DurationType, - HvacState, - RegulationState, - TargetMode, - Thermostat, -) - -from homeassistant.components.climate import ClimateEntity -from homeassistant.components.climate.const import ( - PRESET_AWAY, - PRESET_HOME, - PRESET_SLEEP, - ClimateEntityFeature, - HVACMode, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import COORDINATOR, DOMAIN -from .entity import SomfyEntity - -SUPPORTED_CATEGORIES = {Category.HVAC.value} - -PRESET_FROST_GUARD = "Frost Guard" -PRESET_GEOFENCING = "Geofencing" -PRESET_MANUAL = "Manual" - -PRESETS_MAPPING = { - TargetMode.AT_HOME: PRESET_HOME, - TargetMode.AWAY: PRESET_AWAY, - TargetMode.SLEEP: PRESET_SLEEP, - TargetMode.MANUAL: PRESET_MANUAL, - TargetMode.GEOFENCING: PRESET_GEOFENCING, - TargetMode.FROST_PROTECTION: PRESET_FROST_GUARD, -} -REVERSE_PRESET_MAPPING = {v: k for k, v in PRESETS_MAPPING.items()} - -HVAC_MODES_MAPPING = {HvacState.COOL: HVACMode.COOL, HvacState.HEAT: HVACMode.HEAT} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy climate platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - climates = [ - SomfyClimate(coordinator, device_id) - for device_id, device in coordinator.data.items() - if SUPPORTED_CATEGORIES & set(device.categories) - ] - - async_add_entities(climates) - - -class SomfyClimate(SomfyEntity, ClimateEntity): - """Representation of a Somfy thermostat device.""" - - _attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE - ) - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self._climate = None - self._create_device() - - def _create_device(self): - """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.coordinator.client) - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - return TEMP_CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._climate.get_ambient_temperature() - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._climate.get_target_temperature() - - def set_temperature(self, **kwargs) -> None: - """Set new target temperature.""" - if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: - return - - self._climate.set_target(TargetMode.MANUAL, temperature, DurationType.NEXT_MODE) - - @property - def max_temp(self) -> float: - """Return the maximum temperature.""" - return 26.0 - - @property - def min_temp(self) -> float: - """Return the minimum temperature.""" - return 15.0 - - @property - def current_humidity(self): - """Return the current humidity.""" - return self._climate.get_humidity() - - @property - def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode.""" - if self._climate.get_regulation_state() == RegulationState.TIMETABLE: - return HVACMode.AUTO - return HVAC_MODES_MAPPING[self._climate.get_hvac_state()] - - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - HEAT and COOL mode are exclusive. End user has to enable a mode manually within the Somfy application. - So only one mode can be displayed. Auto mode is a scheduler. - """ - hvac_state = HVAC_MODES_MAPPING[self._climate.get_hvac_state()] - return [HVACMode.AUTO, hvac_state] - - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: - """Set new target hvac mode.""" - if hvac_mode == HVACMode.AUTO: - self._climate.cancel_target() - else: - self._climate.set_target( - TargetMode.MANUAL, self.target_temperature, DurationType.FURTHER_NOTICE - ) - - @property - def preset_mode(self) -> str | None: - """Return the current preset mode.""" - mode = self._climate.get_target_mode() - return PRESETS_MAPPING.get(mode) - - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return list(PRESETS_MAPPING.values()) - - def set_preset_mode(self, preset_mode: str) -> None: - """Set new preset mode.""" - if self.preset_mode == preset_mode: - return - - if preset_mode == PRESET_HOME: - temperature = self._climate.get_at_home_temperature() - elif preset_mode == PRESET_AWAY: - temperature = self._climate.get_away_temperature() - elif preset_mode == PRESET_SLEEP: - temperature = self._climate.get_night_temperature() - elif preset_mode == PRESET_FROST_GUARD: - temperature = self._climate.get_frost_protection_temperature() - elif preset_mode in (PRESET_MANUAL, PRESET_GEOFENCING): - temperature = self.target_temperature - else: - raise ValueError(f"Preset mode not supported: {preset_mode}") - - self._climate.set_target( - REVERSE_PRESET_MAPPING[preset_mode], temperature, DurationType.NEXT_MODE - ) diff --git a/homeassistant/components/somfy/config_flow.py b/homeassistant/components/somfy/config_flow.py deleted file mode 100644 index 05d1720cf6d..00000000000 --- a/homeassistant/components/somfy/config_flow.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Config flow for Somfy.""" -import logging - -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import DOMAIN - - -class SomfyFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN -): - """Config flow to handle Somfy OAuth2 authentication.""" - - DOMAIN = DOMAIN - - @property - def logger(self) -> logging.Logger: - """Return logger.""" - return logging.getLogger(__name__) - - async def async_step_user(self, user_input=None): - """Handle a flow start.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - return await super().async_step_user(user_input) diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py deleted file mode 100644 index 6c7c23e3ab3..00000000000 --- a/homeassistant/components/somfy/const.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Define constants for the Somfy component.""" - -DOMAIN = "somfy" -COORDINATOR = "coordinator" diff --git a/homeassistant/components/somfy/coordinator.py b/homeassistant/components/somfy/coordinator.py deleted file mode 100644 index a22f8185702..00000000000 --- a/homeassistant/components/somfy/coordinator.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Helpers to help coordinate updated.""" -from __future__ import annotations - -from datetime import timedelta -import logging - -from pymfy.api.error import QuotaViolationException, SetupNotFoundException -from pymfy.api.model import Device -from pymfy.api.somfy_api import SomfyApi - -from homeassistant.core import HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator - - -class SomfyDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Somfy data.""" - - def __init__( - self, - hass: HomeAssistant, - logger: logging.Logger, - *, - name: str, - client: SomfyApi, - update_interval: timedelta | None = None, - ) -> None: - """Initialize global data updater.""" - super().__init__( - hass, - logger, - name=name, - update_interval=update_interval, - ) - self.data = {} - self.client = client - self.site_device: dict[str, list] = {} - self.last_site_index = -1 - - async def _async_update_data(self) -> dict[str, Device]: - """Fetch Somfy data. - - Somfy only allow one call per minute to /site. There is one exception: 2 calls are allowed after site retrieval. - """ - if not self.site_device: - sites = await self.hass.async_add_executor_job(self.client.get_sites) - if not sites: - return {} - self.site_device = {site.id: [] for site in sites} - - site_id = self._site_id - try: - devices = await self.hass.async_add_executor_job( - self.client.get_devices, site_id - ) - self.site_device[site_id] = devices - except SetupNotFoundException: - del self.site_device[site_id] - return await self._async_update_data() - except QuotaViolationException: - self.logger.warning("Quota violation") - - return {dev.id: dev for devices in self.site_device.values() for dev in devices} - - @property - def _site_id(self): - """Return the next site id to retrieve. - - This tweak is required as Somfy does not allow to call the /site entrypoint more than once per minute. - """ - self.last_site_index = (self.last_site_index + 1) % len(self.site_device) - return list(self.site_device.keys())[self.last_site_index] diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py deleted file mode 100644 index a2a72d4ce98..00000000000 --- a/homeassistant/components/somfy/cover.py +++ /dev/null @@ -1,205 +0,0 @@ -"""Support for Somfy Covers.""" -from __future__ import annotations - -from typing import cast - -from pymfy.api.devices.blind import Blind -from pymfy.api.devices.category import Category - -from homeassistant.components.cover import ( - ATTR_POSITION, - ATTR_TILT_POSITION, - CoverDeviceClass, - CoverEntity, - CoverEntityFeature, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_OPTIMISTIC, STATE_CLOSED, STATE_OPEN -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity - -from .const import COORDINATOR, DOMAIN -from .coordinator import SomfyDataUpdateCoordinator -from .entity import SomfyEntity - -BLIND_DEVICE_CATEGORIES = {Category.INTERIOR_BLIND.value, Category.EXTERIOR_BLIND.value} -SHUTTER_DEVICE_CATEGORIES = {Category.EXTERIOR_BLIND.value} -SUPPORTED_CATEGORIES = { - Category.ROLLER_SHUTTER.value, - Category.INTERIOR_BLIND.value, - Category.EXTERIOR_BLIND.value, -} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy cover platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - covers = [ - SomfyCover(coordinator, device_id, domain_data[CONF_OPTIMISTIC]) - for device_id, device in coordinator.data.items() - if SUPPORTED_CATEGORIES & set(device.categories) - ] - - async_add_entities(covers) - - -class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity): - """Representation of a Somfy cover device.""" - - def __init__(self, coordinator, device_id, optimistic): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self.categories = set(self.device.categories) - self.optimistic = optimistic - self._closed = None - self._is_opening = None - self._is_closing = None - self._cover = None - self._create_device() - - def _create_device(self) -> Blind: - """Update the device with the latest data.""" - self._cover = Blind( - self.device, cast(SomfyDataUpdateCoordinator, self.coordinator).client - ) - - @property - def supported_features(self) -> int: - """Flag supported features.""" - supported_features = 0 - if self.has_capability("open"): - supported_features |= CoverEntityFeature.OPEN - if self.has_capability("close"): - supported_features |= CoverEntityFeature.CLOSE - if self.has_capability("stop"): - supported_features |= CoverEntityFeature.STOP - if self.has_capability("position"): - supported_features |= CoverEntityFeature.SET_POSITION - if self.has_capability("rotation"): - supported_features |= ( - CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.STOP_TILT - | CoverEntityFeature.SET_TILT_POSITION - ) - - return supported_features - - async def async_close_cover(self, **kwargs): - """Close the cover.""" - self._is_closing = True - self.async_write_ha_state() - try: - # Blocks until the close command is sent - await self.hass.async_add_executor_job(self._cover.close) - self._closed = True - finally: - self._is_closing = None - self.async_write_ha_state() - - async def async_open_cover(self, **kwargs): - """Open the cover.""" - self._is_opening = True - self.async_write_ha_state() - try: - # Blocks until the open command is sent - await self.hass.async_add_executor_job(self._cover.open) - self._closed = False - finally: - self._is_opening = None - self.async_write_ha_state() - - def stop_cover(self, **kwargs): - """Stop the cover.""" - self._cover.stop() - - def set_cover_position(self, **kwargs): - """Move the cover shutter to a specific position.""" - self._cover.set_position(100 - kwargs[ATTR_POSITION]) - - @property - def device_class(self): - """Return the device class.""" - if self.categories & BLIND_DEVICE_CATEGORIES: - return CoverDeviceClass.BLIND - if self.categories & SHUTTER_DEVICE_CATEGORIES: - return CoverDeviceClass.SHUTTER - return None - - @property - def current_cover_position(self): - """Return the current position of cover shutter.""" - if not self.has_state("position"): - return None - return 100 - self._cover.get_position() - - @property - def is_opening(self): - """Return if the cover is opening.""" - if not self.optimistic: - return None - return self._is_opening - - @property - def is_closing(self): - """Return if the cover is closing.""" - if not self.optimistic: - return None - return self._is_closing - - @property - def is_closed(self) -> bool | None: - """Return if the cover is closed.""" - is_closed = None - if self.has_state("position"): - is_closed = self._cover.is_closed() - elif self.optimistic: - is_closed = self._closed - return is_closed - - @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt. - - None is unknown, 0 is closed, 100 is fully open. - """ - if not self.has_state("orientation"): - return None - return 100 - self._cover.orientation - - def set_cover_tilt_position(self, **kwargs): - """Move the cover tilt to a specific position.""" - self._cover.orientation = 100 - kwargs[ATTR_TILT_POSITION] - - def open_cover_tilt(self, **kwargs): - """Open the cover tilt.""" - self._cover.orientation = 0 - - def close_cover_tilt(self, **kwargs): - """Close the cover tilt.""" - self._cover.orientation = 100 - - def stop_cover_tilt(self, **kwargs): - """Stop the cover.""" - self._cover.stop() - - async def async_added_to_hass(self): - """Complete the initialization.""" - await super().async_added_to_hass() - if not self.optimistic: - return - # Restore the last state if we use optimistic - last_state = await self.async_get_last_state() - - if last_state is not None and last_state.state in ( - STATE_OPEN, - STATE_CLOSED, - ): - self._closed = last_state.state == STATE_CLOSED diff --git a/homeassistant/components/somfy/entity.py b/homeassistant/components/somfy/entity.py deleted file mode 100644 index 2d92c8a77c0..00000000000 --- a/homeassistant/components/somfy/entity.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Entity representing a Somfy device.""" - -from abc import abstractmethod - -from homeassistant.core import callback -from homeassistant.helpers.entity import DeviceInfo, Entity -from homeassistant.helpers.update_coordinator import CoordinatorEntity - -from .const import DOMAIN - - -class SomfyEntity(CoordinatorEntity, Entity): - """Representation of a generic Somfy device.""" - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator) - self._id = device_id - - @property - def device(self): - """Return data for the device id.""" - return self.coordinator.data[self._id] - - @property - def unique_id(self) -> str: - """Return the unique id base on the id returned by Somfy.""" - return self._id - - @property - def name(self) -> str: - """Return the name of the device.""" - return self.device.name - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes. - - Implemented by platform classes. - """ - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - name=self.name, - model=self.device.type, - via_device=(DOMAIN, self.device.parent_id), - # For the moment, Somfy only returns their own device. - manufacturer="Somfy", - ) - - def has_capability(self, capability: str) -> bool: - """Test if device has a capability.""" - capabilities = self.device.capabilities - return bool([c for c in capabilities if c.name == capability]) - - def has_state(self, state: str) -> bool: - """Test if device has a state.""" - states = self.device.states - return bool([c for c in states if c.name == state]) - - @property - def assumed_state(self) -> bool: - """Return if the device has an assumed state.""" - return not bool(self.device.states) - - @callback - def _handle_coordinator_update(self): - """Process an update from the coordinator.""" - self._create_device() - super()._handle_coordinator_update() - - @abstractmethod - def _create_device(self): - """Update the device with the latest data.""" diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json deleted file mode 100644 index 76e3d281228..00000000000 --- a/homeassistant/components/somfy/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "domain": "somfy", - "name": "Somfy", - "config_flow": true, - "documentation": "https://www.home-assistant.io/integrations/somfy", - "dependencies": ["auth"], - "codeowners": ["@tetienne"], - "requirements": ["pymfy==0.11.0"], - "zeroconf": [ - { - "type": "_kizbox._tcp.local.", - "name": "gateway*" - } - ], - "iot_class": "cloud_polling", - "loggers": ["pymfy"] -} diff --git a/homeassistant/components/somfy/sensor.py b/homeassistant/components/somfy/sensor.py deleted file mode 100644 index 6dcc45b78a5..00000000000 --- a/homeassistant/components/somfy/sensor.py +++ /dev/null @@ -1,54 +0,0 @@ -"""Support for Somfy Thermostat Battery.""" -from pymfy.api.devices.category import Category -from pymfy.api.devices.thermostat import Thermostat - -from homeassistant.components.sensor import SensorDeviceClass, SensorEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import COORDINATOR, DOMAIN -from .entity import SomfyEntity - -SUPPORTED_CATEGORIES = {Category.HVAC.value} - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy sensor platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - sensors = [ - SomfyThermostatBatterySensor(coordinator, device_id) - for device_id, device in coordinator.data.items() - if SUPPORTED_CATEGORIES & set(device.categories) - ] - - async_add_entities(sensors) - - -class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): - """Representation of a Somfy thermostat battery.""" - - _attr_device_class = SensorDeviceClass.BATTERY - _attr_native_unit_of_measurement = PERCENTAGE - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self._climate = None - self._create_device() - - def _create_device(self): - """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.coordinator.client) - - @property - def native_value(self) -> int: - """Return the state of the sensor.""" - return self._climate.get_battery() diff --git a/homeassistant/components/somfy/strings.json b/homeassistant/components/somfy/strings.json deleted file mode 100644 index 85ef981e356..00000000000 --- a/homeassistant/components/somfy/strings.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "step": { - "pick_implementation": { - "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" - } - }, - "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", - "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", - "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]" - }, - "create_entry": { - "default": "[%key:common::config_flow::create_entry::authenticated%]" - } - } -} diff --git a/homeassistant/components/somfy/switch.py b/homeassistant/components/somfy/switch.py deleted file mode 100644 index d86fc051a14..00000000000 --- a/homeassistant/components/somfy/switch.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Support for Somfy Camera Shutter.""" -from pymfy.api.devices.camera_protect import CameraProtect -from pymfy.api.devices.category import Category - -from homeassistant.components.switch import SwitchEntity -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity_platform import AddEntitiesCallback - -from .const import COORDINATOR, DOMAIN -from .entity import SomfyEntity - - -async def async_setup_entry( - hass: HomeAssistant, - config_entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Set up the Somfy switch platform.""" - domain_data = hass.data[DOMAIN] - coordinator = domain_data[COORDINATOR] - - switches = [ - SomfyCameraShutter(coordinator, device_id) - for device_id, device in coordinator.data.items() - if Category.CAMERA.value in device.categories - ] - - async_add_entities(switches) - - -class SomfyCameraShutter(SomfyEntity, SwitchEntity): - """Representation of a Somfy Camera Shutter device.""" - - def __init__(self, coordinator, device_id): - """Initialize the Somfy device.""" - super().__init__(coordinator, device_id) - self._create_device() - - def _create_device(self): - """Update the device with the latest data.""" - self.shutter = CameraProtect(self.device, self.coordinator.client) - - def turn_on(self, **kwargs) -> None: - """Turn the entity on.""" - self.shutter.open_shutter() - - def turn_off(self, **kwargs): - """Turn the entity off.""" - self.shutter.close_shutter() - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self.shutter.get_shutter_position() == "opened" diff --git a/homeassistant/components/somfy/translations/bg.json b/homeassistant/components/somfy/translations/bg.json deleted file mode 100644 index 62905ef389e..00000000000 --- a/homeassistant/components/somfy/translations/bg.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0434\u0440\u0435\u0441 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0432 \u0441\u0440\u043e\u043a.", - "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 Somfy \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430.", - "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." - }, - "create_entry": { - "default": "\u0423\u0441\u043f\u0435\u0448\u043d\u043e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0438\u0440\u0430\u043d\u0435 \u0441\u044a\u0441 Somfy." - }, - "step": { - "pick_implementation": { - "title": "\u0418\u0437\u0431\u043e\u0440 \u043d\u0430 \u043c\u0435\u0442\u043e\u0434 \u0437\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ca.json b/homeassistant/components/somfy/translations/ca.json deleted file mode 100644 index bc34c57c939..00000000000 --- a/homeassistant/components/somfy/translations/ca.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", - "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", - "no_url_available": "No hi ha cap URL disponible. Per a m\u00e9s informaci\u00f3 sobre aquest error, [consulta la secci\u00f3 d'ajuda]({docs_url})", - "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3." - }, - "create_entry": { - "default": "Autenticaci\u00f3 exitosa" - }, - "step": { - "pick_implementation": { - "title": "Selecciona el m\u00e8tode d'autenticaci\u00f3" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/cs.json b/homeassistant/components/somfy/translations/cs.json deleted file mode 100644 index acc7d260cad..00000000000 --- a/homeassistant/components/somfy/translations/cs.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el", - "missing_configuration": "Komponenta nen\u00ed nastavena. Postupujte podle dokumentace.", - "no_url_available": "Nen\u00ed k dispozici \u017e\u00e1dn\u00e1 adresa URL. Informace o t\u00e9to chyb\u011b naleznete [v sekci n\u00e1pov\u011bdy]({docs_url})", - "single_instance_allowed": "Ji\u017e nastaveno. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace." - }, - "create_entry": { - "default": "\u00dasp\u011b\u0161n\u011b ov\u011b\u0159eno" - }, - "step": { - "pick_implementation": { - "title": "Vyberte metodu ov\u011b\u0159en\u00ed" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/da.json b/homeassistant/components/somfy/translations/da.json deleted file mode 100644 index 3b7a79ef008..00000000000 --- a/homeassistant/components/somfy/translations/da.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout ved generering af autoriseret url.", - "missing_configuration": "Komponenten Somfy er ikke konfigureret. F\u00f8lg venligst dokumentationen." - }, - "create_entry": { - "default": "Godkendt med Somfy." - }, - "step": { - "pick_implementation": { - "title": "V\u00e6lg godkendelsesmetode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/de.json b/homeassistant/components/somfy/translations/de.json deleted file mode 100644 index 29a959f48ce..00000000000 --- a/homeassistant/components/somfy/translations/de.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", - "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", - "no_url_available": "Keine URL verf\u00fcgbar. Informationen zu diesem Fehler findest du [im Hilfebereich]({docs_url}).", - "single_instance_allowed": "Bereits konfiguriert. Nur eine einzige Konfiguration m\u00f6glich." - }, - "create_entry": { - "default": "Erfolgreich authentifiziert" - }, - "step": { - "pick_implementation": { - "title": "W\u00e4hle die Authentifizierungsmethode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/el.json b/homeassistant/components/somfy/translations/el.json deleted file mode 100644 index 8d1f457ae10..00000000000 --- a/homeassistant/components/somfy/translations/el.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7\u03c2.", - "missing_configuration": "\u03a4\u03bf \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03bf \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7.", - "no_url_available": "\u0394\u03b5\u03bd \u03c5\u03c0\u03ac\u03c1\u03c7\u03b5\u03b9 \u03b4\u03b9\u03b1\u03b8\u03ad\u03c3\u03b9\u03bc\u03b7 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7 URL. \u0393\u03b9\u03b1 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2 \u03c3\u03c7\u03b5\u03c4\u03b9\u03ba\u03ac \u03bc\u03b5 \u03b1\u03c5\u03c4\u03cc \u03c4\u03bf \u03c3\u03c6\u03ac\u03bb\u03bc\u03b1, [\u03b5\u03bb\u03ad\u03b3\u03be\u03c4\u03b5 \u03c4\u03b7\u03bd \u03b5\u03bd\u03cc\u03c4\u03b7\u03c4\u03b1 \u03b2\u03bf\u03ae\u03b8\u03b5\u03b9\u03b1\u03c2] ( {docs_url} )", - "single_instance_allowed": "\u03a1\u03c5\u03b8\u03bc\u03af\u03c3\u03c4\u03b7\u03ba\u03b5 \u03ae\u03b4\u03b7. \u039c\u03cc\u03bd\u03bf \u03bc\u03af\u03b1 \u03c0\u03b1\u03c1\u03b1\u03bc\u03b5\u03c4\u03c1\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7 \u03b5\u03af\u03bd\u03b1\u03b9 \u03b4\u03c5\u03bd\u03b1\u03c4\u03ae." - }, - "create_entry": { - "default": "\u0395\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2" - }, - "step": { - "pick_implementation": { - "title": "\u0395\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae \u03bc\u03b5\u03b8\u03cc\u03b4\u03bf\u03c5 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/en.json b/homeassistant/components/somfy/translations/en.json deleted file mode 100644 index e0072d1da4d..00000000000 --- a/homeassistant/components/somfy/translations/en.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout generating authorize URL.", - "missing_configuration": "The component is not configured. Please follow the documentation.", - "no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})", - "single_instance_allowed": "Already configured. Only a single configuration possible." - }, - "create_entry": { - "default": "Successfully authenticated" - }, - "step": { - "pick_implementation": { - "title": "Pick Authentication Method" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/en_GB.json b/homeassistant/components/somfy/translations/en_GB.json deleted file mode 100644 index ddf7ee6d5dd..00000000000 --- a/homeassistant/components/somfy/translations/en_GB.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout generating authorise URL." - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/es-419.json b/homeassistant/components/somfy/translations/es-419.json deleted file mode 100644 index 6acd9bb6bb8..00000000000 --- a/homeassistant/components/somfy/translations/es-419.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tiempo de espera agotado para generar la URL de autorizaci\u00f3n.", - "missing_configuration": "El componente Somfy no est\u00e1 configurado. Por favor, siga la documentaci\u00f3n." - }, - "create_entry": { - "default": "Autenticado con \u00e9xito con Somfy." - }, - "step": { - "pick_implementation": { - "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/es.json b/homeassistant/components/somfy/translations/es.json deleted file mode 100644 index 2f8b35f5af8..00000000000 --- a/homeassistant/components/somfy/translations/es.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tiempo de espera agotado durante la generaci\u00f3n de la URL de autorizaci\u00f3n.", - "missing_configuration": "El componente no est\u00e1 configurado. Consulta la documentaci\u00f3n.", - "no_url_available": "No hay URL disponible. Para obtener informaci\u00f3n sobre este error, [consulta la secci\u00f3n de ayuda]({docs_url})", - "single_instance_allowed": "Ya est\u00e1 configurado. Solo es posible una \u00fanica configuraci\u00f3n." - }, - "create_entry": { - "default": "Autenticado correctamente" - }, - "step": { - "pick_implementation": { - "title": "Seleccione el m\u00e9todo de autenticaci\u00f3n" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/et.json b/homeassistant/components/somfy/translations/et.json deleted file mode 100644 index 9239f7df0ef..00000000000 --- a/homeassistant/components/somfy/translations/et.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Kinnitus-URLi loomise ajal\u00f5pp.", - "missing_configuration": "Komponent pole seadistatud. Palun loe dokumentatsiooni.", - "no_url_available": "URL pole saadaval. Rohkem teavet [check the help section]({docs_url})", - "single_instance_allowed": "Juba seadistatud. V\u00f5imalik on ainult \u00fcks seadistamine." - }, - "create_entry": { - "default": "Edukalt tuvastatud" - }, - "step": { - "pick_implementation": { - "title": "Vali tuvastusmeetod" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/fr.json b/homeassistant/components/somfy/translations/fr.json deleted file mode 100644 index 0c7a25831bc..00000000000 --- a/homeassistant/components/somfy/translations/fr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification expir\u00e9.", - "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", - "no_url_available": "Aucune URL disponible. Pour plus d'informations sur cette erreur, [consultez la section d'aide]({docs_url})", - "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible." - }, - "create_entry": { - "default": "Authentification r\u00e9ussie" - }, - "step": { - "pick_implementation": { - "title": "S\u00e9lectionner une m\u00e9thode d'authentification" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/he.json b/homeassistant/components/somfy/translations/he.json deleted file mode 100644 index c68d7f74d85..00000000000 --- a/homeassistant/components/somfy/translations/he.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u05e4\u05e1\u05e7 \u05d6\u05de\u05df \u05dc\u05d9\u05e6\u05d9\u05e8\u05ea \u05db\u05ea\u05d5\u05d1\u05ea URL \u05dc\u05d0\u05d9\u05e9\u05d5\u05e8.", - "missing_configuration": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05e8\u05db\u05d9\u05d1 \u05dc\u05d0 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e0\u05d0 \u05e2\u05e7\u05d5\u05d1 \u05d0\u05d7\u05e8 \u05d4\u05ea\u05d9\u05e2\u05d5\u05d3.", - "no_url_available": "\u05d0\u05d9\u05df \u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05d6\u05de\u05d9\u05e0\u05d4. \u05e7\u05d1\u05dc\u05ea \u05de\u05d9\u05d3\u05e2 \u05e2\u05dc \u05e9\u05d2\u05d9\u05d0\u05d4 \u05d6\u05d5, [\u05e2\u05d9\u05d9\u05df \u05d1\u05e1\u05e2\u05d9\u05e3 \u05d4\u05e2\u05d6\u05e8\u05d4] ({docs_url})", - "single_instance_allowed": "\u05ea\u05e6\u05d5\u05e8\u05ea\u05d5 \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4. \u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d0\u05e4\u05e9\u05e8\u05d9\u05ea." - }, - "create_entry": { - "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" - }, - "step": { - "pick_implementation": { - "title": "\u05d1\u05d7\u05e8 \u05e9\u05d9\u05d8\u05ea \u05d0\u05d9\u05de\u05d5\u05ea" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/hr.json b/homeassistant/components/somfy/translations/hr.json deleted file mode 100644 index a601eb2b9bf..00000000000 --- a/homeassistant/components/somfy/translations/hr.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "create_entry": { - "default": "Uspje\u0161no autentificirano sa Somfy." - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/hu.json b/homeassistant/components/somfy/translations/hu.json deleted file mode 100644 index 96b873b2c42..00000000000 --- a/homeassistant/components/somfy/translations/hu.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a hiteles\u00edt\u00e9si URL gener\u00e1l\u00e1sa sor\u00e1n.", - "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", - "no_url_available": "Nincs el\u00e9rhet\u0151 URL. A hib\u00e1r\u00f3l tov\u00e1bbi inform\u00e1ci\u00f3 [a s\u00fag\u00f3ban]({docs_url}) tal\u00e1lhat\u00f3.", - "single_instance_allowed": "M\u00e1r konfigur\u00e1lva van. Csak egy konfigur\u00e1ci\u00f3 lehets\u00e9ges." - }, - "create_entry": { - "default": "Sikeres hiteles\u00edt\u00e9s" - }, - "step": { - "pick_implementation": { - "title": "V\u00e1lasszon egy hiteles\u00edt\u00e9si m\u00f3dszert" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/id.json b/homeassistant/components/somfy/translations/id.json deleted file mode 100644 index 2d229de00d5..00000000000 --- a/homeassistant/components/somfy/translations/id.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", - "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", - "no_url_available": "Tidak ada URL yang tersedia. Untuk informasi tentang kesalahan ini, [lihat bagian bantuan]({docs_url})", - "single_instance_allowed": "Sudah dikonfigurasi. Hanya satu konfigurasi yang diizinkan." - }, - "create_entry": { - "default": "Berhasil diautentikasi" - }, - "step": { - "pick_implementation": { - "title": "Pilih Metode Autentikasi" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/it.json b/homeassistant/components/somfy/translations/it.json deleted file mode 100644 index 0201e1e2569..00000000000 --- a/homeassistant/components/somfy/translations/it.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.", - "missing_configuration": "Il componente non \u00e8 configurato. Segui la documentazione.", - "no_url_available": "Nessun URL disponibile. Per informazioni su questo errore, [controlla la sezione della guida]({docs_url})", - "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione." - }, - "create_entry": { - "default": "Autenticazione riuscita" - }, - "step": { - "pick_implementation": { - "title": "Scegli il metodo di autenticazione" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ja.json b/homeassistant/components/somfy/translations/ja.json deleted file mode 100644 index 3c25bd7bb8f..00000000000 --- a/homeassistant/components/somfy/translations/ja.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", - "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", - "no_url_available": "\u4f7f\u7528\u53ef\u80fd\u306aURL\u304c\u3042\u308a\u307e\u305b\u3093\u3002\u3053\u306e\u30a8\u30e9\u30fc\u306e\u8a73\u7d30\u306b\u3064\u3044\u3066\u306f\u3001[\u30d8\u30eb\u30d7\u30bb\u30af\u30b7\u30e7\u30f3\u3092\u78ba\u8a8d\u3057\u3066\u304f\u3060\u3055\u3044]({docs_url})", - "single_instance_allowed": "\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059\u3002\u5358\u4e00\u306e\u8a2d\u5b9a\u3057\u304b\u3067\u304d\u307e\u305b\u3093\u3002" - }, - "create_entry": { - "default": "\u6b63\u5e38\u306b\u8a8d\u8a3c\u3055\u308c\u307e\u3057\u305f" - }, - "step": { - "pick_implementation": { - "title": "\u8a8d\u8a3c\u65b9\u6cd5\u306e\u9078\u629e" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ko.json b/homeassistant/components/somfy/translations/ko.json deleted file mode 100644 index 568c8d05116..00000000000 --- a/homeassistant/components/somfy/translations/ko.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.", - "no_url_available": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c URL\uc774 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uc5d0 \ub300\ud55c \uc790\uc138\ud55c \ub0b4\uc6a9\uc740 [\ub3c4\uc6c0\ub9d0 \uc139\uc158]({docs_url}) \uc744(\ub97c) \ucc38\uc870\ud574\uc8fc\uc138\uc694.", - "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." - }, - "create_entry": { - "default": "\uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" - }, - "step": { - "pick_implementation": { - "title": "\uc778\uc99d \ubc29\ubc95 \uc120\ud0dd\ud558\uae30" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/lb.json b/homeassistant/components/somfy/translations/lb.json deleted file mode 100644 index a463473c2e1..00000000000 --- a/homeassistant/components/somfy/translations/lb.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.", - "missing_configuration": "Komponent ass nach net konfigur\u00e9iert. Folleg w.e.g der Dokumentatioun.", - "no_url_available": "Keng URL disponibel. Fir Informatiounen iwwert d\u00ebse Feeler, [kuck H\u00ebllef Sektioun]({docs_url})", - "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun ass m\u00e9iglech." - }, - "create_entry": { - "default": "Erfollegr\u00e4ich authentifiz\u00e9iert." - }, - "step": { - "pick_implementation": { - "title": "Wiel Authentifikatiouns Method aus" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/nl.json b/homeassistant/components/somfy/translations/nl.json deleted file mode 100644 index efd07952467..00000000000 --- a/homeassistant/components/somfy/translations/nl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Time-out bij het genereren van autorisatie-URL.", - "missing_configuration": "Integratie niet geconfigureerd. Raadpleeg de documentatie.", - "no_url_available": "Geen URL beschikbaar. Voor informatie over deze fout, [raadpleeg de documentatie]({docs_url})", - "single_instance_allowed": "Al geconfigureerd. Slechts \u00e9\u00e9n configuratie mogelijk." - }, - "create_entry": { - "default": "Authenticatie geslaagd" - }, - "step": { - "pick_implementation": { - "title": "Kies een authenticatie methode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/no.json b/homeassistant/components/somfy/translations/no.json deleted file mode 100644 index 57bc6e68436..00000000000 --- a/homeassistant/components/somfy/translations/no.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", - "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", - "no_url_available": "Ingen URL tilgjengelig. For informasjon om denne feilen, [sjekk hjelpseksjonen]({docs_url})", - "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig." - }, - "create_entry": { - "default": "Vellykket godkjenning" - }, - "step": { - "pick_implementation": { - "title": "Velg godkjenningsmetode" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/pl.json b/homeassistant/components/somfy/translations/pl.json deleted file mode 100644 index baeb38e755e..00000000000 --- a/homeassistant/components/somfy/translations/pl.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji", - "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", - "no_url_available": "Brak dost\u0119pnego adresu URL. Aby uzyska\u0107 informacje na temat tego b\u0142\u0119du, [sprawd\u017a sekcj\u0119 pomocy] ({docs_url})", - "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja." - }, - "create_entry": { - "default": "Pomy\u015blnie uwierzytelniono" - }, - "step": { - "pick_implementation": { - "title": "Wybierz metod\u0119 uwierzytelniania" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/pt-BR.json b/homeassistant/components/somfy/translations/pt-BR.json deleted file mode 100644 index 8ad5fac9044..00000000000 --- a/homeassistant/components/somfy/translations/pt-BR.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", - "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "N\u00e3o h\u00e1 URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a se\u00e7\u00e3o de ajuda]({docs_url})", - "single_instance_allowed": "J\u00e1 configurado. Apenas uma configura\u00e7\u00e3o \u00e9 poss\u00edvel." - }, - "create_entry": { - "default": "Autenticado com sucesso" - }, - "step": { - "pick_implementation": { - "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/pt.json b/homeassistant/components/somfy/translations/pt.json deleted file mode 100644 index 592ccd85589..00000000000 --- a/homeassistant/components/somfy/translations/pt.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Tempo excedido a gerar um URL de autoriza\u00e7\u00e3o", - "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", - "no_url_available": "Nenhum URL dispon\u00edvel. Para obter informa\u00e7\u00f5es sobre esse erro, [verifique a sec\u00e7\u00e3o de ajuda]({docs_url})", - "single_instance_allowed": "J\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel." - }, - "create_entry": { - "default": "Autenticado com sucesso" - }, - "step": { - "pick_implementation": { - "title": "Escolha o m\u00e9todo de autentica\u00e7\u00e3o" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/ru.json b/homeassistant/components/somfy/translations/ru.json deleted file mode 100644 index 38ac0dda412..00000000000 --- a/homeassistant/components/somfy/translations/ru.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", - "missing_configuration": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439.", - "no_url_available": "URL-\u0430\u0434\u0440\u0435\u0441 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d. \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e\u0431 \u044d\u0442\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435.", - "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e." - }, - "create_entry": { - "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." - }, - "step": { - "pick_implementation": { - "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043f\u043e\u0441\u043e\u0431 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/sk.json b/homeassistant/components/somfy/translations/sk.json deleted file mode 100644 index c19b1a0b70c..00000000000 --- a/homeassistant/components/somfy/translations/sk.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "config": { - "create_entry": { - "default": "\u00daspe\u0161ne overen\u00e9" - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/sl.json b/homeassistant/components/somfy/translations/sl.json deleted file mode 100644 index 3b9bc038fe6..00000000000 --- a/homeassistant/components/somfy/translations/sl.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", - "missing_configuration": "Komponenta Somfy ni konfigurirana. Upo\u0161tevajte dokumentacijo." - }, - "create_entry": { - "default": "Uspe\u0161no overjen s Somfy-jem." - }, - "step": { - "pick_implementation": { - "title": "Izberite na\u010din preverjanja pristnosti" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/sv.json b/homeassistant/components/somfy/translations/sv.json deleted file mode 100644 index cf0a8ec75bf..00000000000 --- a/homeassistant/components/somfy/translations/sv.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Timeout vid skapandet av en auktoriseringsadress.", - "missing_configuration": "Somfy-komponenten \u00e4r inte konfigurerad. V\u00e4nligen f\u00f6lj dokumentationen." - }, - "create_entry": { - "default": "Lyckad autentisering med Somfy." - }, - "step": { - "pick_implementation": { - "title": "V\u00e4lj autentiseringsmetod" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/tr.json b/homeassistant/components/somfy/translations/tr.json deleted file mode 100644 index b3b645cd52d..00000000000 --- a/homeassistant/components/somfy/translations/tr.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", - "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", - "no_url_available": "Kullan\u0131labilir URL yok. Bu hata hakk\u0131nda bilgi i\u00e7in [yard\u0131m b\u00f6l\u00fcm\u00fcne bak\u0131n]({docs_url})", - "single_instance_allowed": "Zaten yap\u0131land\u0131r\u0131lm\u0131\u015f. Yaln\u0131zca tek bir konfig\u00fcrasyon m\u00fcmk\u00fcnd\u00fcr." - }, - "create_entry": { - "default": "Ba\u015far\u0131yla do\u011fruland\u0131" - }, - "step": { - "pick_implementation": { - "title": "Kimlik Do\u011frulama Y\u00f6ntemini Se\u00e7" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/uk.json b/homeassistant/components/somfy/translations/uk.json deleted file mode 100644 index 207169ad6b0..00000000000 --- a/homeassistant/components/somfy/translations/uk.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u041c\u0438\u043d\u0443\u0432 \u0447\u0430\u0441 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0456\u0457 \u043f\u043e\u0441\u0438\u043b\u0430\u043d\u043d\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457.", - "missing_configuration": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0438 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f. \u0411\u0443\u0434\u044c \u043b\u0430\u0441\u043a\u0430, \u043e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 \u0456\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0456\u044f\u043c\u0438.", - "no_url_available": "URL-\u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430. \u041e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0456\u0454\u044e] ({docs_url}) \u0434\u043b\u044f \u043e\u0442\u0440\u0438\u043c\u0430\u043d\u043d\u044f \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457 \u043f\u0440\u043e \u0446\u044e \u043f\u043e\u043c\u0438\u043b\u043a\u0443.", - "single_instance_allowed": "\u0412\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e. \u041c\u043e\u0436\u043b\u0438\u0432\u0430 \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044f." - }, - "create_entry": { - "default": "\u0410\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044e \u0443\u0441\u043f\u0456\u0448\u043d\u043e \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u043e." - }, - "step": { - "pick_implementation": { - "title": "\u0412\u0438\u0431\u0435\u0440\u0456\u0442\u044c \u0441\u043f\u043e\u0441\u0456\u0431 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u0457" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/components/somfy/translations/zh-Hant.json b/homeassistant/components/somfy/translations/zh-Hant.json deleted file mode 100644 index 8dccd6771cb..00000000000 --- a/homeassistant/components/somfy/translations/zh-Hant.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "config": { - "abort": { - "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002", - "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", - "no_url_available": "\u6c92\u6709\u53ef\u7528\u7684\u7db2\u5740\u3002\u95dc\u65bc\u6b64\u932f\u8aa4\u66f4\u8a73\u7d30\u8a0a\u606f\uff0c[\u9ede\u9078\u5354\u52a9\u7ae0\u7bc0]({docs_url})", - "single_instance_allowed": "\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3001\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u88dd\u7f6e\u3002" - }, - "create_entry": { - "default": "\u5df2\u6210\u529f\u8a8d\u8b49" - }, - "step": { - "pick_implementation": { - "title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f" - } - } - } -} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a50fc85a9f0..6b26b2a99b7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -327,7 +327,6 @@ FLOWS = { "solarlog", "solax", "soma", - "somfy", "somfy_mylink", "sonarr", "songpal", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 8d4f1148582..21631292f13 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -233,10 +233,6 @@ ZEROCONF = { { "domain": "overkiz", "name": "gateway*" - }, - { - "domain": "somfy", - "name": "gateway*" } ], "_leap._tcp.local.": [ diff --git a/requirements_all.txt b/requirements_all.txt index 03359ed2a0e..d38fc15b273 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1648,9 +1648,6 @@ pymelcloud==2.5.6 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 -# homeassistant.components.somfy -pymfy==0.11.0 - # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2d8f44b9da9..2049ae73e25 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1115,9 +1115,6 @@ pymelcloud==2.5.6 # homeassistant.components.meteoclimatic pymeteoclimatic==0.0.6 -# homeassistant.components.somfy -pymfy==0.11.0 - # homeassistant.components.mochad pymochad==0.2.0 diff --git a/tests/components/somfy/__init__.py b/tests/components/somfy/__init__.py deleted file mode 100644 index 05f5cbcf4f0..00000000000 --- a/tests/components/somfy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Somfy component.""" diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py deleted file mode 100644 index 752959802da..00000000000 --- a/tests/components/somfy/test_config_flow.py +++ /dev/null @@ -1,127 +0,0 @@ -"""Tests for the Somfy config flow.""" -import asyncio -from http import HTTPStatus -from unittest.mock import patch - -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.somfy import DOMAIN -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET -from homeassistant.helpers import config_entry_oauth2_flow - -from tests.common import MockConfigEntry - -CLIENT_ID_VALUE = "1234" -CLIENT_SECRET_VALUE = "5678" - - -async def test_abort_if_no_configuration(hass): - """Check flow abort when no configuration.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" - - -async def test_abort_if_existing_entry(hass): - """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" - - -async def test_full_flow( - hass, hass_client_no_auth, aioclient_mock, current_request_with_host -): - """Check full flow.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_CLIENT_ID: CLIENT_ID_VALUE, - CONF_CLIENT_SECRET: CLIENT_SECRET_VALUE, - } - }, - ) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - state = config_entry_oauth2_flow._encode_jwt( - hass, - { - "flow_id": result["flow_id"], - "redirect_uri": "https://example.com/auth/external/callback", - }, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP - assert result["url"] == ( - "https://accounts.somfy.com/oauth/oauth/v2/auth" - f"?response_type=code&client_id={CLIENT_ID_VALUE}" - "&redirect_uri=https://example.com/auth/external/callback" - f"&state={state}" - ) - - client = await hass_client_no_auth() - resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK - assert resp.headers["content-type"] == "text/html; charset=utf-8" - - aioclient_mock.post( - "https://accounts.somfy.com/oauth/oauth/v2/token", - json={ - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - }, - ) - - with patch( - "homeassistant.components.somfy.async_setup_entry", return_value=True - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["data"]["auth_implementation"] == DOMAIN - - result["data"]["token"].pop("expires_at") - assert result["data"]["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_abort_if_authorization_timeout(hass, current_request_with_host): - """Check Somfy authorization timeout.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: { - CONF_CLIENT_ID: CLIENT_ID_VALUE, - CONF_CLIENT_SECRET: CLIENT_SECRET_VALUE, - } - }, - ) - - with patch( - "homeassistant.components.somfy.config_entry_oauth2_flow." - "LocalOAuth2Implementation.async_generate_authorize_url", - side_effect=asyncio.TimeoutError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "authorize_url_timeout" From 8007effd4fe6830997f48fe5d941eddae18d4a57 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 15 Jun 2022 13:32:39 +0200 Subject: [PATCH 433/947] Update pyupgrade to v2.34.0 (#73530) --- .pre-commit-config.yaml | 2 +- requirements_test_pre_commit.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1104ecf07e9..18b34a222aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/asottile/pyupgrade - rev: v2.32.1 + rev: v2.34.0 hooks: - id: pyupgrade args: [--py39-plus] diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 047dcbf90ad..0d204771e40 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -12,5 +12,5 @@ mccabe==0.6.1 pycodestyle==2.8.0 pydocstyle==6.1.1 pyflakes==2.4.0 -pyupgrade==2.32.1 +pyupgrade==2.34.0 yamllint==1.26.3 From 8c0ae545c915e04c1a8e3f478970af0f89b87625 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Jun 2022 14:39:56 +0200 Subject: [PATCH 434/947] Migrate knx NumberEntity to native_value (#73536) --- homeassistant/components/knx/number.py | 27 +++++++++--------- tests/components/knx/test_number.py | 38 +++++++++++++++----------- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/knx/number.py b/homeassistant/components/knx/number.py index 9f12aa4ce24..fbf4db3f5b2 100644 --- a/homeassistant/components/knx/number.py +++ b/homeassistant/components/knx/number.py @@ -7,7 +7,7 @@ from xknx import XKNX from xknx.devices import NumericValue from homeassistant import config_entries -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import RestoreNumber from homeassistant.const import ( CONF_ENTITY_CATEGORY, CONF_MODE, @@ -19,7 +19,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType from .const import ( @@ -57,7 +56,7 @@ def _create_numeric_value(xknx: XKNX, config: ConfigType) -> NumericValue: ) -class KNXNumber(KnxEntity, NumberEntity, RestoreEntity): +class KNXNumber(KnxEntity, RestoreNumber): """Representation of a KNX number.""" _device: NumericValue @@ -65,39 +64,41 @@ class KNXNumber(KnxEntity, NumberEntity, RestoreEntity): def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize a KNX number.""" super().__init__(_create_numeric_value(xknx, config)) - self._attr_max_value = config.get( + self._attr_native_max_value = config.get( NumberSchema.CONF_MAX, self._device.sensor_value.dpt_class.value_max, ) - self._attr_min_value = config.get( + self._attr_native_min_value = config.get( NumberSchema.CONF_MIN, self._device.sensor_value.dpt_class.value_min, ) self._attr_mode = config[CONF_MODE] - self._attr_step = config.get( + self._attr_native_step = config.get( NumberSchema.CONF_STEP, self._device.sensor_value.dpt_class.resolution, ) self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) self._attr_unique_id = str(self._device.sensor_value.group_address) - self._attr_unit_of_measurement = self._device.unit_of_measurement() - self._device.sensor_value.value = max(0, self._attr_min_value) + self._attr_native_unit_of_measurement = self._device.unit_of_measurement() + self._device.sensor_value.value = max(0, self._attr_native_min_value) async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() - if not self._device.sensor_value.readable and ( - last_state := await self.async_get_last_state() + if ( + not self._device.sensor_value.readable + and (last_state := await self.async_get_last_state()) + and (last_number_data := await self.async_get_last_number_data()) ): if last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE): - self._device.sensor_value.value = float(last_state.state) + self._device.sensor_value.value = last_number_data.native_value @property - def value(self) -> float: + def native_value(self) -> float: """Return the entity value to represent the entity state.""" # self._device.sensor_value.value is set in __init__ so it is never None return cast(float, self._device.resolve_state()) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self._device.set(value) diff --git a/tests/components/knx/test_number.py b/tests/components/knx/test_number.py index 668b046df74..837d7624dae 100644 --- a/tests/components/knx/test_number.py +++ b/tests/components/knx/test_number.py @@ -1,6 +1,4 @@ """Test KNX number.""" -from unittest.mock import patch - import pytest from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS @@ -10,6 +8,8 @@ from homeassistant.core import HomeAssistant, State from .conftest import KNXTestKit +from tests.common import mock_restore_cache_with_extra_data + async def test_number_set_value(hass: HomeAssistant, knx: KNXTestKit): """Test KNX number with passive_address and respond_to_read restoring state.""" @@ -64,22 +64,28 @@ async def test_number_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit): """Test KNX number with passive_address and respond_to_read restoring state.""" test_address = "1/1/1" test_passive_address = "3/3/3" - fake_state = State("number.test", "160") - with patch( - "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", - return_value=fake_state, - ): - await knx.setup_integration( - { - NumberSchema.PLATFORM: { - CONF_NAME: "test", - KNX_ADDRESS: [test_address, test_passive_address], - CONF_RESPOND_TO_READ: True, - CONF_TYPE: "illuminance", - } + RESTORE_DATA = { + "native_max_value": None, # Ignored by KNX number + "native_min_value": None, # Ignored by KNX number + "native_step": None, # Ignored by KNX number + "native_unit_of_measurement": None, # Ignored by KNX number + "native_value": 160.0, + } + + mock_restore_cache_with_extra_data( + hass, ((State("number.test", "abc"), RESTORE_DATA),) + ) + await knx.setup_integration( + { + NumberSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: [test_address, test_passive_address], + CONF_RESPOND_TO_READ: True, + CONF_TYPE: "illuminance", } - ) + } + ) # restored state - doesn't send telegram state = hass.states.get("number.test") assert state.state == "160.0" From f8f1bfde2129fb96250fe9d718f6c7a413fce07a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 15 Jun 2022 15:23:36 +0200 Subject: [PATCH 435/947] Add lock typing in components (#73539) * Add lock typing in components * Revert freedompro amends --- homeassistant/components/august/lock.py | 5 +++-- homeassistant/components/demo/lock.py | 15 ++++++++------- homeassistant/components/fibaro/lock.py | 8 +++++--- homeassistant/components/homematic/lock.py | 10 ++++++---- homeassistant/components/keba/lock.py | 8 +++++--- homeassistant/components/kiwi/lock.py | 5 +++-- homeassistant/components/mazda/lock.py | 10 +++++++--- homeassistant/components/mqtt/lock.py | 9 +++++---- homeassistant/components/nuki/lock.py | 19 ++++++++++--------- homeassistant/components/smartthings/lock.py | 7 ++++--- homeassistant/components/starline/lock.py | 6 ++++-- homeassistant/components/subaru/lock.py | 5 +++-- homeassistant/components/template/lock.py | 14 ++++++++------ homeassistant/components/volvooncall/lock.py | 6 ++++-- homeassistant/components/zha/lock.py | 5 +++-- 15 files changed, 78 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index c993cf03b89..9269dc52c6a 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,5 +1,6 @@ """Support for August lock.""" import logging +from typing import Any from aiohttp import ClientResponseError from yalexs.activity import SOURCE_PUBNUB, ActivityType @@ -44,14 +45,14 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): self._attr_unique_id = f"{self._device_id:s}_lock" self._update_from_data() - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" if self._data.activity_stream.pubnub.connected: await self._data.async_lock_async(self._device_id, self._hyper_bridge) return await self._call_lock_operation(self._data.async_lock) - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" if self._data.activity_stream.pubnub.connected: await self._data.async_unlock_async(self._device_id, self._hyper_bridge) diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index 86188e8b935..c59dfad2fab 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from typing import Any from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry @@ -66,26 +67,26 @@ class DemoLock(LockEntity): self._jam_on_operation = jam_on_operation @property - def is_locking(self): + def is_locking(self) -> bool: """Return true if lock is locking.""" return self._state == STATE_LOCKING @property - def is_unlocking(self): + def is_unlocking(self) -> bool: """Return true if lock is unlocking.""" return self._state == STATE_UNLOCKING @property - def is_jammed(self): + def is_jammed(self) -> bool: """Return true if lock is jammed.""" return self._state == STATE_JAMMED @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state == STATE_LOCKED - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" self._state = STATE_LOCKING self.async_write_ha_state() @@ -96,7 +97,7 @@ class DemoLock(LockEntity): self._state = STATE_LOCKED self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self._state = STATE_UNLOCKING self.async_write_ha_state() @@ -104,7 +105,7 @@ class DemoLock(LockEntity): self._state = STATE_UNLOCKED self.async_write_ha_state() - async def async_open(self, **kwargs): + async def async_open(self, **kwargs: Any) -> None: """Open the door latch.""" self._state = STATE_UNLOCKED self.async_write_ha_state() diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index aed7017ba61..ac4ce658b65 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -1,6 +1,8 @@ """Support for Fibaro locks.""" from __future__ import annotations +from typing import Any + from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -37,18 +39,18 @@ class FibaroLock(FibaroDevice, LockEntity): super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the device.""" self.action("secure") self._state = True - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self.action("unsecure") self._state = False @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if device is locked.""" return self._state diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py index 32ee4698736..abca46ddf58 100644 --- a/homeassistant/components/homematic/lock.py +++ b/homeassistant/components/homematic/lock.py @@ -1,6 +1,8 @@ """Support for Homematic locks.""" from __future__ import annotations +from typing import Any + from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,19 +35,19 @@ class HMLock(HMDevice, LockEntity): _attr_supported_features = LockEntityFeature.OPEN @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if the lock is locked.""" return not bool(self._hm_get_state()) - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the lock.""" self._hmdevice.lock() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" self._hmdevice.unlock() - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Open the door latch.""" self._hmdevice.open() diff --git a/homeassistant/components/keba/lock.py b/homeassistant/components/keba/lock.py index b7563ae9b8e..d0316b1e525 100644 --- a/homeassistant/components/keba/lock.py +++ b/homeassistant/components/keba/lock.py @@ -1,6 +1,8 @@ """Support for KEBA charging station switch.""" from __future__ import annotations +from typing import Any + from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -51,15 +53,15 @@ class KebaLock(LockEntity): return f"{self._keba.device_name} {self._name}" @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock wallbox.""" await self._keba.async_stop() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock wallbox.""" await self._keba.async_start() diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index 3884045c9bc..bfd6b430c9f 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from kiwiki import KiwiClient, KiwiException import voluptuous as vol @@ -89,7 +90,7 @@ class KiwiLock(LockEntity): return name or specifier @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state == STATE_LOCKED @@ -104,7 +105,7 @@ class KiwiLock(LockEntity): self._state = STATE_LOCKED self.async_write_ha_state() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" try: diff --git a/homeassistant/components/mazda/lock.py b/homeassistant/components/mazda/lock.py index 4b51a9eb97e..bcd409d2faf 100644 --- a/homeassistant/components/mazda/lock.py +++ b/homeassistant/components/mazda/lock.py @@ -1,4 +1,8 @@ """Platform for Mazda lock integration.""" +from __future__ import annotations + +from typing import Any + from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -36,17 +40,17 @@ class MazdaLock(MazdaEntity, LockEntity): self._attr_unique_id = self.vin @property - def is_locked(self): + def is_locked(self) -> bool | None: """Return true if lock is locked.""" return self.client.get_assumed_lock_state(self.vehicle_id) - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the vehicle doors.""" await self.client.lock_doors(self.vehicle_id) self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the vehicle doors.""" await self.client.unlock_doors(self.vehicle_id) diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 862e76635f7..0bdb66ab48b 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import functools +from typing import Any import voluptuous as vol @@ -183,7 +184,7 @@ class MqttLock(MqttEntity, LockEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state @@ -197,7 +198,7 @@ class MqttLock(MqttEntity, LockEntity): """Flag supported features.""" return LockEntityFeature.OPEN if CONF_PAYLOAD_OPEN in self._config else 0 - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device. This method is a coroutine. @@ -214,7 +215,7 @@ class MqttLock(MqttEntity, LockEntity): self._state = True self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device. This method is a coroutine. @@ -231,7 +232,7 @@ class MqttLock(MqttEntity, LockEntity): self._state = False self.async_write_ha_state() - async def async_open(self, **kwargs): + async def async_open(self, **kwargs: Any) -> None: """Open the door latch. This method is a coroutine. diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 6a39bea8cb6..765fc5f711f 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -1,5 +1,6 @@ """Nuki.io lock platform.""" from abc import ABC, abstractmethod +from typing import Any from pynuki.constants import MODE_OPENER_CONTINUOUS import voluptuous as vol @@ -92,15 +93,15 @@ class NukiDeviceEntity(NukiEntity, LockEntity, ABC): return super().available and self._nuki_device.state not in ERROR_STATES @abstractmethod - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the device.""" @abstractmethod - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" @abstractmethod - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Open the door latch.""" @@ -112,15 +113,15 @@ class NukiLockEntity(NukiDeviceEntity): """Return true if lock is locked.""" return self._nuki_device.is_locked - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the device.""" self._nuki_device.lock() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self._nuki_device.unlock() - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Open the door latch.""" self._nuki_device.unlatch() @@ -144,15 +145,15 @@ class NukiOpenerEntity(NukiDeviceEntity): or self._nuki_device.mode == MODE_OPENER_CONTINUOUS ) - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Disable ring-to-open.""" self._nuki_device.deactivate_rto() - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Enable ring-to-open.""" self._nuki_device.activate_rto() - def open(self, **kwargs): + def open(self, **kwargs: Any) -> None: """Buzz open the door.""" self._nuki_device.electric_strike_actuation() diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index be3fe949061..81866010667 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import Any from pysmartthings import Attribute, Capability @@ -50,18 +51,18 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsLock(SmartThingsEntity, LockEntity): """Define a SmartThings lock.""" - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" await self._device.lock(set_status=True) self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" await self._device.unlock(set_status=True) self.async_write_ha_state() @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._device.status.lock == ST_STATE_LOCKED diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 20e389e48b5..48f91d89809 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -1,4 +1,6 @@ """Support for StarLine lock.""" +from typing import Any + from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -68,10 +70,10 @@ class StarlineLock(StarlineEntity, LockEntity): """Return true if lock is locked.""" return self._device.car_state.get("arm") - def lock(self, **kwargs): + def lock(self, **kwargs: Any) -> None: """Lock the car.""" self._account.api.set_car_state(self._device.device_id, "arm", True) - def unlock(self, **kwargs): + def unlock(self, **kwargs: Any) -> None: """Unlock the car.""" self._account.api.set_car_state(self._device.device_id, "arm", False) diff --git a/homeassistant/components/subaru/lock.py b/homeassistant/components/subaru/lock.py index 3c619690e96..bf9d1a8793b 100644 --- a/homeassistant/components/subaru/lock.py +++ b/homeassistant/components/subaru/lock.py @@ -1,5 +1,6 @@ """Support for Subaru door locks.""" import logging +from typing import Any import voluptuous as vol @@ -68,7 +69,7 @@ class SubaruLock(LockEntity): self._attr_unique_id = f"{vin}_door_locks" self._attr_device_info = get_device_info(vehicle_info) - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Send the lock command.""" _LOGGER.debug("Locking doors for: %s", self.car_name) await async_call_remote_service( @@ -77,7 +78,7 @@ class SubaruLock(LockEntity): self.vehicle_info, ) - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Send the unlock command.""" _LOGGER.debug("Unlocking doors for: %s", self.car_name) await async_call_remote_service( diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index f76750124ed..3f83e628f71 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -1,6 +1,8 @@ """Support for locks which integrates with other components.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.lock import ( @@ -95,22 +97,22 @@ class TemplateLock(TemplateEntity, LockEntity): return self._optimistic @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._state in ("true", STATE_ON, STATE_LOCKED) @property - def is_jammed(self): + def is_jammed(self) -> bool: """Return true if lock is jammed.""" return self._state == STATE_JAMMED @property - def is_unlocking(self): + def is_unlocking(self) -> bool: """Return true if lock is unlocking.""" return self._state == STATE_UNLOCKING @property - def is_locking(self): + def is_locking(self) -> bool: """Return true if lock is locking.""" return self._state == STATE_LOCKING @@ -138,14 +140,14 @@ class TemplateLock(TemplateEntity, LockEntity): ) await super().async_added_to_hass() - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the device.""" if self._optimistic: self._state = True self.async_write_ha_state() await self.async_run_script(self._command_lock, context=self._context) - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the device.""" if self._optimistic: self._state = False diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index 23f80a4fae5..5023749e622 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -1,6 +1,8 @@ """Support for Volvo On Call locks.""" from __future__ import annotations +from typing import Any + from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -30,10 +32,10 @@ class VolvoLock(VolvoEntity, LockEntity): """Return true if lock is locked.""" return self.instrument.is_locked - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the car.""" await self.instrument.lock() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" await self.instrument.unlock() diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 1ebb10cacb6..b26d7087b75 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -1,5 +1,6 @@ """Locks on Zigbee Home Automation networks.""" import functools +from typing import Any import voluptuous as vol from zigpy.zcl.foundation import Status @@ -119,7 +120,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): """Return state attributes.""" return self.state_attributes - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" result = await self._doorlock_channel.lock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: @@ -127,7 +128,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): return self.async_write_ha_state() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" result = await self._doorlock_channel.unlock_door() if isinstance(result, Exception) or result[0] is not Status.SUCCESS: From b014d558ff6ad20cef7301e6b1c11116f3fa89ea Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 15 Jun 2022 07:15:53 -0700 Subject: [PATCH 436/947] Add application credentials platform for nest and deprecate yaml for SDM API (#73050) * Update the nest integration to be useable fully from the config flow * Support discovery in nest config flow * Remove configuration entries * Remove unused import * Remove dead code * Update homeassistant/components/nest/strings.json Co-authored-by: Martin Hjelmare * Remove commented out code * Use config flow for app auth reauthentication path * Improves for re-auth for upgrading existing project and creds * More dead code removal * Apply suggestions from code review Co-authored-by: Martin Hjelmare * Remove outdated code * Update homeassistant/components/nest/config_flow.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/nest/__init__.py | 81 ++- homeassistant/components/nest/api.py | 21 +- .../nest/application_credentials.py | 24 + homeassistant/components/nest/auth.py | 54 -- homeassistant/components/nest/config_flow.py | 341 +++++---- homeassistant/components/nest/const.py | 2 +- homeassistant/components/nest/manifest.json | 2 +- homeassistant/components/nest/strings.json | 38 +- .../components/nest/translations/en.json | 35 +- .../generated/application_credentials.py | 1 + .../helpers/config_entry_oauth2_flow.py | 9 +- tests/components/nest/common.py | 34 +- tests/components/nest/conftest.py | 32 +- tests/components/nest/test_api.py | 5 + .../nest/test_config_flow_legacy.py | 9 - tests/components/nest/test_config_flow_sdm.py | 658 +++++++++++------- tests/components/nest/test_diagnostics.py | 2 - tests/components/nest/test_events.py | 4 +- tests/components/nest/test_init_sdm.py | 21 +- 19 files changed, 856 insertions(+), 517 deletions(-) create mode 100644 homeassistant/components/nest/application_credentials.py delete mode 100644 homeassistant/components/nest/auth.py diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 0920b37e6ef..29c2d817acd 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -22,6 +22,10 @@ from google_nest_sdm.exceptions import ( import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.camera import Image, img_util from homeassistant.components.http.const import KEY_HASS_USER from homeassistant.components.http.view import HomeAssistantView @@ -54,11 +58,14 @@ from . import api, config_flow from .const import ( CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, + CONF_SUBSCRIBER_ID_IMPORTED, DATA_DEVICE_MANAGER, DATA_NEST_CONFIG, DATA_SDM, DATA_SUBSCRIBER, DOMAIN, + INSTALLED_AUTH_DOMAIN, + WEB_AUTH_DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry @@ -112,20 +119,22 @@ THUMBNAIL_SIZE_PX = 175 async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Nest components with dispatch between old/new flows.""" hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_NEST_CONFIG] = config.get(DOMAIN) + + hass.http.register_view(NestEventMediaView(hass)) + hass.http.register_view(NestEventMediaThumbnailView(hass)) if DOMAIN not in config: - return True + return True # ConfigMode.SDM_APPLICATION_CREDENTIALS + + # Note that configuration.yaml deprecation warnings are handled in the + # config entry since we don't know what type of credentials we have and + # whether or not they can be imported. + hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] config_mode = config_flow.get_config_mode(hass) if config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy(hass, config) - config_flow.register_flow_implementation_from_config(hass, config) - - hass.http.register_view(NestEventMediaView(hass)) - hass.http.register_view(NestEventMediaThumbnailView(hass)) - return True @@ -171,9 +180,13 @@ class SignalUpdateCallback: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" - if DATA_SDM not in entry.data: + config_mode = config_flow.get_config_mode(hass) + if config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) + if config_mode == config_flow.ConfigMode.SDM: + await async_import_config(hass, entry) + subscriber = await api.new_subscriber(hass, entry) if not subscriber: return False @@ -223,6 +236,52 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Attempt to import configuration.yaml settings.""" + config = hass.data[DOMAIN][DATA_NEST_CONFIG] + new_data = { + CONF_PROJECT_ID: config[CONF_PROJECT_ID], + **entry.data, + } + if CONF_SUBSCRIBER_ID not in entry.data: + if CONF_SUBSCRIBER_ID not in config: + raise ValueError("Configuration option 'subscriber_id' missing") + new_data.update( + { + CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID], + CONF_SUBSCRIBER_ID_IMPORTED: True, # Don't delete user managed subscriber + } + ) + hass.config_entries.async_update_entry(entry, data=new_data) + + if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: + # App Auth credentials have been deprecated and must be re-created + # by the user in the config flow + raise ConfigEntryAuthFailed( + "Google has deprecated App Auth credentials, and the integration " + "must be reconfigured in the UI to restore access to Nest Devices." + ) + + if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN: + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential( + config[CONF_CLIENT_ID], + config[CONF_CLIENT_SECRET], + ), + WEB_AUTH_DOMAIN, + ) + + _LOGGER.warning( + "Configuration of Nest integration in YAML is deprecated and " + "will be removed in a future release; Your existing configuration " + "(including OAuth Application Credentials) has been imported into " + "the UI automatically and can be safely removed from your " + "configuration.yaml file" + ) + + async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DATA_SDM not in entry.data: @@ -242,7 +301,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle removal of pubsub subscriptions created during config flow.""" - if DATA_SDM not in entry.data or CONF_SUBSCRIBER_ID not in entry.data: + if ( + DATA_SDM not in entry.data + or CONF_SUBSCRIBER_ID not in entry.data + or CONF_SUBSCRIBER_ID_IMPORTED in entry.data + ): return subscriber = await api.new_subscriber(hass, entry) diff --git a/homeassistant/components/nest/api.py b/homeassistant/components/nest/api.py index 830db926d9a..4d92cc30b1a 100644 --- a/homeassistant/components/nest/api.py +++ b/homeassistant/components/nest/api.py @@ -12,7 +12,6 @@ from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client, config_entry_oauth2_flow @@ -20,8 +19,6 @@ from .const import ( API_URL, CONF_PROJECT_ID, CONF_SUBSCRIBER_ID, - DATA_NEST_CONFIG, - DOMAIN, OAUTH2_TOKEN, SDM_SCOPES, ) @@ -111,21 +108,19 @@ async def new_subscriber( hass, entry ) ) - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - if not ( - subscriber_id := entry.data.get( - CONF_SUBSCRIBER_ID, config.get(CONF_SUBSCRIBER_ID) - ) + if not isinstance( + implementation, config_entry_oauth2_flow.LocalOAuth2Implementation ): - _LOGGER.error("Configuration option 'subscriber_id' required") - return None + raise ValueError(f"Unexpected auth implementation {implementation}") + if not (subscriber_id := entry.data.get(CONF_SUBSCRIBER_ID)): + raise ValueError("Configuration option 'subscriber_id' missing") auth = AsyncConfigEntryAuth( aiohttp_client.async_get_clientsession(hass), config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation), - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], + implementation.client_id, + implementation.client_secret, ) - return GoogleNestSubscriber(auth, config[CONF_PROJECT_ID], subscriber_id) + return GoogleNestSubscriber(auth, entry.data[CONF_PROJECT_ID], subscriber_id) def new_subscriber_with_token( diff --git a/homeassistant/components/nest/application_credentials.py b/homeassistant/components/nest/application_credentials.py new file mode 100644 index 00000000000..7d88bc37322 --- /dev/null +++ b/homeassistant/components/nest/application_credentials.py @@ -0,0 +1,24 @@ +"""application_credentials platform for nest.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url="", # Overridden in config flow as needs device access project id + token_url=OAUTH2_TOKEN, + ) + + +async def async_get_description_placeholders(hass: HomeAssistant) -> dict[str, str]: + """Return description placeholders for the credentials dialog.""" + return { + "oauth_consent_url": "https://console.cloud.google.com/apis/credentials/consent", + "more_info_url": "https://www.home-assistant.io/integrations/nest/", + "oauth_creds_url": "https://console.cloud.google.com/apis/credentials", + "redirect_url": "https://my.home-assistant.io/redirect/oauth", + } diff --git a/homeassistant/components/nest/auth.py b/homeassistant/components/nest/auth.py deleted file mode 100644 index 648623b64c7..00000000000 --- a/homeassistant/components/nest/auth.py +++ /dev/null @@ -1,54 +0,0 @@ -"""OAuth implementations.""" -from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow - -from .const import ( - INSTALLED_AUTH_DOMAIN, - OAUTH2_AUTHORIZE, - OAUTH2_TOKEN, - OOB_REDIRECT_URI, - WEB_AUTH_DOMAIN, -) - - -class WebAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): - """OAuth implementation using OAuth for web applications.""" - - name = "OAuth for Web" - - def __init__( - self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str - ) -> None: - """Initialize WebAuth.""" - super().__init__( - hass, - WEB_AUTH_DOMAIN, - client_id, - client_secret, - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, - ) - - -class InstalledAppAuth(config_entry_oauth2_flow.LocalOAuth2Implementation): - """OAuth implementation using OAuth for installed applications.""" - - name = "OAuth for Apps" - - def __init__( - self, hass: HomeAssistant, client_id: str, client_secret: str, project_id: str - ) -> None: - """Initialize InstalledAppAuth.""" - super().__init__( - hass, - INSTALLED_AUTH_DOMAIN, - client_id, - client_secret, - OAUTH2_AUTHORIZE.format(project_id=project_id), - OAUTH2_TOKEN, - ) - - @property - def redirect_uri(self) -> str: - """Return the redirect uri.""" - return OOB_REDIRECT_URI diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 61a61f6c8e0..bacd61447f5 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -1,27 +1,11 @@ """Config flow to configure Nest. This configuration flow supports the following: - - SDM API with Installed app flow where user enters an auth code manually - SDM API with Web OAuth flow with redirect back to Home Assistant - Legacy Nest API auth flow with where user enters an auth code manually NestFlowHandler is an implementation of AbstractOAuth2FlowHandler with -some overrides to support installed app and old APIs auth flow, reauth, -and other custom steps inserted in the middle of the flow. - -The notable config flow steps are: -- user: To dispatch between API versions -- auth: Inserted to add a hook for the installed app flow to accept a token -- async_oauth_create_entry: Overridden to handle when OAuth is complete. This - does not actually create the entry, but holds on to the OAuth token data - for later -- pubsub: Configure the pubsub subscription. Note that subscriptions created - by the config flow are deleted when removed. -- finish: Handles creating a new configuration entry or updating the existing - configuration entry for reauth. - -The SDM API config flow supports a hybrid of configuration.yaml (used as defaults) -and config flow. +some overrides to custom steps inserted in the middle of the flow. """ from __future__ import annotations @@ -43,16 +27,15 @@ from google_nest_sdm.exceptions import ( from google_nest_sdm.structure import InfoTrait, Structure import voluptuous as vol -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET +from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.typing import ConfigType from homeassistant.util import get_random_string from homeassistant.util.json import load_json -from . import api, auth +from . import api from .const import ( CONF_CLOUD_PROJECT_ID, CONF_PROJECT_ID, @@ -60,14 +43,36 @@ from .const import ( DATA_NEST_CONFIG, DATA_SDM, DOMAIN, - OOB_REDIRECT_URI, + INSTALLED_AUTH_DOMAIN, + OAUTH2_AUTHORIZE, SDM_SCOPES, ) DATA_FLOW_IMPL = "nest_flow_implementation" SUBSCRIPTION_FORMAT = "projects/{cloud_project_id}/subscriptions/home-assistant-{rnd}" SUBSCRIPTION_RAND_LENGTH = 10 + +MORE_INFO_URL = "https://www.home-assistant.io/integrations/nest/#configuration" + +# URLs for Configure Cloud Project step CLOUD_CONSOLE_URL = "https://console.cloud.google.com/home/dashboard" +SDM_API_URL = ( + "https://console.cloud.google.com/apis/library/smartdevicemanagement.googleapis.com" +) +PUBSUB_API_URL = "https://console.cloud.google.com/apis/library/pubsub.googleapis.com" + +# URLs for Configure Device Access Project step +DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" + +# URLs for App Auth deprecation and upgrade +UPGRADE_MORE_INFO_URL = ( + "https://www.home-assistant.io/integrations/nest/#deprecated-app-auth-credentials" +) +DEVICE_ACCESS_CONSOLE_EDIT_URL = ( + "https://console.nest.google.com/device-access/project/{project_id}/information" +) + + _LOGGER = logging.getLogger(__name__) @@ -76,13 +81,15 @@ class ConfigMode(Enum): SDM = 1 # SDM api with configuration.yaml LEGACY = 2 # "Works with Nest" API + SDM_APPLICATION_CREDENTIALS = 3 # Config entry only def get_config_mode(hass: HomeAssistant) -> ConfigMode: """Return the integration configuration mode.""" - if DOMAIN not in hass.data: - return ConfigMode.SDM - config = hass.data[DOMAIN][DATA_NEST_CONFIG] + if DOMAIN not in hass.data or not ( + config := hass.data[DOMAIN].get(DATA_NEST_CONFIG) + ): + return ConfigMode.SDM_APPLICATION_CREDENTIALS if CONF_PROJECT_ID in config: return ConfigMode.SDM return ConfigMode.LEGACY @@ -120,31 +127,6 @@ def register_flow_implementation( } -def register_flow_implementation_from_config( - hass: HomeAssistant, - config: ConfigType, -) -> None: - """Register auth implementations for SDM API from configuration yaml.""" - NestFlowHandler.async_register_implementation( - hass, - auth.InstalledAppAuth( - hass, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - config[DOMAIN][CONF_PROJECT_ID], - ), - ) - NestFlowHandler.async_register_implementation( - hass, - auth.WebAuth( - hass, - config[DOMAIN][CONF_CLIENT_ID], - config[DOMAIN][CONF_CLIENT_SECRET], - config[DOMAIN][CONF_PROJECT_ID], - ), - ) - - class NestAuthError(HomeAssistantError): """Base class for Nest auth errors.""" @@ -179,7 +161,7 @@ class NestFlowHandler( def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() - self._reauth = False + self._upgrade = False self._data: dict[str, Any] = {DATA_SDM: {}} # Possible name to use for config entry based on the Google Home name self._structure_config_title: str | None = None @@ -189,6 +171,21 @@ class NestFlowHandler( """Return the configuration type for this flow.""" return get_config_mode(self.hass) + def _async_reauth_entry(self) -> ConfigEntry | None: + """Return existing entry for reauth.""" + if self.source != SOURCE_REAUTH or not ( + entry_id := self.context.get("entry_id") + ): + return None + return next( + ( + entry + for entry in self._async_current_entries() + if entry.entry_id == entry_id + ), + None, + ) + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -204,11 +201,19 @@ class NestFlowHandler( "prompt": "consent", } + async def async_generate_authorize_url(self) -> str: + """Generate a url for the user to authorize based on user input.""" + config = self.hass.data.get(DOMAIN, {}).get(DATA_NEST_CONFIG, {}) + project_id = self._data.get(CONF_PROJECT_ID, config.get(CONF_PROJECT_ID, "")) + query = await super().async_generate_authorize_url() + authorize_url = OAUTH2_AUTHORIZE.format(project_id=project_id) + return f"{authorize_url}{query}" + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: """Complete OAuth setup and finish pubsub or finish.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" self._data.update(data) - if not self._configure_pubsub(): + if self.source == SOURCE_REAUTH: _LOGGER.debug("Skipping Pub/Sub configuration") return await self.async_step_finish() return await self.async_step_pubsub() @@ -221,8 +226,8 @@ class NestFlowHandler( if user_input is None: _LOGGER.error("Reauth invoked with empty config entry data") return self.async_abort(reason="missing_configuration") - self._reauth = True self._data.update(user_input) + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( @@ -232,87 +237,178 @@ class NestFlowHandler( assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" if user_input is None: return self.async_show_form(step_id="reauth_confirm") - existing_entries = self._async_current_entries() - if existing_entries: - # Pick an existing auth implementation for Reauth if present. Note - # only one ConfigEntry is allowed so its safe to pick the first. - entry = next(iter(existing_entries)) - if "auth_implementation" in entry.data: - data = {"implementation": entry.data["auth_implementation"]} - return await self.async_step_user(data) + if self._data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: + # The config entry points to an auth mechanism that no longer works and the + # user needs to take action in the google cloud console to resolve. First + # prompt to create app creds, then later ensure they've updated the device + # access console. + self._upgrade = True + implementations = await config_entry_oauth2_flow.async_get_implementations( + self.hass, self.DOMAIN + ) + if not implementations: + return await self.async_step_auth_upgrade() return await self.async_step_user() + async def async_step_auth_upgrade( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Give instructions for upgrade of deprecated app auth.""" + assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" + if user_input is None: + return self.async_show_form( + step_id="auth_upgrade", + description_placeholders={ + "more_info_url": UPGRADE_MORE_INFO_URL, + }, + ) + # Abort this flow and ask the user for application credentials. The frontend + # will restart a new config flow after the user finishes so schedule a new + # re-auth config flow for the same entry so the user may resume. + if reauth_entry := self._async_reauth_entry(): + self.hass.async_add_job(reauth_entry.async_start_reauth, self.hass) + return self.async_abort(reason="missing_credentials") + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initialized by the user.""" - if self.config_mode == ConfigMode.SDM: - # Reauth will update an existing entry - if self._async_current_entries() and not self._reauth: - return self.async_abort(reason="single_instance_allowed") + if self.config_mode == ConfigMode.LEGACY: + return await self.async_step_init(user_input) + self._data[DATA_SDM] = {} + # Reauth will update an existing entry + entries = self._async_current_entries() + if entries and self.source != SOURCE_REAUTH: + return self.async_abort(reason="single_instance_allowed") + if self.source == SOURCE_REAUTH: return await super().async_step_user(user_input) - return await self.async_step_init(user_input) + # Application Credentials setup needs information from the user + # before creating the OAuth URL + return await self.async_step_create_cloud_project() + + async def async_step_create_cloud_project( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle initial step in app credentails flow.""" + implementations = await config_entry_oauth2_flow.async_get_implementations( + self.hass, self.DOMAIN + ) + if implementations: + return await self.async_step_cloud_project() + # This informational step explains to the user how to setup the + # cloud console and other pre-requisites needed before setting up + # an application credential. This extra step also allows discovery + # to start the config flow rather than aborting. The abort step will + # redirect the user to the right panel in the UI then return with a + # valid auth implementation. + if user_input is not None: + return self.async_abort(reason="missing_credentials") + return self.async_show_form( + step_id="create_cloud_project", + description_placeholders={ + "cloud_console_url": CLOUD_CONSOLE_URL, + "sdm_api_url": SDM_API_URL, + "pubsub_api_url": PUBSUB_API_URL, + "more_info_url": MORE_INFO_URL, + }, + ) + + async def async_step_cloud_project( + self, user_input: dict | None = None + ) -> FlowResult: + """Handle cloud project in user input.""" + if user_input is not None: + self._data.update(user_input) + return await self.async_step_device_project() + return self.async_show_form( + step_id="cloud_project", + data_schema=vol.Schema( + { + vol.Required(CONF_CLOUD_PROJECT_ID): str, + } + ), + description_placeholders={ + "cloud_console_url": CLOUD_CONSOLE_URL, + "more_info_url": MORE_INFO_URL, + }, + ) + + async def async_step_device_project( + self, user_input: dict | None = None + ) -> FlowResult: + """Collect device access project from user input.""" + errors = {} + if user_input is not None: + if user_input[CONF_PROJECT_ID] == self._data[CONF_CLOUD_PROJECT_ID]: + _LOGGER.error( + "Device Access Project ID and Cloud Project ID must not be the same, see documentation" + ) + errors[CONF_PROJECT_ID] = "wrong_project_id" + else: + self._data.update(user_input) + return await super().async_step_user() + + return self.async_show_form( + step_id="device_project", + data_schema=vol.Schema( + { + vol.Required(CONF_PROJECT_ID): str, + } + ), + description_placeholders={ + "device_access_console_url": DEVICE_ACCESS_CONSOLE_URL, + "more_info_url": MORE_INFO_URL, + }, + errors=errors, + ) async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Create an entry for auth.""" - if self.flow_impl.domain == "nest.installed": - # The default behavior from the parent class is to redirect the - # user with an external step. When using installed app auth, we - # instead prompt the user to sign in and copy/paste and - # authentication code back into this form. - # Note: This is similar to the Legacy API flow below, but it is - # simpler to reuse the OAuth logic in the parent class than to - # reuse SDM code with Legacy API code. - if user_input is not None: - self.external_data = { - "code": user_input["code"], - "state": {"redirect_uri": OOB_REDIRECT_URI}, - } - return await super().async_step_creation(user_input) - - result = await super().async_step_auth() - return self.async_show_form( - step_id="auth", - description_placeholders={"url": result["url"]}, - data_schema=vol.Schema({vol.Required("code"): str}), - ) + """Verify any last pre-requisites before sending user through OAuth flow.""" + if user_input is None and self._upgrade: + # During app auth upgrade we need the user to update their device access project + # before we redirect to the authentication flow. + return await self.async_step_device_project_upgrade() return await super().async_step_auth(user_input) - def _configure_pubsub(self) -> bool: - """Return True if the config flow should configure Pub/Sub.""" - if self._reauth: - # Just refreshing tokens and preserving existing subscriber id - return False - if CONF_SUBSCRIBER_ID in self.hass.data[DOMAIN][DATA_NEST_CONFIG]: - # Hard coded configuration.yaml skips pubsub in config flow - return False - # No existing subscription configured, so create in config flow - return True + async def async_step_device_project_upgrade( + self, user_input: dict | None = None + ) -> FlowResult: + """Update the device access project.""" + if user_input is not None: + # Resume OAuth2 redirects + return await super().async_step_auth() + if not isinstance( + self.flow_impl, config_entry_oauth2_flow.LocalOAuth2Implementation + ): + raise ValueError(f"Unexpected OAuth implementation: {self.flow_impl}") + client_id = self.flow_impl.client_id + return self.async_show_form( + step_id="device_project_upgrade", + description_placeholders={ + "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( + project_id=self._data[CONF_PROJECT_ID] + ), + "more_info_url": UPGRADE_MORE_INFO_URL, + "client_id": client_id, + }, + ) async def async_step_pubsub( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure and create Pub/Sub subscriber.""" - # Populate data from the previous config entry during reauth, then - # overwrite with the user entered values. - data = {} - if self._reauth: - data.update(self._data) - if user_input: - data.update(user_input) + data = { + **self._data, + **(user_input if user_input is not None else {}), + } cloud_project_id = data.get(CONF_CLOUD_PROJECT_ID, "").strip() + config = self.hass.data.get(DOMAIN, {}).get(DATA_NEST_CONFIG, {}) + project_id = data.get(CONF_PROJECT_ID, config.get(CONF_PROJECT_ID)) - errors = {} - config = self.hass.data[DOMAIN][DATA_NEST_CONFIG] - if cloud_project_id == config[CONF_PROJECT_ID]: - _LOGGER.error( - "Wrong Project ID. Device Access Project ID used, but expected Cloud Project ID" - ) - errors[CONF_CLOUD_PROJECT_ID] = "wrong_project_id" - - if user_input is not None and not errors: + errors: dict[str, str] = {} + if cloud_project_id: # Create the subscriber id and/or verify it already exists. Note that # the existing id is used, and create call below is idempotent if not (subscriber_id := data.get(CONF_SUBSCRIBER_ID, "")): @@ -321,7 +417,7 @@ class NestFlowHandler( subscriber = api.new_subscriber_with_token( self.hass, self._data["token"]["access_token"], - config[CONF_PROJECT_ID], + project_id, subscriber_id, ) try: @@ -373,18 +469,11 @@ class NestFlowHandler( # Update existing config entry when in the reauth flow. This # integration only supports one config entry so remove any prior entries # added before the "single_instance_allowed" check was added - existing_entries = self._async_current_entries() - if existing_entries: - updated = False - for entry in existing_entries: - if updated: - await self.hass.config_entries.async_remove(entry.entry_id) - continue - updated = True - self.hass.config_entries.async_update_entry( - entry, data=self._data, unique_id=DOMAIN - ) - await self.hass.config_entries.async_reload(entry.entry_id) + if entry := self._async_reauth_entry(): + self.hass.config_entries.async_update_entry( + entry, data=self._data, unique_id=DOMAIN + ) + await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="reauth_successful") title = self.flow_impl.name if self._structure_config_title: diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py index bd951756eae..64c27c1643b 100644 --- a/homeassistant/components/nest/const.py +++ b/homeassistant/components/nest/const.py @@ -11,6 +11,7 @@ INSTALLED_AUTH_DOMAIN = f"{DOMAIN}.installed" CONF_PROJECT_ID = "project_id" CONF_SUBSCRIBER_ID = "subscriber_id" +CONF_SUBSCRIBER_ID_IMPORTED = "subscriber_id_imported" CONF_CLOUD_PROJECT_ID = "cloud_project_id" SIGNAL_NEST_UPDATE = "nest_update" @@ -25,4 +26,3 @@ SDM_SCOPES = [ "https://www.googleapis.com/auth/pubsub", ] API_URL = "https://smartdevicemanagement.googleapis.com/v1" -OOB_REDIRECT_URI = "urn:ietf:wg:oauth:2.0:oob" diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 4f768e08843..d0588d46f06 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -2,7 +2,7 @@ "domain": "nest", "name": "Nest", "config_flow": true, - "dependencies": ["ffmpeg", "http", "auth"], + "dependencies": ["ffmpeg", "http", "application_credentials"], "after_dependencies": ["media_source"], "documentation": "https://www.home-assistant.io/integrations/nest", "requirements": ["python-nest==4.2.0", "google-nest-sdm==2.0.0"], diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 1d3dfda1708..212903179b7 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -1,16 +1,38 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." + }, "config": { "step": { + "auth_upgrade": { + "title": "Nest: App Auth Deprecation", + "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices." + }, + "device_project_upgrade": { + "title": "Nest: Update Device Access Project", + "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`" + }, + "create_cloud_project": { + "title": "Nest: Create and configure Cloud Project", + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up." + }, + "cloud_project": { + "title": "Nest: Enter Cloud Project ID", + "description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).", + "data": { + "cloud_project_id": "Google Cloud Project ID" + } + }, + "device_project": { + "title": "Nest: Create a Device Access Project", + "description": "Create a Nest Device Access project which **requires a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "data": { + "project_id": "Device Access Project ID" + } + }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, - "auth": { - "title": "Link Google Account", - "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", - "data": { - "code": "[%key:common::config_flow::data::access_token%]" - } - }, "pubsub": { "title": "Configure Google Cloud", "description": "Visit the [Cloud Console]({url}) to find your Google Cloud Project ID.", @@ -43,7 +65,7 @@ "unknown": "[%key:common::config_flow::error::unknown%]", "internal_error": "Internal error validating code", "bad_project_id": "Please enter a valid Cloud Project ID (check Cloud Console)", - "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)", + "wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)", "subscriber_error": "Unknown subscriber error, see logs" }, "abort": { diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 6376807302b..90f7c244f7b 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Follow the [instructions]({more_info_url}) to configure the Cloud Console:\n\n1. Go to the [OAuth consent screen]({oauth_consent_url}) and configure\n1. Go to [Credentials]({oauth_creds_url}) and click **Create Credentials**.\n1. From the drop-down list select **OAuth client ID**.\n1. Select **Web Application** for the Application Type.\n1. Add `{redirect_url}` under *Authorized redirect URI*." + }, "config": { "abort": { "authorize_url_timeout": "Timeout generating authorize URL.", @@ -19,15 +22,41 @@ "subscriber_error": "Unknown subscriber error, see logs", "timeout": "Timeout validating code", "unknown": "Unexpected error", - "wrong_project_id": "Please enter a valid Cloud Project ID (found Device Access Project ID)" + "wrong_project_id": "Please enter a valid Cloud Project ID (was same as Device Access Project ID)" }, "step": { "auth": { "data": { "code": "Access Token" }, - "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", - "title": "Link Google Account" + "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below ([more info]({more_info_url})).", + "title": "Nest: Link Google Account" + }, + "auth_upgrade": { + "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices.", + "title": "Nest: App Auth Deprecation" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Enter the Cloud Project ID below e.g. *example-project-12345*. See the [Google Cloud Console]({cloud_console_url}) or the documentation for [more info]({more_info_url}).", + "title": "Nest: Enter Cloud Project ID" + }, + "create_cloud_project": { + "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up.", + "title": "Nest: Create and configure Cloud Project" + }, + "device_project": { + "data": { + "project_id": "Device Access Project ID" + }, + "description": "Create a Nest Device Access project which **requires a US$5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "title": "Nest: Create a Device Access Project" + }, + "device_project_upgrade": { + "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`", + "title": "Nest: Update Device Access Project" }, "init": { "data": { diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6d40b3fdef7..ba9762f58c0 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -11,6 +11,7 @@ APPLICATION_CREDENTIALS = [ "home_connect", "lyric", "neato", + "nest", "netatmo", "senz", "spotify", diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 9322d6e9dc1..0dc3415f7a9 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -233,6 +233,11 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): """Extra data that needs to be appended to the authorize url.""" return {} + async def async_generate_authorize_url(self) -> str: + """Generate a url for the user to authorize.""" + url = await self.flow_impl.async_generate_authorize_url(self.flow_id) + return str(URL(url).update_query(self.extra_authorize_data)) + async def async_step_pick_implementation( self, user_input: dict | None = None ) -> FlowResult: @@ -278,7 +283,7 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): try: async with async_timeout.timeout(10): - url = await self.flow_impl.async_generate_authorize_url(self.flow_id) + url = await self.async_generate_authorize_url() except asyncio.TimeoutError: return self.async_abort(reason="authorize_url_timeout") except NoURLAvailableError: @@ -289,8 +294,6 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta): }, ) - url = str(URL(url).update_query(self.extra_authorize_data)) - return self.async_external_step(step_id="auth", url=url) async def async_step_creation( diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 988906606ad..765a954b6de 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -1,8 +1,10 @@ """Common libraries for test setup.""" +from __future__ import annotations + from collections.abc import Awaitable, Callable import copy -from dataclasses import dataclass +from dataclasses import dataclass, field import time from typing import Any, Generator, TypeVar @@ -13,6 +15,7 @@ from google_nest_sdm.event import EventMessage from google_nest_sdm.event_media import CachePolicy from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber +from homeassistant.components.application_credentials import ClientCredential from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import SDM_SCOPES @@ -73,8 +76,10 @@ def create_config_entry(token_expiration_time=None) -> MockConfigEntry: class NestTestConfig: """Holder for integration configuration.""" - config: dict[str, Any] - config_entry_data: dict[str, Any] + config: dict[str, Any] = field(default_factory=dict) + config_entry_data: dict[str, Any] | None = None + auth_implementation: str = WEB_AUTH_DOMAIN + credential: ClientCredential | None = None # Exercises mode where all configuration is in configuration.yaml @@ -86,7 +91,7 @@ TEST_CONFIG_YAML_ONLY = NestTestConfig( }, ) TEST_CONFIGFLOW_YAML_ONLY = NestTestConfig( - config=TEST_CONFIG_YAML_ONLY.config, config_entry_data=None + config=TEST_CONFIG_YAML_ONLY.config, ) # Exercises mode where subscriber id is created in the config flow, but @@ -106,8 +111,24 @@ TEST_CONFIG_HYBRID = NestTestConfig( "subscriber_id": SUBSCRIBER_ID, }, ) -TEST_CONFIGFLOW_HYBRID = NestTestConfig( - TEST_CONFIG_HYBRID.config, config_entry_data=None +TEST_CONFIGFLOW_HYBRID = NestTestConfig(TEST_CONFIG_HYBRID.config) + +# Exercises mode where all configuration is from the config flow +TEST_CONFIG_APP_CREDS = NestTestConfig( + config_entry_data={ + "sdm": {}, + "token": create_token_entry(), + "project_id": PROJECT_ID, + "cloud_project_id": CLOUD_PROJECT_ID, + "subscriber_id": SUBSCRIBER_ID, + }, + auth_implementation="imported-cred", + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), +) +TEST_CONFIGFLOW_APP_CREDS = NestTestConfig( + config=TEST_CONFIG_APP_CREDS.config, + auth_implementation="imported-cred", + credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) TEST_CONFIG_LEGACY = NestTestConfig( @@ -126,6 +147,7 @@ TEST_CONFIG_LEGACY = NestTestConfig( }, }, }, + credential=None, ) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index fafd04c3764..bacb3924bcd 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -14,6 +14,9 @@ from google_nest_sdm.auth import AbstractAuth from google_nest_sdm.device_manager import DeviceManager import pytest +from homeassistant.components.application_credentials import ( + async_import_client_credential, +) from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID from homeassistant.core import HomeAssistant @@ -22,9 +25,8 @@ from homeassistant.setup import async_setup_component from .common import ( DEVICE_ID, SUBSCRIBER_ID, - TEST_CONFIG_HYBRID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_YAML_ONLY, - WEB_AUTH_DOMAIN, CreateDevice, FakeSubscriber, NestTestConfig, @@ -183,14 +185,14 @@ def subscriber_id() -> str: @pytest.fixture -def auth_implementation() -> str | None: +def auth_implementation(nest_test_config: NestTestConfig) -> str | None: """Fixture to let tests override the auth implementation in the config entry.""" - return WEB_AUTH_DOMAIN + return nest_test_config.auth_implementation @pytest.fixture( - params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_HYBRID], - ids=["yaml-config-only", "hybrid-config"], + params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_APP_CREDS], + ids=["yaml-config-only", "app-creds"], ) def nest_test_config(request) -> NestTestConfig: """Fixture that sets up the configuration used for the test.""" @@ -230,6 +232,20 @@ def config_entry( return MockConfigEntry(domain=DOMAIN, data=data) +@pytest.fixture(autouse=True) +async def credential(hass: HomeAssistant, nest_test_config: NestTestConfig) -> None: + """Fixture that provides the ClientCredential for the test if any.""" + if not nest_test_config.credential: + return + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + nest_test_config.credential, + nest_test_config.auth_implementation, + ) + + @pytest.fixture async def setup_base_platform( hass: HomeAssistant, @@ -240,9 +256,7 @@ async def setup_base_platform( """Fixture to setup the integration platform.""" if config_entry: config_entry.add_to_hass(hass) - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", platforms): + with patch("homeassistant.components.nest.PLATFORMS", platforms): async def _setup_func() -> bool: assert await async_setup_component(hass, DOMAIN, config) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 894fda09a8f..7d88ba1d329 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -11,6 +11,8 @@ The tests below exercise both cases during integration setup. import time from unittest.mock import patch +import pytest + from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import API_URL, OAUTH2_TOKEN, SDM_SCOPES from homeassistant.setup import async_setup_component @@ -23,6 +25,7 @@ from .common import ( FAKE_REFRESH_TOKEN, FAKE_TOKEN, PROJECT_ID, + TEST_CONFIGFLOW_YAML_ONLY, create_config_entry, ) @@ -35,6 +38,7 @@ async def async_setup_sdm(hass): await hass.async_block_till_done() +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth(hass, aioclient_mock): """Exercise authentication library creates valid credentials.""" @@ -84,6 +88,7 @@ async def test_auth(hass, aioclient_mock): assert creds.scopes == SDM_SCOPES +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) async def test_auth_expired_token(hass, aioclient_mock): """Verify behavior of an expired token.""" diff --git a/tests/components/nest/test_config_flow_legacy.py b/tests/components/nest/test_config_flow_legacy.py index 843c9b582ae..f199d2ec7dd 100644 --- a/tests/components/nest/test_config_flow_legacy.py +++ b/tests/components/nest/test_config_flow_legacy.py @@ -13,15 +13,6 @@ from tests.common import MockConfigEntry CONFIG = TEST_CONFIG_LEGACY.config -async def test_abort_if_no_implementation_registered(hass): - """Test we abort if no implementation is registered.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "missing_configuration" - - async def test_abort_if_single_instance_allowed(hass): """Test we abort if Nest is already setup.""" existing_entry = MockConfigEntry(domain=DOMAIN, data={}) diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index ff55c1f518d..f4299808bf0 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -1,5 +1,8 @@ """Test the Google Nest Device Access config flow.""" +from __future__ import annotations + +from typing import Any from unittest.mock import patch from google_nest_sdm.exceptions import ( @@ -12,23 +15,31 @@ import pytest from homeassistant import config_entries from homeassistant.components import dhcp +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .common import ( APP_AUTH_DOMAIN, CLIENT_ID, + CLIENT_SECRET, CLOUD_PROJECT_ID, FAKE_TOKEN, PROJECT_ID, SUBSCRIBER_ID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_HYBRID, TEST_CONFIG_YAML_ONLY, - TEST_CONFIGFLOW_HYBRID, + TEST_CONFIGFLOW_APP_CREDS, TEST_CONFIGFLOW_YAML_ONLY, WEB_AUTH_DOMAIN, MockConfigEntry, + NestTestConfig, ) WEB_REDIRECT_URL = "https://example.com/auth/external/callback" @@ -49,17 +60,35 @@ class OAuthFixture: self.hass_client = hass_client_no_auth self.aioclient_mock = aioclient_mock - async def async_pick_flow(self, result: dict, auth_domain: str) -> dict: - """Invoke flow to puth the auth type to use for this flow.""" - assert result["type"] == "form" - assert result["step_id"] == "pick_implementation" + async def async_app_creds_flow( + self, + result: dict, + cloud_project_id: str = CLOUD_PROJECT_ID, + project_id: str = PROJECT_ID, + ) -> None: + """Invoke multiple steps in the app credentials based flow.""" + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" - return await self.async_configure(result, {"implementation": auth_domain}) + result = await self.async_configure( + result, {"cloud_project_id": CLOUD_PROJECT_ID} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" - async def async_oauth_web_flow(self, result: dict) -> None: + result = await self.async_configure(result, {"project_id": PROJECT_ID}) + await self.async_oauth_web_flow(result) + + async def async_oauth_web_flow(self, result: dict, project_id=PROJECT_ID) -> None: """Invoke the oauth flow for Web Auth with fake responses.""" state = self.create_state(result, WEB_REDIRECT_URL) - assert result["url"] == self.authorize_url(state, WEB_REDIRECT_URL) + assert result["type"] == "external" + assert result["url"] == self.authorize_url( + state, + WEB_REDIRECT_URL, + CLIENT_ID, + project_id, + ) # Simulate user redirect back with auth code client = await self.hass_client() @@ -69,38 +98,26 @@ class OAuthFixture: await self.async_mock_refresh(result) - async def async_oauth_app_flow(self, result: dict) -> None: - """Invoke the oauth flow for Installed Auth with fake responses.""" - # Render form with a link to get an auth token - assert result["type"] == "form" - assert result["step_id"] == "auth" - assert "description_placeholders" in result - assert "url" in result["description_placeholders"] - state = self.create_state(result, APP_REDIRECT_URL) - assert result["description_placeholders"]["url"] == self.authorize_url( - state, APP_REDIRECT_URL - ) - # Simulate user entering auth token in form - await self.async_mock_refresh(result, {"code": "abcd"}) - - async def async_reauth(self, old_data: dict) -> dict: + async def async_reauth(self, config_entry: ConfigEntry) -> dict: """Initiate a reuath flow.""" - result = await self.hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=old_data - ) - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" + config_entry.async_start_reauth(self.hass) + await self.hass.async_block_till_done() # Advance through the reauth flow - flows = self.hass.config_entries.flow.async_progress() - assert len(flows) == 1 - assert flows[0]["step_id"] == "reauth_confirm" + result = self.async_progress() + assert result["step_id"] == "reauth_confirm" # Advance to the oauth flow return await self.hass.config_entries.flow.async_configure( - flows[0]["flow_id"], {} + result["flow_id"], {} ) + def async_progress(self) -> FlowResult: + """Return the current step of the config flow.""" + flows = self.hass.config_entries.flow.async_progress() + assert len(flows) == 1 + return flows[0] + def create_state(self, result: dict, redirect_url: str) -> str: """Create state object based on redirect url.""" return config_entry_oauth2_flow._encode_jwt( @@ -111,11 +128,13 @@ class OAuthFixture: }, ) - def authorize_url(self, state: str, redirect_url: str) -> str: + def authorize_url( + self, state: str, redirect_url: str, client_id: str, project_id: str + ) -> str: """Generate the expected authorization url.""" - oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=PROJECT_ID) + oauth_authorize = OAUTH2_AUTHORIZE.format(project_id=project_id) return ( - f"{oauth_authorize}?response_type=code&client_id={CLIENT_ID}" + f"{oauth_authorize}?response_type=code&client_id={client_id}" f"&redirect_uri={redirect_url}" f"&state={state}&scope=https://www.googleapis.com/auth/sdm.service" "+https://www.googleapis.com/auth/pubsub" @@ -146,13 +165,16 @@ class OAuthFixture: await self.hass.async_block_till_done() return self.get_config_entry() - async def async_configure(self, result: dict, user_input: dict) -> dict: + async def async_configure( + self, result: dict[str, Any], user_input: dict[str, Any] + ) -> dict: """Advance to the next step in the config flow.""" return await self.hass.config_entries.flow.async_configure( - result["flow_id"], user_input + result["flow_id"], + user_input, ) - async def async_pubsub_flow(self, result: dict, cloud_project_id="") -> ConfigEntry: + async def async_pubsub_flow(self, result: dict, cloud_project_id="") -> None: """Verify the pubsub creation step.""" # Render form with a link to get an auth token assert result["type"] == "form" @@ -164,7 +186,7 @@ class OAuthFixture: def get_config_entry(self) -> ConfigEntry: """Get the config entry.""" entries = self.hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 + assert len(entries) >= 1 return entries[0] @@ -174,42 +196,209 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_web_full_flow(hass, oauth, setup_platform): +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_app_credentials(hass, oauth, subscriber, setup_platform): """Check full flow.""" await setup_platform() result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await oauth.async_app_creds_flow(result) - result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) - - await oauth.async_oauth_web_flow(result) entry = await oauth.async_finish_setup(result) - assert entry.title == "OAuth for Web" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, } - # Subscriber from configuration.yaml - assert "subscriber_id" not in entry.data + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_restart(hass, oauth, subscriber, setup_platform): + """Check with auth implementation is re-initialized when aborting the flow.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + + # At this point, we should have a valid auth implementation configured. + # Simulate aborting the flow and starting over to ensure we get prompted + # again to configure everything. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" + + # Change the values to show they are reflected below + result = await oauth.async_configure( + result, {"cloud_project_id": "new-cloud-project-id"} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": "new-project-id"}) + await oauth.async_oauth_web_flow(result, "new-project-id") + + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert "projects/new-cloud-project-id/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": "new-cloud-project-id", + "project_id": "new-project-id", + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_wrong_project_id(hass, oauth, subscriber, setup_platform): + """Check the case where the wrong project ids are entered.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" + + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + # Enter the cloud project id instead of device access project id (really we just check + # they are the same value which is never correct) + result = await oauth.async_configure(result, {"project_id": CLOUD_PROJECT_ID}) + assert result["type"] == "form" + assert "errors" in result + assert "project_id" in result["errors"] + assert result["errors"]["project_id"] == "wrong_project_id" + + # Fix with a correct value and complete the rest of the flow + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) + await oauth.async_oauth_web_flow(result) + await hass.async_block_till_done() + + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_pubsub_configuration_error( + hass, + oauth, + setup_platform, + mock_subscriber, +): + """Check full flow fails with configuration error.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + + mock_subscriber.create_subscription.side_effect = ConfigurationException + result = await oauth.async_configure(result, {"code": "1234"}) + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "bad_project_id" + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_config_flow_pubsub_subscriber_error( + hass, oauth, setup_platform, mock_subscriber +): + """Check full flow with a subscriber error.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await oauth.async_app_creds_flow(result) + + mock_subscriber.create_subscription.side_effect = SubscriberException() + result = await oauth.async_configure(result, {"code": "1234"}) + + assert result["type"] == "form" + assert "errors" in result + assert "cloud_project_id" in result["errors"] + assert result["errors"]["cloud_project_id"] == "subscriber_error" + + +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +async def test_config_yaml_ignored(hass, oauth, setup_platform): + """Check full flow.""" + await setup_platform() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == "form" + assert result["step_id"] == "create_cloud_project" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" @pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_YAML_ONLY]) async def test_web_reauth(hass, oauth, setup_platform, config_entry): """Test Nest reauthentication.""" - await setup_platform() assert config_entry.data["token"].get("access_token") == FAKE_TOKEN - result = await oauth.async_reauth(config_entry.data) + orig_subscriber_id = config_entry.data.get("subscriber_id") + result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) entry = await oauth.async_finish_setup(result) @@ -223,7 +412,7 @@ async def test_web_reauth(hass, oauth, setup_platform, config_entry): "expires_in": 60, } assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN - assert "subscriber_id" not in entry.data # not updated + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated async def test_single_config_entry(hass, setup_platform): @@ -237,7 +426,9 @@ async def test_single_config_entry(hass, setup_platform): assert result["reason"] == "single_instance_allowed" -async def test_unexpected_existing_config_entries(hass, oauth, setup_platform): +async def test_unexpected_existing_config_entries( + hass, oauth, setup_platform, config_entry +): """Test Nest reauthentication with multiple existing config entries.""" # Note that this case will not happen in the future since only a single # instance is now allowed, but this may have been allowed in the past. @@ -246,23 +437,29 @@ async def test_unexpected_existing_config_entries(hass, oauth, setup_platform): await setup_platform() old_entry = MockConfigEntry( - domain=DOMAIN, data={"auth_implementation": WEB_AUTH_DOMAIN, "sdm": {}} + domain=DOMAIN, + data={ + **config_entry.data, + "extra_data": True, + }, ) old_entry.add_to_hass(hass) entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 2 + orig_subscriber_id = config_entry.data.get("subscriber_id") + # Invoke the reauth flow - result = await oauth.async_reauth(old_entry.data) + result = await oauth.async_reauth(config_entry) await oauth.async_oauth_web_flow(result) await oauth.async_finish_setup(result) - # Only a single entry now exists, and the other was cleaned up + # Only reauth entry was updated, the other entry is preserved entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 + assert len(entries) == 2 entry = entries[0] assert entry.unique_id == DOMAIN entry.data["token"].pop("expires_at") @@ -272,7 +469,14 @@ async def test_unexpected_existing_config_entries(hass, oauth, setup_platform): "type": "Bearer", "expires_in": 60, } - assert "subscriber_id" not in entry.data # not updated + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + assert not entry.data.get("extra_data") + + # Other entry was not refreshed + entry = entries[1] + entry.data["token"].pop("expires_at") + assert entry.data.get("token", {}).get("access_token") == "some-token" + assert entry.data.get("extra_data") async def test_reauth_missing_config_entry(hass, setup_platform): @@ -287,42 +491,51 @@ async def test_reauth_missing_config_entry(hass, setup_platform): assert result["reason"] == "missing_configuration" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_app_full_flow(hass, oauth, setup_platform): - """Check full flow.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - - await oauth.async_oauth_app_flow(result) - entry = await oauth.async_finish_setup(result, {"code": "1234"}) - assert entry.title == "OAuth for Apps" - assert "token" in entry.data - entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - # Subscriber from configuration.yaml - assert "subscriber_id" not in entry.data - - @pytest.mark.parametrize( - "nest_test_config,auth_implementation", [(TEST_CONFIG_YAML_ONLY, APP_AUTH_DOMAIN)] + "nest_test_config,auth_implementation", [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] ) -async def test_app_reauth(hass, oauth, setup_platform, config_entry): - """Test Nest reauthentication for Installed App Auth.""" +async def test_app_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): + """Test reauth for deprecated app auth credentails upgrade instructions.""" await setup_platform() - result = await oauth.async_reauth(config_entry.data) - await oauth.async_oauth_app_flow(result) + orig_subscriber_id = config_entry.data.get("subscriber_id") + assert config_entry.data["auth_implementation"] == APP_AUTH_DOMAIN + + result = oauth.async_progress() + assert result.get("step_id") == "reauth_confirm" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "form" + assert result.get("step_id") == "auth_upgrade" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" + await hass.async_block_till_done() + # Config flow is aborted, but new one created back in re-auth state waiting for user + # to create application credentials + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + + # Emulate user entering credentials (different from configuration.yaml creds) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + # Config flow is placed back into a reuath state + result = oauth.async_progress() + assert result.get("step_id") == "reauth_confirm" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project_upgrade" + + # Frontend sends user back through the config flow again + result = await oauth.async_configure(result, {}) + await oauth.async_oauth_web_flow(result) # Verify existing tokens are replaced entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -334,29 +547,28 @@ async def test_app_reauth(hass, oauth, setup_platform, config_entry): "type": "Bearer", "expires_in": 60, } - assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN - assert "subscriber_id" not in entry.data # not updated + assert entry.data["auth_implementation"] == DOMAIN + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated + + # Existing entry is updated + assert config_entry.data["auth_implementation"] == DOMAIN -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_subscription(hass, oauth, subscriber, setup_platform): - """Check flow that creates a pub/sub subscription.""" +@pytest.mark.parametrize( + "nest_test_config,auth_implementation", [(TEST_CONFIG_YAML_ONLY, WEB_AUTH_DOMAIN)] +) +async def test_web_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): + """Test Nest reauthentication for Installed App Auth.""" + await setup_platform() - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + orig_subscriber_id = config_entry.data.get("subscriber_id") - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + result = await oauth.async_reauth(config_entry) + await oauth.async_oauth_web_flow(result) - assert entry.title == "OAuth for Apps" - assert "token" in entry.data + # Verify existing tokens are replaced + entry = await oauth.async_finish_setup(result, {"code": "1234"}) entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN assert entry.data["token"] == { @@ -365,11 +577,11 @@ async def test_pubsub_subscription(hass, oauth, subscriber, setup_platform): "type": "Bearer", "expires_in": 60, } - assert "subscriber_id" in entry.data - assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID + assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN + assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_strip_whitespace( hass, oauth, subscriber, setup_platform ): @@ -379,16 +591,12 @@ async def test_pubsub_subscription_strip_whitespace( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": " " + CLOUD_PROJECT_ID + " "} + await oauth.async_app_creds_flow( + result, cloud_project_id=" " + CLOUD_PROJECT_ID + " " ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) - assert entry.title == "OAuth for Apps" + assert entry.title == "Import from configuration.yaml" assert "token" in entry.data entry.data["token"].pop("expires_at") assert entry.unique_id == DOMAIN @@ -402,7 +610,7 @@ async def test_pubsub_subscription_strip_whitespace( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_auth_failure( hass, oauth, setup_platform, mock_subscriber ): @@ -412,102 +620,25 @@ async def test_pubsub_subscription_auth_failure( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) mock_subscriber.create_subscription.side_effect = AuthException() - await oauth.async_pubsub_flow(result) - result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + await oauth.async_app_creds_flow(result) + result = await oauth.async_configure(result, {"code": "1234"}) assert result["type"] == "abort" assert result["reason"] == "invalid_access_token" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_subscription_failure( - hass, oauth, setup_platform, mock_subscriber -): - """Check flow that creates a pub/sub subscription.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - - mock_subscriber.create_subscription.side_effect = SubscriberException() - - result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) - - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "subscriber_error" - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_subscription_configuration_failure( - hass, oauth, setup_platform, mock_subscriber -): - """Check flow that creates a pub/sub subscription.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - - mock_subscriber.create_subscription.side_effect = ConfigurationException() - result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) - - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "bad_project_id" - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) -async def test_pubsub_with_wrong_project_id(hass, oauth, setup_platform): - """Test a possible common misconfiguration mixing up project ids.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - result = await oauth.async_configure( - result, {"cloud_project_id": PROJECT_ID} # SDM project id - ) - await hass.async_block_till_done() - - assert result["type"] == "form" - assert "errors" in result - assert "cloud_project_id" in result["errors"] - assert result["errors"]["cloud_project_id"] == "wrong_project_id" - - -@pytest.mark.parametrize( - "nest_test_config,auth_implementation", [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] -) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_pubsub_subscriber_config_entry_reauth( - hass, oauth, setup_platform, subscriber, config_entry + hass, oauth, setup_platform, subscriber, config_entry, auth_implementation ): """Test the pubsub subscriber id is preserved during reauth.""" await setup_platform() - result = await oauth.async_reauth(config_entry.data) - await oauth.async_oauth_app_flow(result) + result = await oauth.async_reauth(config_entry) + await oauth.async_oauth_web_flow(result) # Entering an updated access token refreshs the config entry. entry = await oauth.async_finish_setup(result, {"code": "1234"}) @@ -519,12 +650,12 @@ async def test_pubsub_subscriber_config_entry_reauth( "type": "Bearer", "expires_in": 60, } - assert entry.data["auth_implementation"] == APP_AUTH_DOMAIN + assert entry.data["auth_implementation"] == auth_implementation assert entry.data["subscriber_id"] == SUBSCRIBER_ID assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_from_home(hass, oauth, setup_platform, subscriber): """Test that the Google Home name is used for the config entry title.""" @@ -547,22 +678,16 @@ async def test_config_entry_title_from_home(hass, oauth, setup_platform, subscri result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + await oauth.async_app_creds_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home" assert "token" in entry.data assert "subscriber_id" in entry.data assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_multiple_homes( hass, oauth, setup_platform, subscriber ): @@ -599,18 +724,13 @@ async def test_config_entry_title_multiple_homes( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + await oauth.async_app_creds_flow(result) - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) assert entry.title == "Example Home #1, Example Home #2" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_title_failure_fallback(hass, oauth, setup_platform, mock_subscriber): """Test exception handling when determining the structure names.""" await setup_platform() @@ -618,24 +738,17 @@ async def test_title_failure_fallback(hass, oauth, setup_platform, mock_subscrib result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) + await oauth.async_app_creds_flow(result) mock_subscriber.async_get_device_manager.side_effect = AuthException() - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) - - assert entry.title == "OAuth for Apps" + entry = await oauth.async_finish_setup(result, {"code": "1234"}) + assert entry.title == "Import from configuration.yaml" assert "token" in entry.data assert "subscriber_id" in entry.data assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_HYBRID]) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_structure_missing_trait(hass, oauth, setup_platform, subscriber): """Test handling the case where a structure has no name set.""" @@ -655,34 +768,33 @@ async def test_structure_missing_trait(hass, oauth, setup_platform, subscriber): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - result = await oauth.async_pick_flow(result, APP_AUTH_DOMAIN) - await oauth.async_oauth_app_flow(result) - - result = await oauth.async_configure(result, {"code": "1234"}) - await oauth.async_pubsub_flow(result) - entry = await oauth.async_finish_setup( - result, {"cloud_project_id": CLOUD_PROJECT_ID} - ) + await oauth.async_app_creds_flow(result) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) # Fallback to default name - assert entry.title == "OAuth for Apps" + assert entry.title == "Import from configuration.yaml" -async def test_dhcp_discovery_without_config(hass, oauth): - """Exercise discovery dhcp with no config present (can't run).""" +@pytest.mark.parametrize("nest_test_config", [NestTestConfig()]) +async def test_dhcp_discovery(hass, oauth, subscriber): + """Exercise discovery dhcp starts the config flow and kicks user to frontend creds flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=FAKE_DHCP_DATA, ) await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == "missing_configuration" + assert result["type"] == "form" + assert result["step_id"] == "create_cloud_project" + + result = await oauth.async_configure(result, {}) + assert result.get("type") == "abort" + assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_dhcp_discovery(hass, oauth, setup_platform): - """Discover via dhcp when config is present.""" +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) +async def test_dhcp_discovery_with_creds(hass, oauth, subscriber, setup_platform): + """Exercise discovery dhcp with no config present (can't run).""" await setup_platform() result = await hass.config_entries.flow.async_init( @@ -691,19 +803,33 @@ async def test_dhcp_discovery(hass, oauth, setup_platform): data=FAKE_DHCP_DATA, ) await hass.async_block_till_done() + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" - # DHCP discovery invokes the config flow - result = await oauth.async_pick_flow(result, WEB_AUTH_DOMAIN) + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) await oauth.async_oauth_web_flow(result) - entry = await oauth.async_finish_setup(result) - assert entry.title == "OAuth for Web" - - # Discovery does not run once configured - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=FAKE_DHCP_DATA, - ) + entry = await oauth.async_finish_setup(result, {"code": "1234"}) await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == "already_configured" + + data = dict(entry.data) + assert "token" in data + data["token"].pop("expires_in") + data["token"].pop("expires_at") + assert "subscriber_id" in data + assert f"projects/{CLOUD_PROJECT_ID}/subscriptions" in data["subscriber_id"] + data.pop("subscriber_id") + assert data == { + "sdm": {}, + "auth_implementation": "imported-cred", + "cloud_project_id": CLOUD_PROJECT_ID, + "project_id": PROJECT_ID, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + }, + } diff --git a/tests/components/nest/test_diagnostics.py b/tests/components/nest/test_diagnostics.py index 8e28222e356..85b63b23301 100644 --- a/tests/components/nest/test_diagnostics.py +++ b/tests/components/nest/test_diagnostics.py @@ -127,8 +127,6 @@ async def test_setup_susbcriber_failure( ): """Test configuration error.""" with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch( "homeassistant.components.nest.api.GoogleNestSubscriber.start_async", side_effect=SubscriberException(), ): diff --git a/tests/components/nest/test_events.py b/tests/components/nest/test_events.py index 28550bd57b6..83845586764 100644 --- a/tests/components/nest/test_events.py +++ b/tests/components/nest/test_events.py @@ -443,9 +443,7 @@ async def test_structure_update_event(hass, subscriber, setup_platform): }, auth=None, ) - with patch( - "homeassistant.helpers.config_entry_oauth2_flow.async_get_config_entry_implementation" - ), patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( + with patch("homeassistant.components.nest.PLATFORMS", [PLATFORM]), patch( "homeassistant.components.nest.api.GoogleNestSubscriber", return_value=subscriber, ): diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index 381252c6f75..1b473ccd62f 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -25,10 +25,13 @@ from homeassistant.components.nest import DOMAIN from homeassistant.config_entries import ConfigEntryState from .common import ( + PROJECT_ID, + SUBSCRIBER_ID, + TEST_CONFIG_APP_CREDS, TEST_CONFIG_HYBRID, TEST_CONFIG_YAML_ONLY, + TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, - NestTestConfig, YieldFixture, ) @@ -170,7 +173,8 @@ async def test_subscriber_configuration_failure( @pytest.mark.parametrize( - "nest_test_config", [NestTestConfig(config={}, config_entry_data=None)] + "nest_test_config", + [TEST_CONFIGFLOW_APP_CREDS], ) async def test_empty_config(hass, error_caplog, config, setup_platform): """Test setup is a no-op with not config.""" @@ -205,8 +209,12 @@ async def test_unload_entry(hass, setup_platform): TEST_CONFIG_HYBRID, True, ), # Integration created subscriber, garbage collect on remove + ( + TEST_CONFIG_APP_CREDS, + True, + ), # Integration created subscriber, garbage collect on remove ], - ids=["yaml-config-only", "hybrid-config"], + ids=["yaml-config-only", "hybrid-config", "config-entry"], ) async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_called): """Test successful unload of a ConfigEntry.""" @@ -220,6 +228,9 @@ async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_ assert len(entries) == 1 entry = entries[0] assert entry.state is ConfigEntryState.LOADED + # Assert entry was imported if from configuration.yaml + assert entry.data.get("subscriber_id") == SUBSCRIBER_ID + assert entry.data.get("project_id") == PROJECT_ID with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.subscriber_id" @@ -234,7 +245,9 @@ async def test_remove_entry(hass, nest_test_config, setup_base_platform, delete_ @pytest.mark.parametrize( - "nest_test_config", [TEST_CONFIG_HYBRID], ids=["hyrbid-config"] + "nest_test_config", + [TEST_CONFIG_HYBRID, TEST_CONFIG_APP_CREDS], + ids=["hyrbid-config", "app-creds"], ) async def test_remove_entry_delete_subscriber_failure( hass, nest_test_config, setup_base_platform From 7a82794ad727e6782638c51e61fa40f26229c5b8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Jun 2022 17:06:44 +0200 Subject: [PATCH 437/947] Migrate template NumberEntity to native_value (#73537) --- homeassistant/components/number/__init__.py | 4 +-- homeassistant/components/template/number.py | 38 ++++++++++----------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 5a0bf9947f3..13ba47e87dc 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -252,8 +252,6 @@ class NumberEntity(Entity): @property def native_step(self) -> float | None: """Return the increment/decrement step.""" - if hasattr(self, "_attr_native_step"): - return self._attr_native_step if ( hasattr(self, "entity_description") and self.entity_description.native_step is not None @@ -273,6 +271,8 @@ class NumberEntity(Entity): ): self._report_deprecated_number_entity() return self.entity_description.step + if hasattr(self, "_attr_native_step"): + return self._attr_native_step if (native_step := self.native_step) is not None: return native_step step = DEFAULT_STEP diff --git a/homeassistant/components/template/number.py b/homeassistant/components/template/number.py index bf3dcbb120b..d41bdee597b 100644 --- a/homeassistant/components/template/number.py +++ b/homeassistant/components/template/number.py @@ -119,45 +119,45 @@ class TemplateNumber(TemplateEntity, NumberEntity): self._min_value_template = config[ATTR_MIN] self._max_value_template = config[ATTR_MAX] self._attr_assumed_state = self._optimistic = config[CONF_OPTIMISTIC] - self._attr_value = None - self._attr_step = None - self._attr_min_value = None - self._attr_max_value = None + self._attr_native_value = None + self._attr_native_step = None + self._attr_native_min_value = None + self._attr_native_max_value = None async def async_added_to_hass(self) -> None: """Register callbacks.""" self.add_template_attribute( - "_attr_value", + "_attr_native_value", self._value_template, validator=vol.Coerce(float), none_on_template_error=True, ) self.add_template_attribute( - "_attr_step", + "_attr_native_step", self._step_template, validator=vol.Coerce(float), none_on_template_error=True, ) if self._min_value_template is not None: self.add_template_attribute( - "_attr_min_value", + "_attr_native_min_value", self._min_value_template, validator=vol.Coerce(float), none_on_template_error=True, ) if self._max_value_template is not None: self.add_template_attribute( - "_attr_max_value", + "_attr_native_max_value", self._max_value_template, validator=vol.Coerce(float), none_on_template_error=True, ) await super().async_added_to_hass() - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value of the number.""" if self._optimistic: - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() await self.async_run_script( self._command_set_value, @@ -193,35 +193,35 @@ class TriggerNumberEntity(TriggerEntity, NumberEntity): ) @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the currently selected option.""" return vol.Any(vol.Coerce(float), None)(self._rendered.get(CONF_STATE)) @property - def min_value(self) -> int: + def native_min_value(self) -> int: """Return the minimum value.""" return vol.Any(vol.Coerce(float), None)( - self._rendered.get(ATTR_MIN, super().min_value) + self._rendered.get(ATTR_MIN, super().native_min_value) ) @property - def max_value(self) -> int: + def native_max_value(self) -> int: """Return the maximum value.""" return vol.Any(vol.Coerce(float), None)( - self._rendered.get(ATTR_MAX, super().max_value) + self._rendered.get(ATTR_MAX, super().native_max_value) ) @property - def step(self) -> int: + def native_step(self) -> int: """Return the increment/decrement step.""" return vol.Any(vol.Coerce(float), None)( - self._rendered.get(ATTR_STEP, super().step) + self._rendered.get(ATTR_STEP, super().native_step) ) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set value of the number.""" if self._config[CONF_OPTIMISTIC]: - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() await self._command_set_value.async_run( {ATTR_VALUE: value}, context=self._context From 0ace5af9143e9e9a279419ec8a469123e49eca45 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 15 Jun 2022 23:07:18 +0200 Subject: [PATCH 438/947] Correct migration of unifiprotect number (#73553) --- homeassistant/components/unifiprotect/number.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 3ac5b673ea5..1cfb4477673 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -108,7 +108,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Auto-shutoff Duration", icon="mdi:camera-timer", entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, ufp_min=15, ufp_max=900, ufp_step=15, @@ -139,7 +139,7 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Auto-lock Timeout", icon="mdi:walk", entity_category=EntityCategory.CONFIG, - unit_of_measurement=TIME_SECONDS, + native_unit_of_measurement=TIME_SECONDS, ufp_min=0, ufp_max=3600, ufp_step=15, From b4359c7721e0df74263ffa591eeb64cc50a83f45 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 16 Jun 2022 00:22:45 +0000 Subject: [PATCH 439/947] [ci skip] Translation update --- .../binary_sensor/translations/ca.json | 2 +- .../eight_sleep/translations/id.json | 19 ++++++++++++ .../components/google/translations/id.json | 3 ++ .../components/nest/translations/ca.json | 9 ++++++ .../components/nest/translations/de.json | 29 +++++++++++++++++ .../components/nest/translations/en.json | 6 ++-- .../components/nest/translations/pt-BR.json | 31 ++++++++++++++++++- .../components/nest/translations/zh-Hant.json | 31 ++++++++++++++++++- 8 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/eight_sleep/translations/id.json diff --git a/homeassistant/components/binary_sensor/translations/ca.json b/homeassistant/components/binary_sensor/translations/ca.json index 10d705e797b..a65633140a0 100644 --- a/homeassistant/components/binary_sensor/translations/ca.json +++ b/homeassistant/components/binary_sensor/translations/ca.json @@ -81,7 +81,7 @@ "not_moist": "{entity_name} es torna sec", "not_moving": "{entity_name} ha parat de moure's", "not_occupied": "{entity_name} es desocupa", - "not_opened": "{entity_name} es tanca", + "not_opened": "{entity_name} tancat/tancada", "not_plugged_in": "{entity_name} desendollat", "not_powered": "{entity_name} no est\u00e0 alimentat", "not_present": "{entity_name} no est\u00e0 present", diff --git a/homeassistant/components/eight_sleep/translations/id.json b/homeassistant/components/eight_sleep/translations/id.json new file mode 100644 index 00000000000..4bc4e32e7ee --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/id.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi", + "cannot_connect": "Tidak dapat terhubung ke cloud Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "Tidak dapat terhubung ke cloud Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Kata Sandi", + "username": "Nama Pengguna" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/id.json b/homeassistant/components/google/translations/id.json index 25c3e9fa1e6..085d49e92a3 100644 --- a/homeassistant/components/google/translations/id.json +++ b/homeassistant/components/google/translations/id.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Ikuti [petunjuk]({more_info_url}) untuk [Layar persetujuan OAuth]({oauth_consent_url}) untuk memberi Home Assistant akses ke Google Kalender Anda. Anda juga perlu membuat Kredensial Aplikasi yang ditautkan ke Kalender Anda:\n1. Buka [Kredensial]({oauth_creds_url}) dan klik **Buat Kredensial**.\n1. Dari daftar pilihan pilih **ID klien OAuth **.\n1. Pilih **TV dan Perangkat Input Terbatas** untuk Jenis Aplikasi.\n\n" + }, "config": { "abort": { "already_configured": "Akun sudah dikonfigurasi", diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index 888b7ed5b44..c8d6238e742 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -29,6 +29,15 @@ "description": "Per enlla\u00e7ar un compte de Google, [autoritza el compte]({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copia i enganxa a continuaci\u00f3 el codi 'token' d'autenticaci\u00f3 proporcionat.", "title": "Vinculaci\u00f3 amb compte de Google" }, + "cloud_project": { + "data": { + "cloud_project_id": "ID de projecte Google Cloud" + }, + "title": "Nest: introdueix l'identificador del projecte Cloud" + }, + "create_cloud_project": { + "title": "Nest: crea i configura el projecte Cloud" + }, "init": { "data": { "flow_impl": "Prove\u00efdor" diff --git a/homeassistant/components/nest/translations/de.json b/homeassistant/components/nest/translations/de.json index 47d0505dca5..bee1bb547f2 100644 --- a/homeassistant/components/nest/translations/de.json +++ b/homeassistant/components/nest/translations/de.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Folgen Sie den [Anweisungen]({more_info_url}), um die Cloud-Konsole zu konfigurieren:\n\n1. Gehen Sie zum [OAuth-Zustimmungsbildschirm]({oauth_consent_url}) und konfigurieren Sie\n1. Gehen Sie zu [Credentials]({oauth_creds_url}) und klicken Sie auf **Create Credentials**.\n1. W\u00e4hlen Sie in der Dropdown-Liste **OAuth-Client-ID**.\n1. W\u00e4hlen Sie **Webanwendung** f\u00fcr den Anwendungstyp.\n1. F\u00fcgen Sie `{redirect_url}` unter *Authorized redirect URI* hinzu." + }, "config": { "abort": { "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", @@ -29,6 +32,32 @@ "description": "Um dein Google-Konto zu verkn\u00fcpfen, w\u00e4hle [Konto autorisieren]({url}).\n\nKopiere nach der Autorisierung den unten angegebenen Authentifizierungstoken-Code.", "title": "Google-Konto verkn\u00fcpfen" }, + "auth_upgrade": { + "description": "App Auth wurde von Google abgeschafft, um die Sicherheit zu verbessern, und Sie m\u00fcssen Ma\u00dfnahmen ergreifen, indem Sie neue Anmeldedaten f\u00fcr die Anwendung erstellen.\n\n\u00d6ffnen Sie die [Dokumentation]({more_info_url}) und folgen Sie den n\u00e4chsten Schritten, die Sie durchf\u00fchren m\u00fcssen, um den Zugriff auf Ihre Nest-Ger\u00e4te wiederherzustellen.", + "title": "Nest: Einstellung der App-Authentifizierung" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Projekt-ID" + }, + "description": "Geben Sie unten die Cloud-Projekt-ID ein, z. B. *example-project-12345*. Siehe die [Google Cloud Console]({cloud_console_url}) oder die Dokumentation f\u00fcr [weitere Informationen]({more_info_url}).", + "title": "Nest: Cloud-Projekt-ID eingeben" + }, + "create_cloud_project": { + "description": "Die Nest-Integration erm\u00f6glicht es Ihnen, Ihre Nest-Thermostate, -Kameras und -T\u00fcrklingeln \u00fcber die Smart Device Management API zu integrieren. Die SDM API **erfordert eine einmalige Einrichtungsgeb\u00fchr von US $5**. Siehe Dokumentation f\u00fcr [weitere Informationen]({more_info_url}).\n\n1. Rufen Sie die [Google Cloud Console]({cloud_console_url}) auf.\n1. Wenn dies Ihr erstes Projekt ist, klicken Sie auf **Projekt erstellen** und dann auf **Neues Projekt**.\n1. Geben Sie Ihrem Cloud-Projekt einen Namen und klicken Sie dann auf **Erstellen**.\n1. Speichern Sie die Cloud Project ID, z. B. *example-project-12345*, da Sie diese sp\u00e4ter ben\u00f6tigen.\n1. Gehen Sie zur API-Bibliothek f\u00fcr [Smart Device Management API]({sdm_api_url}) und klicken Sie auf **Aktivieren**.\n1. Wechseln Sie zur API-Bibliothek f\u00fcr [Cloud Pub/Sub API]({pubsub_api_url}) und klicken Sie auf **Aktivieren**.\n\nFahren Sie fort, wenn Ihr Cloud-Projekt eingerichtet ist.", + "title": "Nest: Cloud-Projekt erstellen und konfigurieren" + }, + "device_project": { + "data": { + "project_id": "Ger\u00e4tezugriffsprojekt ID" + }, + "description": "Erstellen Sie ein Nest Ger\u00e4tezugriffsprojekt, f\u00fcr dessen Einrichtung **eine Geb\u00fchr von 5 US-Dollar** anf\u00e4llt.\n1. Gehen Sie zur [Device Access Console]({device_access_console_url}) und durchlaufen Sie den Zahlungsablauf.\n1. Klicken Sie auf **Projekt erstellen**.\n1. Geben Sie Ihrem Device Access-Projekt einen Namen und klicken Sie auf **Weiter**.\n1. Geben Sie Ihre OAuth-Client-ID ein\n1. Aktivieren Sie Ereignisse, indem Sie auf **Aktivieren** und **Projekt erstellen** klicken.\n\nGeben Sie unten Ihre Ger\u00e4tezugriffsprojekt ID ein ([more info]({more_info_url})).\n", + "title": "Nest: Erstelle ein Ger\u00e4tezugriffsprojekt" + }, + "device_project_upgrade": { + "description": "Aktualisieren Sie das Nest Ger\u00e4tezugriffsprojekt mit Ihrer neuen OAuth Client ID ([more info]({more_info_url}))\n1. Gehen Sie zur [Ger\u00e4tezugriffskonsole]({device_access_console_url}).\n1. Klicken Sie auf das Papierkorbsymbol neben *OAuth Client ID*.\n1. Klicken Sie auf das \u00dcberlaufmen\u00fc und *Client ID hinzuf\u00fcgen*.\n1. Geben Sie Ihre neue OAuth-Client-ID ein und klicken Sie auf **Hinzuf\u00fcgen**.\n\nIhre OAuth-Client-ID lautet: `{client_id}`", + "title": "Nest: Aktualisiere das Ger\u00e4tezugriffsprojekt" + }, "init": { "data": { "flow_impl": "Anbieter" diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 90f7c244f7b..2f3324ea956 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -29,8 +29,8 @@ "data": { "code": "Access Token" }, - "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below ([more info]({more_info_url})).", - "title": "Nest: Link Google Account" + "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", + "title": "Link Google Account" }, "auth_upgrade": { "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices.", @@ -51,7 +51,7 @@ "data": { "project_id": "Device Access Project ID" }, - "description": "Create a Nest Device Access project which **requires a US$5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", + "description": "Create a Nest Device Access project which **requires a US $5 fee** to set up.\n1. Go to the [Device Access Console]({device_access_console_url}), and through the payment flow.\n1. Click on **Create project**\n1. Give your Device Access project a name and click **Next**.\n1. Enter your OAuth Client ID\n1. Enable events by clicking **Enable** and **Create project**.\n\nEnter your Device Access Project ID below ([more info]({more_info_url})).\n", "title": "Nest: Create a Device Access Project" }, "device_project_upgrade": { diff --git a/homeassistant/components/nest/translations/pt-BR.json b/homeassistant/components/nest/translations/pt-BR.json index 54b558493b8..1a4b2d8fad9 100644 --- a/homeassistant/components/nest/translations/pt-BR.json +++ b/homeassistant/components/nest/translations/pt-BR.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Siga as [instru\u00e7\u00f5es]( {more_info_url} ) para configurar o Console da nuvem: \n\n 1. V\u00e1 para a [tela de consentimento OAuth]( {oauth_consent_url} ) e configure.\n 1. Acesse [Credentials]( {oauth_creds_url} ) e clique em **Create Credentials**.\n 1. Na lista suspensa, selecione **ID do cliente OAuth**.\n 1. Selecione **Aplicativo Web** para o Tipo de aplicativo.\n 1. Adicione ` {redirect_url} ` em *URI de redirecionamento autorizado*." + }, "config": { "abort": { "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", @@ -19,7 +22,7 @@ "subscriber_error": "Erro de assinante desconhecido, veja os logs", "timeout": "Excedido tempo limite para validar c\u00f3digo", "unknown": "Erro inesperado", - "wrong_project_id": "Insira um ID de projeto do Cloud v\u00e1lido (ID do projeto de acesso ao dispositivo encontrado)" + "wrong_project_id": "Insira um ID de projeto da nuvem v\u00e1lido (\u00e9 o mesmo que o ID do projeto de acesso ao dispositivo)" }, "step": { "auth": { @@ -29,6 +32,32 @@ "description": "Para vincular sua conta do Google, [autorize sua conta]( {url} ). \n\n Ap\u00f3s a autoriza\u00e7\u00e3o, copie e cole o c\u00f3digo de token de autentica\u00e7\u00e3o fornecido abaixo.", "title": "Vincular Conta do Google" }, + "auth_upgrade": { + "description": "A autentica\u00e7\u00e3o de aplicativo foi preterida pelo Google para melhorar a seguran\u00e7a, e voc\u00ea precisa agir criando novas credenciais de aplicativo. \n\n Abra a [documenta\u00e7\u00e3o]( {more_info_url} ) para acompanhar, pois as pr\u00f3ximas etapas o guiar\u00e3o pelas etapas necess\u00e1rias para restaurar o acesso aos seus dispositivos Nest.", + "title": "Nest: suspens\u00e3o de uso da autentica\u00e7\u00e3o do aplicativo" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID do projeto do Google Cloud" + }, + "description": "Insira o ID do projeto da nuvem abaixo, por exemplo, *example-project-12345*. Consulte o [Google Cloud Console]( {cloud_console_url} ) ou a documenta\u00e7\u00e3o para [mais informa\u00e7\u00f5es]( {more_info_url} ).", + "title": "Nest: Insira o ID do projeto da nuvem" + }, + "create_cloud_project": { + "description": "A integra\u00e7\u00e3o Nest permite que voc\u00ea integre seus termostatos, c\u00e2meras e campainhas Nest usando a API de gerenciamento de dispositivos inteligentes. A API SDM **requer uma taxa de configura\u00e7\u00e3o \u00fanica de US$ 5**. Consulte a documenta\u00e7\u00e3o para [mais informa\u00e7\u00f5es]( {more_info_url} ). \n\n 1. Acesse o [Console do Google Cloud]( {cloud_console_url} ).\n 1. Se este for seu primeiro projeto, clique em **Criar Projeto** e depois em **Novo Projeto**.\n 1. D\u00ea um nome ao seu projeto na nuvem e clique em **Criar**.\n 1. Salve o ID do projeto da nuvem, por exemplo, *example-project-12345*, pois voc\u00ea precisar\u00e1 dele mais tarde.\n 1. Acesse a Biblioteca de API para [Smart Device Management API]( {sdm_api_url} ) e clique em **Ativar**.\n 1. Acesse a API Library for [Cloud Pub/Sub API]( {pubsub_api_url} ) e clique em **Ativar**. \n\n Prossiga quando seu projeto de nuvem estiver configurado.", + "title": "Nest: criar e configurar o projeto de nuvem" + }, + "device_project": { + "data": { + "project_id": "C\u00f3digo do projeto de acesso ao dispositivo" + }, + "description": "Crie um projeto Nest Device Access que **exija uma taxa de US$ 5** para ser configurado.\n 1. V\u00e1 para o [Device Access Console]( {device_access_console_url} ) e atrav\u00e9s do fluxo de pagamento.\n 1. Clique em **Criar projeto**\n 1. D\u00ea um nome ao seu projeto Device Access e clique em **Pr\u00f3ximo**.\n 1. Insira seu ID do cliente OAuth\n 1. Ative os eventos clicando em **Ativar** e **Criar projeto**. \n\n Insira o ID do projeto de acesso ao dispositivo abaixo ([mais informa\u00e7\u00f5es]( {more_info_url} )).", + "title": "Nest: criar um projeto de acesso ao dispositivo" + }, + "device_project_upgrade": { + "description": "Atualize o Nest Device Access Project com seu novo ID do cliente OAuth ([mais informa\u00e7\u00f5es]( {more_info_url} ))\n 1. V\u00e1 para o [Console de acesso ao dispositivo]( {device_access_console_url} ).\n 1. Clique no \u00edcone da lixeira ao lado de *ID do cliente OAuth*.\n 1. Clique no menu flutuante `...` e *Adicionar ID do cliente*.\n 1. Insira seu novo ID do cliente OAuth e clique em **Adicionar**. \n\n Seu ID do cliente OAuth \u00e9: ` {client_id} `", + "title": "Nest: Atualizar projeto de acesso ao dispositivo" + }, "init": { "data": { "flow_impl": "Provedor" diff --git a/homeassistant/components/nest/translations/zh-Hant.json b/homeassistant/components/nest/translations/zh-Hant.json index c52a22e6970..8eb646217b1 100644 --- a/homeassistant/components/nest/translations/zh-Hant.json +++ b/homeassistant/components/nest/translations/zh-Hant.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "\u8ddf\u96a8 [\u8aaa\u660e]({more_info_url}) \u4ee5\u8a2d\u5b9a Cloud \u63a7\u5236\u53f0\uff1a\n\n1. \u700f\u89bd\u81f3 [OAuth \u63a7\u5236\u53f0\u756b\u9762]({oauth_consent_url}) \u4e26\u8a2d\u5b9a\n1. \u700f\u89bd\u81f3 [\u6191\u8b49]({oauth_creds_url}) \u4e26\u9ede\u9078 **\u5efa\u7acb\u6191\u8b49**\u3002\n1. \u7531\u4e0b\u62c9\u9078\u55ae\u4e2d\u9078\u64c7 **OAuth \u7528\u6236\u7aef ID**\u3002\n1. \u61c9\u7528\u7a0b\u5f0f\u985e\u578b\u5247\u9078\u64c7 **Web \u61c9\u7528\u7a0b\u5f0f**\u3002\n1. \u65bc *\u8a8d\u8b49\u91cd\u65b0\u5c0e\u5411 URI* \u4e2d\u65b0\u589e `{redirect_url}`\u3002" + }, "config": { "abort": { "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002", @@ -19,7 +22,7 @@ "subscriber_error": "\u672a\u77e5\u8a02\u95b1\u932f\u8aa4\uff0c\u8acb\u53c3\u95b1\u65e5\u8a8c", "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642", "unknown": "\u672a\u9810\u671f\u932f\u8aa4", - "wrong_project_id": "\u8acb\u8f38\u5165\u6709\u6548 Cloud \u5c08\u6848 ID\uff08\u53ef\u65bc Device Access Project ID \u4e2d\u627e\u5230\uff09" + "wrong_project_id": "\u8acb\u8f38\u5165\u6709\u6548 Cloud \u5c08\u6848 ID\uff08\u8207\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ID \u76f8\u540c\uff09" }, "step": { "auth": { @@ -29,6 +32,32 @@ "description": "\u6b32\u9023\u7d50 Google \u5e33\u865f\u3001\u8acb\u5148 [\u8a8d\u8b49\u5e33\u865f]({url})\u3002\n\n\u65bc\u8a8d\u8b49\u5f8c\u3001\u65bc\u4e0b\u65b9\u8cbc\u4e0a\u8a8d\u8b49\u6b0a\u6756\u4ee3\u78bc\u3002", "title": "\u9023\u7d50 Google \u5e33\u865f" }, + "auth_upgrade": { + "description": "Google \u5df2\u4e0d\u518d\u63a8\u85a6\u4f7f\u7528 App Auth \u4ee5\u63d0\u9ad8\u5b89\u5168\u6027\u3001\u56e0\u6b64\u60a8\u9700\u8981\u5efa\u7acb\u65b0\u7684\u61c9\u7528\u7a0b\u5f0f\u6191\u8b49\u3002\n\n\u958b\u555f [\u76f8\u95dc\u6587\u4ef6]({more_info_url}) \u4e26\u8ddf\u96a8\u6b65\u9a5f\u6307\u5f15\u3001\u5c07\u5e36\u9818\u60a8\u5b58\u53d6\u6216\u56de\u5fa9\u60a8\u7684 Nest \u88dd\u7f6e\u3002", + "title": "Nest: App Auth \u5df2\u4e0d\u63a8\u85a6\u4f7f\u7528" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud \u5c08\u6848 ID" + }, + "description": "\u65bc\u4e0b\u65b9\u8f38\u5165 Cloud \u5c08\u6848 ID\u3002\u4f8b\u5982\uff1a*example-project-12345*\u3002\u8acb\u53c3\u95b1 [Google Cloud Console]({cloud_console_url}) \u6216\u76f8\u95dc\u6587\u4ef6\u4ee5\u7372\u5f97 [\u66f4\u8a73\u7d30\u8cc7\u8a0a]({more_info_url})\u3002", + "title": "Nest\uff1a\u8f38\u5165 Cloud \u5c08\u6848 ID" + }, + "create_cloud_project": { + "description": "Nest \u6574\u5408\u5c07\u5141\u8a31\u4f7f\u7528\u88dd\u7f6e\u7ba1\u7406 API \u4ee5\u6574\u5408 Nest \u6eab\u63a7\u5668\u3001\u651d\u5f71\u6a5f\u53ca\u9580\u9234\u3002SDM API **\u5c07\u5fc5\u9808\u652f\u4ed8 $5 \u7f8e\u91d1** \u4e00\u6b21\u6027\u7684\u8a2d\u5b9a\u8cbb\u7528\u3002\u8acb\u53c3\u95b1\u76f8\u95dc\u6587\u4ef6\u4ee5\u53d6\u5f97 [\u66f4\u591a\u8cc7\u8a0a]({more_info_url})\u3002\n\n1. \u700f\u89bd\u81f3 [Google Cloud \u63a7\u5236\u53f0]({cloud_console_url})\u3002\n1. \u5047\u5982\u9019\u662f\u7b2c\u4e00\u500b\u5c08\u6848\u3001\u9ede\u9078 **\u5efa\u7acb\u5c08\u6848** \u4e26\u9078\u64c7 **\u65b0\u5c08\u6848**\u3002\n1. \u5c0d Cloud \u5c08\u6848\u9032\u884c\u547d\u540d\u4e26\u9ede\u9078 **\u5efa\u7acb**\u3002\n1. \u5132\u5b58 Cloud \u5c08\u6848 ID\u3001\u4f8b\u5982\uff1a*example-project-12345*\u3001\u7a0d\u5f8c\u5c07\u6703\u7528\u4e0a\u3002\n1. \u700f\u89bd\u81f3 [\u667a\u6167\u88dd\u7f6e\u7ba1\u7406 API]({sdm_api_url}) API \u8cc7\u6599\u5eab\u4e26\u9ede\u9078 **\u555f\u7528**\u3002\n1. \u700f\u89bd\u81f3 [Cloud Pub/Sub API]({pubsub_api_url}) API \u8cc7\u6599\u5eab\u4e26\u9ede\u9078 **\u555f\u7528**\u3002\n\nCloud project \u8a2d\u5b9a\u5b8c\u6210\u5f8c\u7e7c\u7e8c\u3002", + "title": "Nest\uff1a\u5efa\u7acb\u4e26\u8a2d\u5b9a Cloud \u5c08\u6848" + }, + "device_project": { + "data": { + "project_id": "\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ID" + }, + "description": "\u5efa\u8b70 Nest \u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 **\u5c07\u6703\u9700\u8981\u652f\u4ed8 $5 \u7f8e\u91d1\u8cbb\u7528** \u4ee5\u9032\u884c\u8a2d\u5b9a\u3002\n1. \u9023\u7dda\u81f3 [\u88dd\u7f6e\u5b58\u53d6\u63a7\u5236\u53f0]({device_access_console_url})\u3001\u4e26\u9032\u884c\u4ed8\u6b3e\u7a0b\u5e8f\u3002\n1. \u9ede\u9078 **\u5efa\u7acb\u5c08\u6848**\n1. \u9032\u884c\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848\u547d\u540d\u3001\u4e26\u9ede\u9078 **\u4e0b\u4e00\u6b65**\u3002\n1. \u8f38\u5165 OAuth \u5ba2\u6236\u7aef ID\n1. \u9ede\u9078 **\u555f\u7528** \u4ee5\u555f\u7528\u4e8b\u4ef6\u4e26 **\u5efa\u7acb\u5c08\u6848**\u3002\n\n\u65bc\u4e0b\u65b9 ([\u66f4\u591a\u8cc7\u8a0a]({more_info_url})) \u8f38\u5165\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ID\u3002\n", + "title": "Nest\uff1a\u5efa\u7acb\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848" + }, + "device_project_upgrade": { + "description": "\u4f7f\u7528\u65b0\u5efa OAuth \u5ba2\u6236\u7aef ID \u66f4\u65b0 Nest \u88dd\u7f6e\u5b58\u53d6\u5c08\u6848 ([\u66f4\u8a73\u7d30\u8cc7\u8a0a]({more_info_url}))\n1. \u700f\u89bd\u81f3 [\u88dd\u7f6e\u5b58\u53d6\u63a7\u5236\u53f0]({device_access_console_url})\u3002\n1. \u9ede\u9078 *OAuth \u5ba2\u6236\u7aef ID* \u65c1\u7684\u5783\u573e\u6876\u5716\u6848\u3002\n1. \u9ede\u9078 `...` \u9078\u55ae\u4e26\u9078\u64c7 *\u65b0\u589e\u5ba2\u6236\u7aef ID*\u3002\n1. \u8f38\u5165\u65b0\u5efa OAuth \u5ba2\u6236\u7aef ID \u4e26\u9ede\u9078 **\u65b0\u589e**\u3002\n\nOAuth \u5ba2\u6236\u7aef ID \u70ba\uff1a`{client_id}`", + "title": "Nest\uff1a\u66f4\u65b0\u88dd\u7f6e\u5b58\u53d6\u5c08\u6848" + }, "init": { "data": { "flow_impl": "\u8a8d\u8b49\u63d0\u4f9b\u8005" From 90e402eca53d37dd6b8cbf49da1fc18b99b9696a Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 16 Jun 2022 00:21:39 -0500 Subject: [PATCH 440/947] Allow removing Sonos devices (#73567) --- homeassistant/components/sonos/__init__.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 1d7acd8d8dc..c26bf269a30 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -21,7 +21,7 @@ from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOSTS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_send from homeassistant.helpers.event import async_track_time_interval, call_later from homeassistant.helpers.typing import ConfigType @@ -396,3 +396,17 @@ class SonosDiscoveryManager: AVAILABILITY_CHECK_INTERVAL, ) ) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove Sonos config entry from a device.""" + known_devices = hass.data[DATA_SONOS].discovered.keys() + for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN: + continue + uid = identifier[1] + if uid not in known_devices: + return True + return False From 90dba36f80a5f724c611ecdb1c394917383f8a08 Mon Sep 17 00:00:00 2001 From: Corbeno Date: Thu, 16 Jun 2022 00:35:58 -0500 Subject: [PATCH 441/947] Proxmoxve code cleanup (#73571) code cleanup --- .../components/proxmoxve/binary_sensor.py | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/proxmoxve/binary_sensor.py b/homeassistant/components/proxmoxve/binary_sensor.py index 780e7240267..97995431778 100644 --- a/homeassistant/components/proxmoxve/binary_sensor.py +++ b/homeassistant/components/proxmoxve/binary_sensor.py @@ -35,31 +35,18 @@ async def async_setup_platform( for node_config in host_config["nodes"]: node_name = node_config["node"] - for vm_id in node_config["vms"]: - coordinator = host_name_coordinators[node_name][vm_id] + for dev_id in node_config["vms"] + node_config["containers"]: + coordinator = host_name_coordinators[node_name][dev_id] - # unfound vm case + # unfound case if (coordinator_data := coordinator.data) is None: continue - vm_name = coordinator_data["name"] - vm_sensor = create_binary_sensor( - coordinator, host_name, node_name, vm_id, vm_name + name = coordinator_data["name"] + sensor = create_binary_sensor( + coordinator, host_name, node_name, dev_id, name ) - sensors.append(vm_sensor) - - for container_id in node_config["containers"]: - coordinator = host_name_coordinators[node_name][container_id] - - # unfound container case - if (coordinator_data := coordinator.data) is None: - continue - - container_name = coordinator_data["name"] - container_sensor = create_binary_sensor( - coordinator, host_name, node_name, container_id, container_name - ) - sensors.append(container_sensor) + sensors.append(sensor) add_entities(sensors) From af81ec1f5f84869bfbf261e67f9aa5250b49e6ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 15 Jun 2022 19:51:55 -1000 Subject: [PATCH 442/947] Handle offline generators in oncue (#73568) Fixes #73565 --- homeassistant/components/oncue/entity.py | 10 +- tests/components/oncue/__init__.py | 277 +++++++++++++++++++++++ tests/components/oncue/test_sensor.py | 22 +- 3 files changed, 304 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/oncue/entity.py b/homeassistant/components/oncue/entity.py index 40ca21edf96..d1942c532e7 100644 --- a/homeassistant/components/oncue/entity.py +++ b/homeassistant/components/oncue/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from aiooncue import OncueDevice, OncueSensor +from homeassistant.const import ATTR_CONNECTIONS from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from homeassistant.helpers.update_coordinator import ( @@ -30,16 +31,21 @@ class OncueEntity(CoordinatorEntity, Entity): self._device_id = device_id self._attr_unique_id = f"{device_id}_{description.key}" self._attr_name = f"{device.name} {sensor.display_name}" - mac_address_hex = hex(int(device.sensors["MacAddress"].value))[2:] self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device_id)}, - connections={(dr.CONNECTION_NETWORK_MAC, mac_address_hex)}, name=device.name, hw_version=device.hardware_version, sw_version=device.sensors["FirmwareVersion"].display_value, model=device.sensors["GensetModelNumberSelect"].display_value, manufacturer="Kohler", ) + try: + mac_address_hex = hex(int(device.sensors["MacAddress"].value))[2:] + except ValueError: # MacAddress may be invalid if the gateway is offline + return + self._attr_device_info[ATTR_CONNECTIONS] = { + (dr.CONNECTION_NETWORK_MAC, mac_address_hex) + } @property def _oncue_value(self) -> str: diff --git a/tests/components/oncue/__init__.py b/tests/components/oncue/__init__.py index 48492a19933..32845aa8d26 100644 --- a/tests/components/oncue/__init__.py +++ b/tests/components/oncue/__init__.py @@ -269,6 +269,271 @@ MOCK_ASYNC_FETCH_ALL = { } +MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE = { + "456789": OncueDevice( + name="My Generator", + state="Off", + product_name="RDC 2.4", + hardware_version="319", + serial_number="SERIAL", + sensors={ + "Product": OncueSensor( + name="Product", + display_name="Controller Type", + value="RDC 2.4", + display_value="RDC 2.4", + unit=None, + ), + "FirmwareVersion": OncueSensor( + name="FirmwareVersion", + display_name="Current Firmware", + value="2.0.6", + display_value="2.0.6", + unit=None, + ), + "LatestFirmware": OncueSensor( + name="LatestFirmware", + display_name="Latest Firmware", + value="2.0.6", + display_value="2.0.6", + unit=None, + ), + "EngineSpeed": OncueSensor( + name="EngineSpeed", + display_name="Engine Speed", + value="0", + display_value="0 R/min", + unit="R/min", + ), + "EngineTargetSpeed": OncueSensor( + name="EngineTargetSpeed", + display_name="Engine Target Speed", + value="0", + display_value="0 R/min", + unit="R/min", + ), + "EngineOilPressure": OncueSensor( + name="EngineOilPressure", + display_name="Engine Oil Pressure", + value=0, + display_value="0 Psi", + unit="Psi", + ), + "EngineCoolantTemperature": OncueSensor( + name="EngineCoolantTemperature", + display_name="Engine Coolant Temperature", + value=32, + display_value="32 F", + unit="F", + ), + "BatteryVoltage": OncueSensor( + name="BatteryVoltage", + display_name="Battery Voltage", + value="13.4", + display_value="13.4 V", + unit="V", + ), + "LubeOilTemperature": OncueSensor( + name="LubeOilTemperature", + display_name="Lube Oil Temperature", + value=32, + display_value="32 F", + unit="F", + ), + "GensetControllerTemperature": OncueSensor( + name="GensetControllerTemperature", + display_name="Generator Controller Temperature", + value=84.2, + display_value="84.2 F", + unit="F", + ), + "EngineCompartmentTemperature": OncueSensor( + name="EngineCompartmentTemperature", + display_name="Engine Compartment Temperature", + value=62.6, + display_value="62.6 F", + unit="F", + ), + "GeneratorTrueTotalPower": OncueSensor( + name="GeneratorTrueTotalPower", + display_name="Generator True Total Power", + value="0.0", + display_value="0.0 W", + unit="W", + ), + "GeneratorTruePercentOfRatedPower": OncueSensor( + name="GeneratorTruePercentOfRatedPower", + display_name="Generator True Percent Of Rated Power", + value="0", + display_value="0 %", + unit="%", + ), + "GeneratorVoltageAB": OncueSensor( + name="GeneratorVoltageAB", + display_name="Generator Voltage AB", + value="0.0", + display_value="0.0 V", + unit="V", + ), + "GeneratorVoltageAverageLineToLine": OncueSensor( + name="GeneratorVoltageAverageLineToLine", + display_name="Generator Voltage Average Line To Line", + value="0.0", + display_value="0.0 V", + unit="V", + ), + "GeneratorCurrentAverage": OncueSensor( + name="GeneratorCurrentAverage", + display_name="Generator Current Average", + value="0.0", + display_value="0.0 A", + unit="A", + ), + "GeneratorFrequency": OncueSensor( + name="GeneratorFrequency", + display_name="Generator Frequency", + value="0.0", + display_value="0.0 Hz", + unit="Hz", + ), + "GensetSerialNumber": OncueSensor( + name="GensetSerialNumber", + display_name="Generator Serial Number", + value="33FDGMFR0026", + display_value="33FDGMFR0026", + unit=None, + ), + "GensetState": OncueSensor( + name="GensetState", + display_name="Generator State", + value="Off", + display_value="Off", + unit=None, + ), + "GensetControllerSerialNumber": OncueSensor( + name="GensetControllerSerialNumber", + display_name="Generator Controller Serial Number", + value="-1", + display_value="-1", + unit=None, + ), + "GensetModelNumberSelect": OncueSensor( + name="GensetModelNumberSelect", + display_name="Genset Model Number Select", + value="38 RCLB", + display_value="38 RCLB", + unit=None, + ), + "GensetControllerClockTime": OncueSensor( + name="GensetControllerClockTime", + display_name="Generator Controller Clock Time", + value="2022-01-13 18:08:13", + display_value="2022-01-13 18:08:13", + unit=None, + ), + "GensetControllerTotalOperationTime": OncueSensor( + name="GensetControllerTotalOperationTime", + display_name="Generator Controller Total Operation Time", + value="16770.8", + display_value="16770.8 h", + unit="h", + ), + "EngineTotalRunTime": OncueSensor( + name="EngineTotalRunTime", + display_name="Engine Total Run Time", + value="28.1", + display_value="28.1 h", + unit="h", + ), + "EngineTotalRunTimeLoaded": OncueSensor( + name="EngineTotalRunTimeLoaded", + display_name="Engine Total Run Time Loaded", + value="5.5", + display_value="5.5 h", + unit="h", + ), + "EngineTotalNumberOfStarts": OncueSensor( + name="EngineTotalNumberOfStarts", + display_name="Engine Total Number Of Starts", + value="101", + display_value="101", + unit=None, + ), + "GensetTotalEnergy": OncueSensor( + name="GensetTotalEnergy", + display_name="Genset Total Energy", + value="1.2022309E7", + display_value="1.2022309E7 kWh", + unit="kWh", + ), + "AtsContactorPosition": OncueSensor( + name="AtsContactorPosition", + display_name="Ats Contactor Position", + value="Source1", + display_value="Source1", + unit=None, + ), + "AtsSourcesAvailable": OncueSensor( + name="AtsSourcesAvailable", + display_name="Ats Sources Available", + value="Source1", + display_value="Source1", + unit=None, + ), + "Source1VoltageAverageLineToLine": OncueSensor( + name="Source1VoltageAverageLineToLine", + display_name="Source1 Voltage Average Line To Line", + value="253.5", + display_value="253.5 V", + unit="V", + ), + "Source2VoltageAverageLineToLine": OncueSensor( + name="Source2VoltageAverageLineToLine", + display_name="Source2 Voltage Average Line To Line", + value="0.0", + display_value="0.0 V", + unit="V", + ), + "IPAddress": OncueSensor( + name="IPAddress", + display_name="IP Address", + value="1.2.3.4:1026", + display_value="1.2.3.4:1026", + unit=None, + ), + "MacAddress": OncueSensor( + name="MacAddress", + display_name="Mac Address", + value="--", + display_value="--", + unit=None, + ), + "ConnectedServerIPAddress": OncueSensor( + name="ConnectedServerIPAddress", + display_name="Connected Server IP Address", + value="40.117.195.28", + display_value="40.117.195.28", + unit=None, + ), + "NetworkConnectionEstablished": OncueSensor( + name="NetworkConnectionEstablished", + display_name="Network Connection Established", + value="true", + display_value="True", + unit=None, + ), + "SerialNumber": OncueSensor( + name="SerialNumber", + display_name="Serial Number", + value="1073879692", + display_value="1073879692", + unit=None, + ), + }, + ) +} + + def _patch_login_and_data(): @contextmanager def _patcher(): @@ -279,3 +544,15 @@ def _patch_login_and_data(): yield return _patcher() + + +def _patch_login_and_data_offline_device(): + @contextmanager + def _patcher(): + with patch("homeassistant.components.oncue.Oncue.async_login",), patch( + "homeassistant.components.oncue.Oncue.async_fetch_all", + return_value=MOCK_ASYNC_FETCH_ALL_OFFLINE_DEVICE, + ): + yield + + return _patcher() diff --git a/tests/components/oncue/test_sensor.py b/tests/components/oncue/test_sensor.py index 5fe8b807c1b..60c9f68f81b 100644 --- a/tests/components/oncue/test_sensor.py +++ b/tests/components/oncue/test_sensor.py @@ -1,19 +1,29 @@ """Tests for the oncue sensor.""" from __future__ import annotations +import pytest + from homeassistant.components import oncue from homeassistant.components.oncue.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component -from . import _patch_login_and_data +from . import _patch_login_and_data, _patch_login_and_data_offline_device from tests.common import MockConfigEntry -async def test_sensors(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "patcher, connections", + [ + [_patch_login_and_data, {("mac", "c9:24:22:6f:14:00")}], + [_patch_login_and_data_offline_device, set()], + ], +) +async def test_sensors(hass: HomeAssistant, patcher, connections) -> None: """Test that the sensors are setup with the expected values.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -21,11 +31,17 @@ async def test_sensors(hass: HomeAssistant) -> None: unique_id="any", ) config_entry.add_to_hass(hass) - with _patch_login_and_data(): + with patcher(): await async_setup_component(hass, oncue.DOMAIN, {oncue.DOMAIN: {}}) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED + entity_registry = er.async_get(hass) + ent = entity_registry.async_get("sensor.my_generator_latest_firmware") + device_registry = dr.async_get(hass) + dev = device_registry.async_get(ent.device_id) + assert dev.connections == connections + assert len(hass.states.async_all("sensor")) == 25 assert hass.states.get("sensor.my_generator_latest_firmware").state == "2.0.6" From 2d07cda4e7d9a4e0c65a32d8702f760efd8b3def Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jun 2022 09:05:13 +0200 Subject: [PATCH 443/947] Improve number deprecation warnings (#73552) --- homeassistant/components/number/__init__.py | 39 +++++++++++++++++++-- tests/components/number/test_init.py | 8 +++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index 13ba47e87dc..f0dc77b7dfb 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -133,8 +133,11 @@ class NumberEntityDescription(EntityDescription): or self.step is not None or self.unit_of_measurement is not None ): - caller = inspect.stack()[2] - module = inspect.getmodule(caller[0]) + if self.__class__.__name__ == "NumberEntityDescription": + caller = inspect.stack()[2] + module = inspect.getmodule(caller[0]) + else: + module = inspect.getmodule(self) if module and module.__file__ and "custom_components" in module.__file__: report_issue = "report it to the custom component author." else: @@ -187,6 +190,38 @@ class NumberEntity(Entity): _attr_native_unit_of_measurement: str | None _deprecated_number_entity_reported = False + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + if any( + method in cls.__dict__ + for method in ( + "async_set_value", + "max_value", + "min_value", + "set_value", + "step", + "unit_of_measurement", + "value", + ) + ): + module = inspect.getmodule(cls) + if module and module.__file__ and "custom_components" in module.__file__: + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s::%s is overriding deprecated methods on an instance of " + "NumberEntity, this is not valid and will be unsupported " + "from Home Assistant 2022.10. Please %s", + cls.__module__, + cls.__name__, + report_issue, + ) + @property def capability_attributes(self) -> dict[str, Any]: """Return capability attributes.""" diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 0df7f79e4a4..9921d2a639e 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -229,8 +229,8 @@ async def test_attributes(hass: HomeAssistant) -> None: assert number_4.value is None -async def test_attributes_deprecated(hass: HomeAssistant, caplog) -> None: - """Test overriding the deprecated attributes.""" +async def test_deprecation_warnings(hass: HomeAssistant, caplog) -> None: + """Test overriding the deprecated attributes is possible and warnings are logged.""" number = MockDefaultNumberEntityDeprecated() number.hass = hass assert number.max_value == 100.0 @@ -263,6 +263,10 @@ async def test_attributes_deprecated(hass: HomeAssistant, caplog) -> None: assert number_4.unit_of_measurement == "rabbits" assert number_4.value == 0.5 + assert ( + "tests.components.number.test_init::MockNumberEntityDeprecated is overriding " + " deprecated methods on an instance of NumberEntity" + ) assert ( "Entity None () " "is using deprecated NumberEntity features" in caplog.text From 7a7729678e57306aa740e15bb1aa3b8e075b02bf Mon Sep 17 00:00:00 2001 From: muppet3000 Date: Thu, 16 Jun 2022 09:15:19 +0100 Subject: [PATCH 444/947] Bump growattServer to 1.2.2 (#73561) Fix #71577 - Updating growattServer dependency --- homeassistant/components/growatt_server/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json index c8a71d426e7..4127b48ae64 100644 --- a/homeassistant/components/growatt_server/manifest.json +++ b/homeassistant/components/growatt_server/manifest.json @@ -3,7 +3,7 @@ "name": "Growatt", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/growatt_server/", - "requirements": ["growattServer==1.1.0"], + "requirements": ["growattServer==1.2.2"], "codeowners": ["@indykoning", "@muppet3000", "@JasperPlant"], "iot_class": "cloud_polling", "loggers": ["growattServer"] diff --git a/requirements_all.txt b/requirements_all.txt index d38fc15b273..6ea4e85475a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -771,7 +771,7 @@ greenwavereality==0.5.1 gridnet==4.0.0 # homeassistant.components.growatt_server -growattServer==1.1.0 +growattServer==1.2.2 # homeassistant.components.gstreamer gstreamer-player==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2049ae73e25..e75320d28fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -553,7 +553,7 @@ greeneye_monitor==3.0.3 gridnet==4.0.0 # homeassistant.components.growatt_server -growattServer==1.1.0 +growattServer==1.2.2 # homeassistant.components.profiler guppy3==3.1.2 From 6374fd099241145f0374e76512e420f22d3042c9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:19:44 +0200 Subject: [PATCH 445/947] Add lock typing in volvooncall (#73548) --- homeassistant/components/volvooncall/lock.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py index 5023749e622..c341627eef4 100644 --- a/homeassistant/components/volvooncall/lock.py +++ b/homeassistant/components/volvooncall/lock.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from volvooncall.dashboard import Lock + from homeassistant.components.lock import LockEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -27,8 +29,10 @@ async def async_setup_platform( class VolvoLock(VolvoEntity, LockEntity): """Represents a car lock.""" + instrument: Lock + @property - def is_locked(self): + def is_locked(self) -> bool | None: """Return true if lock is locked.""" return self.instrument.is_locked From 2b5748912d9ec21050dcc3882f1f115563c63804 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:20:08 +0200 Subject: [PATCH 446/947] Add lock typing in starline (#73546) --- homeassistant/components/starline/lock.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 48f91d89809..3a5c45a2ed1 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -1,4 +1,6 @@ """Support for StarLine lock.""" +from __future__ import annotations + from typing import Any from homeassistant.components.lock import LockEntity @@ -66,7 +68,7 @@ class StarlineLock(StarlineEntity, LockEntity): ) @property - def is_locked(self): + def is_locked(self) -> bool | None: """Return true if lock is locked.""" return self._device.car_state.get("arm") From 521d52a8b90cd1e6c4f5c6f303a8f0d434b33867 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:20:34 +0200 Subject: [PATCH 447/947] Add lock typing in nuki (#73545) --- homeassistant/components/nuki/lock.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 765fc5f711f..33d7465e12d 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from typing import Any +from pynuki import NukiLock, NukiOpener from pynuki.constants import MODE_OPENER_CONTINUOUS import voluptuous as vol @@ -73,11 +74,6 @@ class NukiDeviceEntity(NukiEntity, LockEntity, ABC): """Return a unique ID.""" return self._nuki_device.nuki_id - @property - @abstractmethod - def is_locked(self): - """Return true if lock is locked.""" - @property def extra_state_attributes(self): """Return the device specific state attributes.""" @@ -108,8 +104,10 @@ class NukiDeviceEntity(NukiEntity, LockEntity, ABC): class NukiLockEntity(NukiDeviceEntity): """Representation of a Nuki lock.""" + _nuki_device: NukiLock + @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if lock is locked.""" return self._nuki_device.is_locked @@ -137,8 +135,10 @@ class NukiLockEntity(NukiDeviceEntity): class NukiOpenerEntity(NukiDeviceEntity): """Representation of a Nuki opener.""" + _nuki_device: NukiOpener + @property - def is_locked(self): + def is_locked(self) -> bool: """Return true if either ring-to-open or continuous mode is enabled.""" return not ( self._nuki_device.is_rto_activated From 7731cfd978db7045ccf761c020be2c28c307a69b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jun 2022 10:40:55 +0200 Subject: [PATCH 448/947] Add lock typing in freedompro (#73544) --- homeassistant/components/freedompro/lock.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/freedompro/lock.py b/homeassistant/components/freedompro/lock.py index 7dbc625966e..237ad50c053 100644 --- a/homeassistant/components/freedompro/lock.py +++ b/homeassistant/components/freedompro/lock.py @@ -1,5 +1,6 @@ """Support for Freedompro lock.""" import json +from typing import Any from pyfreedompro import put_state @@ -75,10 +76,10 @@ class Device(CoordinatorEntity, LockEntity): await super().async_added_to_hass() self._handle_coordinator_update() - async def async_lock(self, **kwargs): + async def async_lock(self, **kwargs: Any) -> None: """Async function to lock the lock.""" - payload = {"lock": 1} - payload = json.dumps(payload) + payload_dict = {"lock": 1} + payload = json.dumps(payload_dict) await put_state( self._session, self._api_key, @@ -87,10 +88,10 @@ class Device(CoordinatorEntity, LockEntity): ) await self.coordinator.async_request_refresh() - async def async_unlock(self, **kwargs): + async def async_unlock(self, **kwargs: Any) -> None: """Async function to unlock the lock.""" - payload = {"lock": 0} - payload = json.dumps(payload) + payload_dict = {"lock": 0} + payload = json.dumps(payload_dict) await put_state( self._session, self._api_key, From c2b484e38b11458b14c163249ae15a1414e4e435 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 16 Jun 2022 11:43:36 +0200 Subject: [PATCH 449/947] Use IP address instead of hostname in Brother integration (#73556) --- .../components/brother/config_flow.py | 5 +-- tests/components/brother/test_config_flow.py | 38 +++++++++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/brother/config_flow.py b/homeassistant/components/brother/config_flow.py index 24e7d701ed0..bcedc65d7ff 100644 --- a/homeassistant/components/brother/config_flow.py +++ b/homeassistant/components/brother/config_flow.py @@ -83,8 +83,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" - # Hostname is format: brother.local. - self.host = discovery_info.hostname.rstrip(".") + self.host = discovery_info.host # Do not probe the device if the host is already configured self._async_abort_entries_match({CONF_HOST: self.host}) @@ -102,7 +101,7 @@ class BrotherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Check if already configured await self.async_set_unique_id(self.brother.serial.lower()) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured({CONF_HOST: self.host}) self.context.update( { diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 6dbaebdfa7b..00493011500 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_HOST, CONF_TYPE from tests.common import MockConfigEntry, load_fixture -CONFIG = {CONF_HOST: "localhost", CONF_TYPE: "laser"} +CONFIG = {CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"} async def test_show_form(hass): @@ -32,13 +32,15 @@ async def test_create_entry_with_hostname(hass): return_value=json.loads(load_fixture("printer_data.json", "brother")), ): result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "example.local", CONF_TYPE: "laser"}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "HL-L2340DW 0123456789" - assert result["data"][CONF_HOST] == CONFIG[CONF_HOST] - assert result["data"][CONF_TYPE] == CONFIG[CONF_TYPE] + assert result["data"][CONF_HOST] == "example.local" + assert result["data"][CONF_TYPE] == "laser" async def test_create_entry_with_ipv4_address(hass): @@ -48,9 +50,7 @@ async def test_create_entry_with_ipv4_address(hass): return_value=json.loads(load_fixture("printer_data.json", "brother")), ): result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_USER}, - data={CONF_HOST: "127.0.0.1", CONF_TYPE: "laser"}, + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -145,7 +145,7 @@ async def test_zeroconf_snmp_error(hass): DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", addresses=["mock_host"], hostname="example.local.", name="Brother Printer", @@ -166,7 +166,7 @@ async def test_zeroconf_unsupported_model(hass): DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", addresses=["mock_host"], hostname="example.local.", name="Brother Printer", @@ -187,15 +187,18 @@ async def test_zeroconf_device_exists_abort(hass): "brother.Brother._get_data", return_value=json.loads(load_fixture("printer_data.json", "brother")), ): - MockConfigEntry(domain=DOMAIN, unique_id="0123456789", data=CONFIG).add_to_hass( - hass + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="0123456789", + data={CONF_HOST: "example.local", CONF_TYPE: "laser"}, ) + entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", addresses=["mock_host"], hostname="example.local.", name="Brother Printer", @@ -208,6 +211,9 @@ async def test_zeroconf_device_exists_abort(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured" + # Test config entry got updated with latest IP + assert entry.data["host"] == "127.0.0.1" + async def test_zeroconf_no_probe_existing_device(hass): """Test we do not probe the device is the host is already configured.""" @@ -218,9 +224,9 @@ async def test_zeroconf_no_probe_existing_device(hass): DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", addresses=["mock_host"], - hostname="localhost", + hostname="example.local.", name="Brother Printer", port=None, properties={}, @@ -245,7 +251,7 @@ async def test_zeroconf_confirm_create_entry(hass): DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", + host="127.0.0.1", addresses=["mock_host"], hostname="example.local.", name="Brother Printer", @@ -266,5 +272,5 @@ async def test_zeroconf_confirm_create_entry(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "HL-L2340DW 0123456789" - assert result["data"][CONF_HOST] == "example.local" + assert result["data"][CONF_HOST] == "127.0.0.1" assert result["data"][CONF_TYPE] == "laser" From 67b035463255921a2450b70367ce702ffbb5a184 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jun 2022 12:57:41 +0200 Subject: [PATCH 450/947] Adjust FlowResult construction in data entry flow (#72884) --- homeassistant/data_entry_flow.py | 140 +++++++++++++++---------------- 1 file changed, 70 insertions(+), 70 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index abc8061c0d5..23b35138df7 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -109,12 +109,12 @@ def _async_flow_handler_to_flow_result( ) -> list[FlowResult]: """Convert a list of FlowHandler to a partial FlowResult that can be serialized.""" return [ - { - "flow_id": flow.flow_id, - "handler": flow.handler, - "context": flow.context, - "step_id": flow.cur_step["step_id"] if flow.cur_step else None, - } + FlowResult( + flow_id=flow.flow_id, + handler=flow.handler, + context=flow.context, + step_id=flow.cur_step["step_id"] if flow.cur_step else None, + ) for flow in flows if include_uninitialized or flow.cur_step is not None ] @@ -446,16 +446,16 @@ class FlowHandler: last_step: bool | None = None, ) -> FlowResult: """Return the definition of a form to gather user input.""" - return { - "type": FlowResultType.FORM, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "data_schema": data_schema, - "errors": errors, - "description_placeholders": description_placeholders, - "last_step": last_step, # Display next or submit button in frontend - } + return FlowResult( + type=FlowResultType.FORM, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + data_schema=data_schema, + errors=errors, + description_placeholders=description_placeholders, + last_step=last_step, # Display next or submit button in frontend + ) @callback def async_create_entry( @@ -467,16 +467,16 @@ class FlowHandler: description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Finish config flow and create a config entry.""" - return { - "version": self.VERSION, - "type": FlowResultType.CREATE_ENTRY, - "flow_id": self.flow_id, - "handler": self.handler, - "title": title, - "data": data, - "description": description, - "description_placeholders": description_placeholders, - } + return FlowResult( + version=self.VERSION, + type=FlowResultType.CREATE_ENTRY, + flow_id=self.flow_id, + handler=self.handler, + title=title, + data=data, + description=description, + description_placeholders=description_placeholders, + ) @callback def async_abort( @@ -499,24 +499,24 @@ class FlowHandler: description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Return the definition of an external step for the user to take.""" - return { - "type": FlowResultType.EXTERNAL_STEP, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "url": url, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.EXTERNAL_STEP, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + url=url, + description_placeholders=description_placeholders, + ) @callback def async_external_step_done(self, *, next_step_id: str) -> FlowResult: """Return the definition of an external step for the user to take.""" - return { - "type": FlowResultType.EXTERNAL_STEP_DONE, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": next_step_id, - } + return FlowResult( + type=FlowResultType.EXTERNAL_STEP_DONE, + flow_id=self.flow_id, + handler=self.handler, + step_id=next_step_id, + ) @callback def async_show_progress( @@ -527,24 +527,24 @@ class FlowHandler: description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Show a progress message to the user, without user input allowed.""" - return { - "type": FlowResultType.SHOW_PROGRESS, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "progress_action": progress_action, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.SHOW_PROGRESS, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + progress_action=progress_action, + description_placeholders=description_placeholders, + ) @callback def async_show_progress_done(self, *, next_step_id: str) -> FlowResult: """Mark the progress done.""" - return { - "type": FlowResultType.SHOW_PROGRESS_DONE, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": next_step_id, - } + return FlowResult( + type=FlowResultType.SHOW_PROGRESS_DONE, + flow_id=self.flow_id, + handler=self.handler, + step_id=next_step_id, + ) @callback def async_show_menu( @@ -558,15 +558,15 @@ class FlowHandler: Options dict maps step_id => i18n label """ - return { - "type": FlowResultType.MENU, - "flow_id": self.flow_id, - "handler": self.handler, - "step_id": step_id, - "data_schema": vol.Schema({"next_step_id": vol.In(menu_options)}), - "menu_options": menu_options, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.MENU, + flow_id=self.flow_id, + handler=self.handler, + step_id=step_id, + data_schema=vol.Schema({"next_step_id": vol.In(menu_options)}), + menu_options=menu_options, + description_placeholders=description_placeholders, + ) @callback @@ -577,10 +577,10 @@ def _create_abort_data( description_placeholders: Mapping[str, str] | None = None, ) -> FlowResult: """Return the definition of an external step for the user to take.""" - return { - "type": FlowResultType.ABORT, - "flow_id": flow_id, - "handler": handler, - "reason": reason, - "description_placeholders": description_placeholders, - } + return FlowResult( + type=FlowResultType.ABORT, + flow_id=flow_id, + handler=handler, + reason=reason, + description_placeholders=description_placeholders, + ) From dea80414614c2f201686e840b4d2c1a9411279d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jun 2022 13:34:54 +0200 Subject: [PATCH 451/947] Add device_class to MQTT number and migrate to native_value (#73534) --- homeassistant/components/mqtt/number.py | 33 +++-- homeassistant/components/number/__init__.py | 5 +- tests/components/mqtt/test_number.py | 141 ++++++++++++++------ 3 files changed, 122 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 1404dc86a3c..bbc78ae07db 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -11,10 +11,12 @@ from homeassistant.components.number import ( DEFAULT_MAX_VALUE, DEFAULT_MIN_VALUE, DEFAULT_STEP, - NumberEntity, + DEVICE_CLASSES_SCHEMA, + RestoreNumber, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_NAME, CONF_OPTIMISTIC, CONF_UNIT_OF_MEASUREMENT, @@ -23,7 +25,6 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -78,6 +79,7 @@ def validate_config(config): _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( { vol.Optional(CONF_COMMAND_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): vol.Coerce(float), vol.Optional(CONF_MIN, default=DEFAULT_MIN_VALUE): vol.Coerce(float), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -152,7 +154,7 @@ async def _async_setup_entity( async_add_entities([MqttNumber(hass, config, config_entry, discovery_data)]) -class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): +class MqttNumber(MqttEntity, RestoreNumber): """representation of an MQTT number.""" _entity_id_format = number.ENTITY_ID_FORMAT @@ -166,7 +168,7 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): self._current_number = None - NumberEntity.__init__(self) + RestoreNumber.__init__(self) MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod @@ -243,35 +245,37 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and (last_state := await self.async_get_last_state()): - self._current_number = last_state.state + if self._optimistic and ( + last_number_data := await self.async_get_last_number_data() + ): + self._current_number = last_number_data.native_value @property - def min_value(self) -> float: + def native_min_value(self) -> float: """Return the minimum value.""" return self._config[CONF_MIN] @property - def max_value(self) -> float: + def native_max_value(self) -> float: """Return the maximum value.""" return self._config[CONF_MAX] @property - def step(self) -> float: + def native_step(self) -> float: """Return the increment/decrement step.""" return self._config[CONF_STEP] @property - def unit_of_measurement(self) -> str | None: + def native_unit_of_measurement(self) -> str | None: """Return the unit of measurement.""" return self._config.get(CONF_UNIT_OF_MEASUREMENT) @property - def value(self): + def native_value(self): """Return the current value.""" return self._current_number - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value.""" current_number = value @@ -295,3 +299,8 @@ class MqttNumber(MqttEntity, NumberEntity, RestoreEntity): def assumed_state(self): """Return true if we do optimistic updates.""" return self._optimistic + + @property + def device_class(self) -> str | None: + """Return the device class of the sensor.""" + return self._config.get(CONF_DEVICE_CLASS) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index f0dc77b7dfb..f0095e2aecb 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -8,7 +8,7 @@ from datetime import timedelta import inspect import logging from math import ceil, floor -from typing import Any, final +from typing import Any, Final, final import voluptuous as vol @@ -54,6 +54,9 @@ class NumberDeviceClass(StrEnum): TEMPERATURE = "temperature" +DEVICE_CLASSES_SCHEMA: Final = vol.All(vol.Lower, vol.Coerce(NumberDeviceClass)) + + class NumberMode(StrEnum): """Modes for number entities.""" diff --git a/tests/components/mqtt/test_number.py b/tests/components/mqtt/test_number.py index ea79c5cd7aa..1db7c5e3463 100644 --- a/tests/components/mqtt/test_number.py +++ b/tests/components/mqtt/test_number.py @@ -18,11 +18,14 @@ from homeassistant.components.number import ( ATTR_VALUE, DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, + NumberDeviceClass, ) from homeassistant.const import ( ATTR_ASSUMED_STATE, + ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, + TEMP_FAHRENHEIT, Platform, ) import homeassistant.core as ha @@ -58,7 +61,7 @@ from .test_common import ( help_test_update_with_json_attrs_not_dict, ) -from tests.common import async_fire_mqtt_message +from tests.common import async_fire_mqtt_message, mock_restore_cache_with_extra_data DEFAULT_CONFIG = { number.DOMAIN: {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} @@ -84,7 +87,8 @@ async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): "state_topic": topic, "command_topic": topic, "name": "Test Number", - "unit_of_measurement": "my unit", + "device_class": "temperature", + "unit_of_measurement": TEMP_FAHRENHEIT, "payload_reset": "reset!", } }, @@ -97,16 +101,18 @@ async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): await hass.async_block_till_done() state = hass.states.get("number.test_number") - assert state.state == "10" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "my unit" + assert state.state == "-12.0" # 10 °F -> -12 °C + assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" async_fire_mqtt_message(hass, topic, "20.5") await hass.async_block_till_done() state = hass.states.get("number.test_number") - assert state.state == "20.5" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "my unit" + assert state.state == "-6.4" # 20.5 °F -> -6.4 °C + assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" async_fire_mqtt_message(hass, topic, "reset!") @@ -114,7 +120,8 @@ async def test_run_number_setup(hass, mqtt_mock_entry_with_yaml_config): state = hass.states.get("number.test_number") assert state.state == "unknown" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "my unit" + assert state.attributes.get(ATTR_DEVICE_CLASS) == NumberDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "°C" async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): @@ -158,29 +165,70 @@ async def test_value_template(hass, mqtt_mock_entry_with_yaml_config): assert state.state == "unknown" +async def test_restore_native_value(hass, mqtt_mock_entry_with_yaml_config): + """Test that the stored native_value is restored.""" + topic = "test/number" + + RESTORE_DATA = { + "native_max_value": None, # Ignored by MQTT number + "native_min_value": None, # Ignored by MQTT number + "native_step": None, # Ignored by MQTT number + "native_unit_of_measurement": None, # Ignored by MQTT number + "native_value": 100.0, + } + + mock_restore_cache_with_extra_data( + hass, ((ha.State("number.test_number", "abc"), RESTORE_DATA),) + ) + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": topic, + "device_class": "temperature", + "unit_of_measurement": TEMP_FAHRENHEIT, + "name": "Test Number", + } + }, + ) + await hass.async_block_till_done() + await mqtt_mock_entry_with_yaml_config() + + state = hass.states.get("number.test_number") + assert state.state == "37.8" + assert state.attributes.get(ATTR_ASSUMED_STATE) + + async def test_run_number_service_optimistic(hass, mqtt_mock_entry_with_yaml_config): """Test that set_value service works in optimistic mode.""" topic = "test/number" - fake_state = ha.State("switch.test", "3") + RESTORE_DATA = { + "native_max_value": None, # Ignored by MQTT number + "native_min_value": None, # Ignored by MQTT number + "native_step": None, # Ignored by MQTT number + "native_unit_of_measurement": None, # Ignored by MQTT number + "native_value": 3, + } - with patch( - "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", - return_value=fake_state, - ): - assert await async_setup_component( - hass, - number.DOMAIN, - { - "number": { - "platform": "mqtt", - "command_topic": topic, - "name": "Test Number", - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache_with_extra_data( + hass, ((ha.State("number.test_number", "abc"), RESTORE_DATA),) + ) + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Number", + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" @@ -232,26 +280,31 @@ async def test_run_number_service_optimistic_with_command_template( """Test that set_value service works in optimistic mode and with a command_template.""" topic = "test/number" - fake_state = ha.State("switch.test", "3") + RESTORE_DATA = { + "native_max_value": None, # Ignored by MQTT number + "native_min_value": None, # Ignored by MQTT number + "native_step": None, # Ignored by MQTT number + "native_unit_of_measurement": None, # Ignored by MQTT number + "native_value": 3, + } - with patch( - "homeassistant.helpers.restore_state.RestoreEntity.async_get_last_state", - return_value=fake_state, - ): - assert await async_setup_component( - hass, - number.DOMAIN, - { - "number": { - "platform": "mqtt", - "command_topic": topic, - "name": "Test Number", - "command_template": '{"number": {{ value }} }', - } - }, - ) - await hass.async_block_till_done() - mqtt_mock = await mqtt_mock_entry_with_yaml_config() + mock_restore_cache_with_extra_data( + hass, ((ha.State("number.test_number", "abc"), RESTORE_DATA),) + ) + assert await async_setup_component( + hass, + number.DOMAIN, + { + "number": { + "platform": "mqtt", + "command_topic": topic, + "name": "Test Number", + "command_template": '{"number": {{ value }} }', + } + }, + ) + await hass.async_block_till_done() + mqtt_mock = await mqtt_mock_entry_with_yaml_config() state = hass.states.get("number.test_number") assert state.state == "3" From ddca199961aa0ac8943ad0e994c94a963b81a748 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jun 2022 13:49:16 +0200 Subject: [PATCH 452/947] Migrate tuya NumberEntity to native_value (#73491) --- homeassistant/components/tuya/number.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index a342fe58bd2..bee5242d4ae 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -338,9 +338,9 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): description.key, dptype=DPType.INTEGER, prefer_function=True ): self._number = int_type - self._attr_max_value = self._number.max_scaled - self._attr_min_value = self._number.min_scaled - self._attr_step = self._number.step_scaled + self._attr_native_max_value = self._number.max_scaled + self._attr_native_min_value = self._number.min_scaled + self._attr_native_step = self._number.step_scaled # Logic to ensure the set device class and API received Unit Of Measurement # match Home Assistants requirements. @@ -373,8 +373,14 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): if self.device_class: self._attr_icon = None + # Found unit of measurement, use the standardized Unit + # Use the target conversion unit (if set) + self._attr_native_unit_of_measurement = ( + self._uom.conversion_unit or self._uom.unit + ) + @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the entity value to represent the entity state.""" # Unknown or unsupported data type if self._number is None: @@ -386,7 +392,7 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity): return self._number.scale_value(value) - def set_value(self, value: float) -> None: + def set_native_value(self, value: float) -> None: """Set new value.""" if self._number is None: raise RuntimeError("Cannot set value, device doesn't provide type data") From 8049170e5a9e904f6618580281bbd9fcb8adb64d Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 16 Jun 2022 14:40:41 +0200 Subject: [PATCH 453/947] Initialize hass.config_entries for check config (#73575) --- homeassistant/scripts/check_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 221dafa729c..cff40d2535c 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -13,6 +13,7 @@ from unittest.mock import patch from homeassistant import core from homeassistant.config import get_default_config_dir +from homeassistant.config_entries import ConfigEntries from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import area_registry, device_registry, entity_registry from homeassistant.helpers.check_config import async_check_ha_config_file @@ -228,6 +229,7 @@ async def async_check_config(config_dir): """Check the HA config.""" hass = core.HomeAssistant() hass.config.config_dir = config_dir + hass.config_entries = ConfigEntries(hass, {}) await area_registry.async_load(hass) await device_registry.async_load(hass) await entity_registry.async_load(hass) From e2327622c36316a653d6f38cb7264572ded42ac1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jun 2022 15:02:50 +0200 Subject: [PATCH 454/947] Migrate SNMP sensor to TemplateEntity (#73324) --- homeassistant/components/snmp/sensor.py | 51 +++++++--------- requirements_test_all.txt | 3 + tests/components/snmp/__init__.py | 1 + tests/components/snmp/test_sensor.py | 79 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 30 deletions(-) create mode 100644 tests/components/snmp/__init__.py create mode 100644 tests/components/snmp/test_sensor.py diff --git a/homeassistant/components/snmp/sensor.py b/homeassistant/components/snmp/sensor.py index ba111ffc9bc..11f8c7d2f64 100644 --- a/homeassistant/components/snmp/sensor.py +++ b/homeassistant/components/snmp/sensor.py @@ -17,12 +17,11 @@ from pysnmp.hlapi.asyncio import ( ) import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, - CONF_NAME, CONF_PORT, - CONF_UNIT_OF_MEASUREMENT, + CONF_UNIQUE_ID, CONF_USERNAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, @@ -30,6 +29,10 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import ( + TEMPLATE_SENSOR_BASE_SCHEMA, + TemplateSensor, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -66,9 +69,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string, vol.Optional(CONF_DEFAULT_VALUE): cv.string, vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In(SNMP_VERSIONS), vol.Optional(CONF_USERNAME): cv.string, @@ -81,7 +82,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( MAP_PRIV_PROTOCOLS ), } -) +).extend(TEMPLATE_SENSOR_BASE_SCHEMA.schema) async def async_setup_platform( @@ -91,12 +92,10 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the SNMP sensor.""" - name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) community = config.get(CONF_COMMUNITY) baseoid = config.get(CONF_BASEOID) - unit = config.get(CONF_UNIT_OF_MEASUREMENT) version = config[CONF_VERSION] username = config.get(CONF_USERNAME) authkey = config.get(CONF_AUTH_KEY) @@ -105,10 +104,7 @@ async def async_setup_platform( privproto = config[CONF_PRIV_PROTOCOL] accept_errors = config.get(CONF_ACCEPT_ERRORS) default_value = config.get(CONF_DEFAULT_VALUE) - value_template = config.get(CONF_VALUE_TEMPLATE) - - if value_template is not None: - value_template.hass = hass + unique_id = config.get(CONF_UNIQUE_ID) if version == "3": @@ -146,35 +142,30 @@ async def async_setup_platform( return data = SnmpData(request_args, baseoid, accept_errors, default_value) - async_add_entities([SnmpSensor(data, name, unit, value_template)], True) + async_add_entities([SnmpSensor(hass, data, config, unique_id)], True) -class SnmpSensor(SensorEntity): +class SnmpSensor(TemplateSensor): """Representation of a SNMP sensor.""" - def __init__(self, data, name, unit_of_measurement, value_template): - """Initialize the sensor.""" - self.data = data - self._name = name - self._state = None - self._unit_of_measurement = unit_of_measurement - self._value_template = value_template + _attr_should_poll = True - @property - def name(self): - """Return the name of the sensor.""" - return self._name + def __init__(self, hass, data, config, unique_id): + """Initialize the sensor.""" + super().__init__( + hass, config=config, unique_id=unique_id, fallback_name=DEFAULT_NAME + ) + self.data = data + self._state = None + self._value_template = config.get(CONF_VALUE_TEMPLATE) + if (value_template := self._value_template) is not None: + value_template.hass = hass @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit_of_measurement - async def async_update(self): """Get the latest data and updates the states.""" await self.data.async_update() diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e75320d28fa..b696ce6829e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1241,6 +1241,9 @@ pysmartapp==0.3.3 # homeassistant.components.smartthings pysmartthings==0.7.6 +# homeassistant.components.snmp +pysnmplib==5.0.15 + # homeassistant.components.soma pysoma==0.0.10 diff --git a/tests/components/snmp/__init__.py b/tests/components/snmp/__init__.py new file mode 100644 index 00000000000..e3890bb18fe --- /dev/null +++ b/tests/components/snmp/__init__.py @@ -0,0 +1 @@ +"""Tests for the SNMP integration.""" diff --git a/tests/components/snmp/test_sensor.py b/tests/components/snmp/test_sensor.py new file mode 100644 index 00000000000..9f3a555c2d9 --- /dev/null +++ b/tests/components/snmp/test_sensor.py @@ -0,0 +1,79 @@ +"""SNMP sensor tests.""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + + +@pytest.fixture(autouse=True) +def hlapi_mock(): + """Mock out 3rd party API.""" + mock_data = MagicMock() + mock_data.prettyPrint = Mock(return_value="hello") + with patch( + "homeassistant.components.snmp.sensor.getCmd", + return_value=(None, None, None, [[mock_data]]), + ): + yield + + +async def test_basic_config(hass: HomeAssistant) -> None: + """Test basic entity configuration.""" + + config = { + SENSOR_DOMAIN: { + "platform": "snmp", + "host": "192.168.1.32", + "baseoid": "1.3.6.1.4.1.2021.10.1.3.1", + }, + } + + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.snmp") + assert state.state == "hello" + assert state.attributes == {"friendly_name": "SNMP"} + + +async def test_entity_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + SENSOR_DOMAIN: { + # SNMP configuration + "platform": "snmp", + "host": "192.168.1.32", + "baseoid": "1.3.6.1.4.1.2021.10.1.3.1", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "device_class": "temperature", + "name": "{{'SNMP' + ' ' + 'Sensor'}}", + "state_class": "measurement", + "unique_id": "very_unique", + "unit_of_measurement": "beardsecond", + }, + } + + assert await async_setup_component(hass, SENSOR_DOMAIN, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert entity_registry.async_get("sensor.snmp_sensor").unique_id == "very_unique" + + state = hass.states.get("sensor.snmp_sensor") + assert state.state == "hello" + assert state.attributes == { + "device_class": "temperature", + "entity_picture": "blabla.png", + "friendly_name": "SNMP Sensor", + "icon": "mdi:one_two_three", + "state_class": "measurement", + "unit_of_measurement": "beardsecond", + } From 3e1a4d86a3f7165dd60344348cf21e4a769fe8c1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 16 Jun 2022 16:35:00 +0200 Subject: [PATCH 455/947] Fix modification of mutable global in xiaomi_miio number (#73579) --- homeassistant/components/xiaomi_miio/number.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/number.py b/homeassistant/components/xiaomi_miio/number.py index 02855a89c1f..7fd5347f432 100644 --- a/homeassistant/components/xiaomi_miio/number.py +++ b/homeassistant/components/xiaomi_miio/number.py @@ -1,6 +1,7 @@ """Motor speed support for Xiaomi Mi Air Humidifier.""" from __future__ import annotations +import dataclasses from dataclasses import dataclass from homeassistant.components.number import NumberEntity, NumberEntityDescription @@ -273,9 +274,12 @@ async def async_setup_entry( description.key == ATTR_OSCILLATION_ANGLE and model in OSCILLATION_ANGLE_VALUES ): - description.max_value = OSCILLATION_ANGLE_VALUES[model].max_value - description.min_value = OSCILLATION_ANGLE_VALUES[model].min_value - description.step = OSCILLATION_ANGLE_VALUES[model].step + description = dataclasses.replace( + description, + native_max_value=OSCILLATION_ANGLE_VALUES[model].max_value, + native_min_value=OSCILLATION_ANGLE_VALUES[model].min_value, + native_step=OSCILLATION_ANGLE_VALUES[model].step, + ) entities.append( XiaomiNumberEntity( From f7945cdc647ef4cf4de0f642be58e77b46b2456b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 16 Jun 2022 16:43:09 +0200 Subject: [PATCH 456/947] Add build musllinux wheel (#73587) * Add build musllinux wheel * cleanup --- .github/workflows/wheels.yml | 118 +++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 605820efb33..602a8a78d8c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -102,6 +102,54 @@ jobs: requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" + core_musllinux: + name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for core + if: github.repository_owner == 'home-assistant' + needs: init + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: ${{ fromJson(needs.init.outputs.architectures) }} + steps: + - name: Checkout the repository + uses: actions/checkout@v3.0.2 + + - name: Download env_file + uses: actions/download-artifact@v3 + with: + name: env_file + + - name: Download requirements_diff + uses: actions/download-artifact@v3 + with: + name: requirements_diff + + - name: Adjust ENV / CP310 + run: | + if [ "${{ matrix.arch }}" = "i386" ]; + echo "NPY_DISABLE_SVML=1" >> .env_file + fi + + requirement_files="requirements_all.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + sed -i "s|numpy==1.21.6|numpy==1.22.4|g" ${requirement_file} + done + + - name: Build wheels + uses: home-assistant/wheels@2022.06.1 + with: + abi: cp310 + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev" + skip-binary: aiohttp + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements.txt" + integrations: name: Build wheels with ${{ matrix.tag }} (${{ matrix.arch }}) for integrations if: github.repository_owner == 'home-assistant' @@ -164,3 +212,73 @@ jobs: constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txt" + + integrations_musllinux: + name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for integrations + if: github.repository_owner == 'home-assistant' + needs: init + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + arch: ${{ fromJson(needs.init.outputs.architectures) }} + steps: + - name: Checkout the repository + uses: actions/checkout@v3.0.2 + + - name: Download env_file + uses: actions/download-artifact@v3 + with: + name: env_file + + - name: Download requirements_diff + uses: actions/download-artifact@v3 + with: + name: requirements_diff + + - name: Uncomment packages + run: | + requirement_files="requirements_all.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + sed -i "s|# pybluez|pybluez|g" ${requirement_file} + sed -i "s|# bluepy|bluepy|g" ${requirement_file} + sed -i "s|# beacontools|beacontools|g" ${requirement_file} + sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} + sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} + sed -i "s|# evdev|evdev|g" ${requirement_file} + sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} + sed -i "s|# pycups|pycups|g" ${requirement_file} + sed -i "s|# homekit|homekit|g" ${requirement_file} + sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} + sed -i "s|# decora|decora|g" ${requirement_file} + sed -i "s|# avion|avion|g" ${requirement_file} + sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} + sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} + sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} + sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} + done + + - name: Adjust ENV / CP310 + run: | + if [ "${{ matrix.arch }}" = "i386" ]; + echo "NPY_DISABLE_SVML=1" >> .env_file + fi + + requirement_files="requirements_all.txt requirements_diff.txt" + for requirement_file in ${requirement_files}; do + sed -i "s|numpy==1.21.6|numpy==1.22.4|g" ${requirement_file} + done + + - name: Build wheels + uses: home-assistant/wheels@2022.06.1 + with: + abi: cp310 + tag: musllinux_1_2 + arch: ${{ matrix.arch }} + wheels-key: ${{ secrets.WHEELS_KEY }} + env-file: true + apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev" + skip-binary: aiohttp;grpcio + constraints: "homeassistant/package_constraints.txt" + requirements-diff: "requirements_diff.txt" + requirements: "requirements_all.txt" From 63ff3f87dc61a135daba5e1a2577fcb2e6d93eee Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 16 Jun 2022 17:00:36 +0200 Subject: [PATCH 457/947] Fix wheel pipeline (#73594) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 602a8a78d8c..d9bd606bf85 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -127,7 +127,7 @@ jobs: - name: Adjust ENV / CP310 run: | - if [ "${{ matrix.arch }}" = "i386" ]; + if [ "${{ matrix.arch }}" = "i386" ]; then echo "NPY_DISABLE_SVML=1" >> .env_file fi @@ -260,7 +260,7 @@ jobs: - name: Adjust ENV / CP310 run: | - if [ "${{ matrix.arch }}" = "i386" ]; + if [ "${{ matrix.arch }}" = "i386" ]; then echo "NPY_DISABLE_SVML=1" >> .env_file fi From 9687aab802e1e4377ed0bd3e73c62f47244fb98f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 16 Jun 2022 17:17:30 +0200 Subject: [PATCH 458/947] Add yaml-dev core wheel apk (#73597) --- .github/workflows/wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index d9bd606bf85..c499f7ee1a4 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -144,7 +144,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev" + apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;yaml-dev" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" From 01a4a83babd19e1316c1770b65a2b12803ab1837 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Fri, 17 Jun 2022 01:48:52 +1000 Subject: [PATCH 459/947] Improve stream playback on high latency cameras (#72547) * Disable LL-HLS for HLS sources * Add extra wait for Nest cameras --- homeassistant/components/camera/__init__.py | 2 +- .../components/generic/config_flow.py | 2 +- homeassistant/components/nest/camera_sdm.py | 2 + homeassistant/components/stream/__init__.py | 72 ++++++++++++------- homeassistant/components/stream/const.py | 1 + homeassistant/components/stream/core.py | 19 ++--- homeassistant/components/stream/hls.py | 28 +++++--- homeassistant/components/stream/recorder.py | 11 ++- homeassistant/components/stream/worker.py | 25 ++++--- tests/components/stream/test_ll_hls.py | 4 +- tests/components/stream/test_worker.py | 48 ++++++++++--- 11 files changed, 145 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 2ed8b58232d..45b77ec1bd6 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -454,7 +454,7 @@ class Camera(Entity): def __init__(self) -> None: """Initialize a camera.""" self.stream: Stream | None = None - self.stream_options: dict[str, str | bool] = {} + self.stream_options: dict[str, str | bool | float] = {} self.content_type: str = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) self._warned_old_signature = False diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 93b34133c63..3cc78fca406 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -209,7 +209,7 @@ async def async_test_stream(hass, info) -> dict[str, str]: except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", stream_source, err) return {CONF_STREAM_SOURCE: "template_error"} - stream_options: dict[str, bool | str] = {} + stream_options: dict[str, str | bool | float] = {} if rtsp_transport := info.get(CONF_RTSP_TRANSPORT): stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index 61f8ead4ea3..a089163a826 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -20,6 +20,7 @@ from google_nest_sdm.exceptions import ApiException from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.components.camera.const import StreamType +from homeassistant.components.stream import CONF_EXTRA_PART_WAIT_TIME from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -67,6 +68,7 @@ class NestCamera(Camera): self._create_stream_url_lock = asyncio.Lock() self._stream_refresh_unsub: Callable[[], None] | None = None self._attr_is_streaming = CameraLiveStreamTrait.NAME in self._device.traits + self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 @property def should_poll(self) -> bool: diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index c33188fd71c..19ef009a845 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -18,6 +18,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Mapping +import copy import logging import re import secrets @@ -38,6 +39,7 @@ from .const import ( ATTR_ENDPOINTS, ATTR_SETTINGS, ATTR_STREAMS, + CONF_EXTRA_PART_WAIT_TIME, CONF_LL_HLS, CONF_PART_DURATION, CONF_RTSP_TRANSPORT, @@ -62,8 +64,11 @@ from .diagnostics import Diagnostics from .hls import HlsStreamOutput, async_setup_hls __all__ = [ + "ATTR_SETTINGS", + "CONF_EXTRA_PART_WAIT_TIME", "CONF_RTSP_TRANSPORT", "CONF_USE_WALLCLOCK_AS_TIMESTAMPS", + "DOMAIN", "FORMAT_CONTENT_TYPE", "HLS_PROVIDER", "OUTPUT_FORMATS", @@ -91,7 +96,7 @@ def redact_credentials(data: str) -> str: def create_stream( hass: HomeAssistant, stream_source: str, - options: dict[str, str | bool], + options: Mapping[str, str | bool | float], stream_label: str | None = None, ) -> Stream: """Create a stream with the specified identfier based on the source url. @@ -101,11 +106,35 @@ def create_stream( The stream_label is a string used as an additional message in logging. """ + + def convert_stream_options( + hass: HomeAssistant, stream_options: Mapping[str, str | bool | float] + ) -> tuple[dict[str, str], StreamSettings]: + """Convert options from stream options into PyAV options and stream settings.""" + stream_settings = copy.copy(hass.data[DOMAIN][ATTR_SETTINGS]) + pyav_options: dict[str, str] = {} + try: + STREAM_OPTIONS_SCHEMA(stream_options) + except vol.Invalid as exc: + raise HomeAssistantError("Invalid stream options") from exc + + if extra_wait_time := stream_options.get(CONF_EXTRA_PART_WAIT_TIME): + stream_settings.hls_part_timeout += extra_wait_time + if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): + assert isinstance(rtsp_transport, str) + # The PyAV options currently match the stream CONF constants, but this + # will not necessarily always be the case, so they are hard coded here + pyav_options["rtsp_transport"] = rtsp_transport + if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): + pyav_options["use_wallclock_as_timestamps"] = "1" + + return pyav_options, stream_settings + if DOMAIN not in hass.config.components: raise HomeAssistantError("Stream integration is not set up.") - # Convert extra stream options into PyAV options - pyav_options = convert_stream_options(options) + # Convert extra stream options into PyAV options and stream settings + pyav_options, stream_settings = convert_stream_options(hass, options) # For RTSP streams, prefer TCP if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": pyav_options = { @@ -115,7 +144,11 @@ def create_stream( } stream = Stream( - hass, stream_source, options=pyav_options, stream_label=stream_label + hass, + stream_source, + pyav_options=pyav_options, + stream_settings=stream_settings, + stream_label=stream_label, ) hass.data[DOMAIN][ATTR_STREAMS].append(stream) return stream @@ -230,13 +263,15 @@ class Stream: self, hass: HomeAssistant, source: str, - options: dict[str, str], + pyav_options: dict[str, str], + stream_settings: StreamSettings, stream_label: str | None = None, ) -> None: """Initialize a stream.""" self.hass = hass self.source = source - self.options = options + self.pyav_options = pyav_options + self._stream_settings = stream_settings self._stream_label = stream_label self.keepalive = False self.access_token: str | None = None @@ -284,7 +319,9 @@ class Stream: self.check_idle() provider = PROVIDERS[fmt]( - self.hass, IdleTimer(self.hass, timeout, idle_callback) + self.hass, + IdleTimer(self.hass, timeout, idle_callback), + self._stream_settings, ) self._outputs[fmt] = provider @@ -368,7 +405,8 @@ class Stream: try: stream_worker( self.source, - self.options, + self.pyav_options, + self._stream_settings, stream_state, self._keyframe_converter, self._thread_quit, @@ -507,22 +545,6 @@ STREAM_OPTIONS_SCHEMA: Final = vol.Schema( { vol.Optional(CONF_RTSP_TRANSPORT): vol.In(RTSP_TRANSPORTS), vol.Optional(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): bool, + vol.Optional(CONF_EXTRA_PART_WAIT_TIME): cv.positive_float, } ) - - -def convert_stream_options(stream_options: dict[str, str | bool]) -> dict[str, str]: - """Convert options from stream options into PyAV options.""" - pyav_options: dict[str, str] = {} - try: - STREAM_OPTIONS_SCHEMA(stream_options) - except vol.Invalid as exc: - raise HomeAssistantError("Invalid stream options") from exc - - if rtsp_transport := stream_options.get(CONF_RTSP_TRANSPORT): - assert isinstance(rtsp_transport, str) - pyav_options["rtsp_transport"] = rtsp_transport - if stream_options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): - pyav_options["use_wallclock_as_timestamps"] = "1" - - return pyav_options diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py index f8c9ba85d59..35af633435e 100644 --- a/homeassistant/components/stream/const.py +++ b/homeassistant/components/stream/const.py @@ -53,3 +53,4 @@ RTSP_TRANSPORTS = { "http": "HTTP", } CONF_USE_WALLCLOCK_AS_TIMESTAMPS = "use_wallclock_as_timestamps" +CONF_EXTRA_PART_WAIT_TIME = "extra_part_wait_time" diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index da18a5a6a08..c8d831157a8 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -118,6 +118,10 @@ class Segment: if self.hls_playlist_complete: return self.hls_playlist_template[0] if not self.hls_playlist_template: + # Logically EXT-X-DISCONTINUITY makes sense above the parts, but Apple's + # media stream validator seems to only want it before the segment + if last_stream_id != self.stream_id: + self.hls_playlist_template.append("#EXT-X-DISCONTINUITY") # This is a placeholder where the rendered parts will be inserted self.hls_playlist_template.append("{}") if render_parts: @@ -133,22 +137,19 @@ class Segment: # the first element to avoid an extra newline when we don't render any parts. # Append an empty string to create a trailing newline when we do render parts self.hls_playlist_parts.append("") - self.hls_playlist_template = [] - # Logically EXT-X-DISCONTINUITY would make sense above the parts, but Apple's - # media stream validator seems to only want it before the segment - if last_stream_id != self.stream_id: - self.hls_playlist_template.append("#EXT-X-DISCONTINUITY") + self.hls_playlist_template = ( + [] if last_stream_id == self.stream_id else ["#EXT-X-DISCONTINUITY"] + ) # Add the remaining segment metadata + # The placeholder goes on the same line as the next element self.hls_playlist_template.extend( [ - "#EXT-X-PROGRAM-DATE-TIME:" + "{}#EXT-X-PROGRAM-DATE-TIME:" + self.start_time.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z", f"#EXTINF:{self.duration:.3f},\n./segment/{self.sequence}.m4s", ] ) - # The placeholder now goes on the same line as the first element - self.hls_playlist_template[0] = "{}" + self.hls_playlist_template[0] # Store intermediate playlist data in member variables for reuse self.hls_playlist_template = ["\n".join(self.hls_playlist_template)] @@ -237,11 +238,13 @@ class StreamOutput: self, hass: HomeAssistant, idle_timer: IdleTimer, + stream_settings: StreamSettings, deque_maxlen: int | None = None, ) -> None: """Initialize a stream output.""" self._hass = hass self.idle_timer = idle_timer + self.stream_settings = stream_settings self._event = asyncio.Event() self._part_event = asyncio.Event() self._segments: deque[Segment] = deque(maxlen=deque_maxlen) diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 8e78093d07a..efecdcbe9dc 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -9,8 +9,6 @@ from aiohttp import web from homeassistant.core import HomeAssistant, callback from .const import ( - ATTR_SETTINGS, - DOMAIN, EXT_X_START_LL_HLS, EXT_X_START_NON_LL_HLS, FORMAT_CONTENT_TYPE, @@ -47,11 +45,15 @@ def async_setup_hls(hass: HomeAssistant) -> str: class HlsStreamOutput(StreamOutput): """Represents HLS Output formats.""" - def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: + def __init__( + self, + hass: HomeAssistant, + idle_timer: IdleTimer, + stream_settings: StreamSettings, + ) -> None: """Initialize HLS output.""" - super().__init__(hass, idle_timer, deque_maxlen=MAX_SEGMENTS) - self.stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] - self._target_duration = self.stream_settings.min_segment_duration + super().__init__(hass, idle_timer, stream_settings, deque_maxlen=MAX_SEGMENTS) + self._target_duration = stream_settings.min_segment_duration @property def name(self) -> str: @@ -78,14 +80,20 @@ class HlsStreamOutput(StreamOutput): ) def discontinuity(self) -> None: - """Remove incomplete segment from deque.""" + """Fix incomplete segment at end of deque.""" self._hass.loop.call_soon_threadsafe(self._async_discontinuity) @callback def _async_discontinuity(self) -> None: - """Remove incomplete segment from deque in event loop.""" - if self._segments and not self._segments[-1].complete: - self._segments.pop() + """Fix incomplete segment at end of deque in event loop.""" + # Fill in the segment duration or delete the segment if empty + if self._segments: + if (last_segment := self._segments[-1]).parts: + last_segment.duration = sum( + part.duration for part in last_segment.parts + ) + else: + self._segments.pop() class HlsMasterPlaylistView(StreamView): diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index ae1c64396c8..4d97c0d683d 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -17,7 +17,7 @@ from .const import ( RECORDER_PROVIDER, SEGMENT_CONTAINER_FORMAT, ) -from .core import PROVIDERS, IdleTimer, Segment, StreamOutput +from .core import PROVIDERS, IdleTimer, Segment, StreamOutput, StreamSettings _LOGGER = logging.getLogger(__name__) @@ -121,9 +121,14 @@ def recorder_save_worker(file_out: str, segments: deque[Segment]) -> None: class RecorderOutput(StreamOutput): """Represents HLS Output formats.""" - def __init__(self, hass: HomeAssistant, idle_timer: IdleTimer) -> None: + def __init__( + self, + hass: HomeAssistant, + idle_timer: IdleTimer, + stream_settings: StreamSettings, + ) -> None: """Initialize recorder output.""" - super().__init__(hass, idle_timer) + super().__init__(hass, idle_timer, stream_settings) self.video_path: str @property diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index f8d12c1cb44..4cfe8864de0 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -16,9 +16,7 @@ from homeassistant.core import HomeAssistant from . import redact_credentials from .const import ( - ATTR_SETTINGS, AUDIO_CODECS, - DOMAIN, HLS_PROVIDER, MAX_MISSING_DTS, MAX_TIMESTAMP_GAP, @@ -87,7 +85,7 @@ class StreamState: # simple to check for discontinuity at output time, and to determine # the discontinuity sequence number. self._stream_id += 1 - # Call discontinuity to remove incomplete segment from the HLS output + # Call discontinuity to fix incomplete segment in HLS output if hls_output := self._outputs_callback().get(HLS_PROVIDER): cast(HlsStreamOutput, hls_output).discontinuity() @@ -111,6 +109,7 @@ class StreamMuxer: video_stream: av.video.VideoStream, audio_stream: av.audio.stream.AudioStream | None, stream_state: StreamState, + stream_settings: StreamSettings, ) -> None: """Initialize StreamMuxer.""" self._hass = hass @@ -126,7 +125,7 @@ class StreamMuxer: self._memory_file_pos: int = cast(int, None) self._part_start_dts: int = cast(int, None) self._part_has_keyframe = False - self._stream_settings: StreamSettings = hass.data[DOMAIN][ATTR_SETTINGS] + self._stream_settings = stream_settings self._stream_state = stream_state self._start_time = datetime.datetime.utcnow() @@ -445,19 +444,20 @@ def unsupported_audio(packets: Iterator[av.Packet], audio_stream: Any) -> bool: def stream_worker( source: str, - options: dict[str, str], + pyav_options: dict[str, str], + stream_settings: StreamSettings, stream_state: StreamState, keyframe_converter: KeyFrameConverter, quit_event: Event, ) -> None: """Handle consuming streams.""" - if av.library_versions["libavformat"][0] >= 59 and "stimeout" in options: + if av.library_versions["libavformat"][0] >= 59 and "stimeout" in pyav_options: # the stimeout option was renamed to timeout as of ffmpeg 5.0 - options["timeout"] = options["stimeout"] - del options["stimeout"] + pyav_options["timeout"] = pyav_options["stimeout"] + del pyav_options["stimeout"] try: - container = av.open(source, options=options, timeout=SOURCE_TIMEOUT) + container = av.open(source, options=pyav_options, timeout=SOURCE_TIMEOUT) except av.AVError as err: raise StreamWorkerError( f"Error opening stream ({err.type}, {err.strerror}) {redact_credentials(str(source))}" @@ -480,6 +480,9 @@ def stream_worker( # Some audio streams do not have a profile and throw errors when remuxing if audio_stream and audio_stream.profile is None: audio_stream = None + # Disable ll-hls for hls inputs + if container.format.name == "hls": + stream_settings.ll_hls = False stream_state.diagnostics.set_value("container_format", container.format.name) stream_state.diagnostics.set_value("video_codec", video_stream.name) if audio_stream: @@ -535,7 +538,9 @@ def stream_worker( "Error demuxing stream while finding first packet: %s" % str(ex) ) from ex - muxer = StreamMuxer(stream_state.hass, video_stream, audio_stream, stream_state) + muxer = StreamMuxer( + stream_state.hass, video_stream, audio_stream, stream_state, stream_settings + ) muxer.reset(start_dts) # Mux the first keyframe, then proceed through the rest of the packets diff --git a/tests/components/stream/test_ll_hls.py b/tests/components/stream/test_ll_hls.py index 4aaec93d646..447b9ff58e9 100644 --- a/tests/components/stream/test_ll_hls.py +++ b/tests/components/stream/test_ll_hls.py @@ -91,12 +91,12 @@ def make_segment_with_parts( ): """Create a playlist response for a segment including part segments.""" response = [] + if discontinuity: + response.append("#EXT-X-DISCONTINUITY") for i in range(num_parts): response.append( f'#EXT-X-PART:DURATION={TEST_PART_DURATION:.3f},URI="./segment/{segment}.{i}.m4s"{",INDEPENDENT=YES" if i%independent_period==0 else ""}' ) - if discontinuity: - response.append("#EXT-X-DISCONTINUITY") response.extend( [ "#EXT-X-PROGRAM-DATE-TIME:" diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index a70f2be81b8..298d7287e69 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -268,17 +268,24 @@ class MockPyAv: return self.container -def run_worker(hass, stream, stream_source): +def run_worker(hass, stream, stream_source, stream_settings=None): """Run the stream worker under test.""" stream_state = StreamState(hass, stream.outputs, stream._diagnostics) stream_worker( - stream_source, {}, stream_state, KeyFrameConverter(hass), threading.Event() + stream_source, + {}, + stream_settings or hass.data[DOMAIN][ATTR_SETTINGS], + stream_state, + KeyFrameConverter(hass), + threading.Event(), ) -async def async_decode_stream(hass, packets, py_av=None): +async def async_decode_stream(hass, packets, py_av=None, stream_settings=None): """Start a stream worker that decodes incoming stream packets into output segments.""" - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream( + hass, STREAM_SOURCE, {}, stream_settings or hass.data[DOMAIN][ATTR_SETTINGS] + ) stream.add_provider(HLS_PROVIDER) if not py_av: @@ -290,7 +297,7 @@ async def async_decode_stream(hass, packets, py_av=None): side_effect=py_av.capture_buffer.capture_output_segment, ): try: - run_worker(hass, stream, STREAM_SOURCE) + run_worker(hass, stream, STREAM_SOURCE, stream_settings) except StreamEndedError: # Tests only use a limited number of packets, then the worker exits as expected. In # production, stream ending would be unexpected. @@ -304,7 +311,7 @@ async def async_decode_stream(hass, packets, py_av=None): async def test_stream_open_fails(hass): """Test failure on stream open.""" - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS]) stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open, pytest.raises(StreamWorkerError): av_open.side_effect = av.error.InvalidDataError(-2, "error") @@ -637,7 +644,7 @@ async def test_stream_stopped_while_decoding(hass): worker_open = threading.Event() worker_wake = threading.Event() - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS]) stream.add_provider(HLS_PROVIDER) py_av = MockPyAv() @@ -667,7 +674,7 @@ async def test_update_stream_source(hass): worker_open = threading.Event() worker_wake = threading.Event() - stream = Stream(hass, STREAM_SOURCE, {}) + stream = Stream(hass, STREAM_SOURCE, {}, hass.data[DOMAIN][ATTR_SETTINGS]) stream.add_provider(HLS_PROVIDER) # Note that retries are disabled by default in tests, however the stream is "restarted" when # the stream source is updated. @@ -709,7 +716,9 @@ async def test_update_stream_source(hass): async def test_worker_log(hass, caplog): """Test that the worker logs the url without username and password.""" - stream = Stream(hass, "https://abcd:efgh@foo.bar", {}) + stream = Stream( + hass, "https://abcd:efgh@foo.bar", {}, hass.data[DOMAIN][ATTR_SETTINGS] + ) stream.add_provider(HLS_PROVIDER) with patch("av.open") as av_open, pytest.raises(StreamWorkerError) as err: @@ -906,3 +915,24 @@ async def test_get_image(hass, record_worker_sync): assert await stream.async_get_image() == EMPTY_8_6_JPEG await stream.stop() + + +async def test_worker_disable_ll_hls(hass): + """Test that the worker disables ll-hls for hls inputs.""" + stream_settings = StreamSettings( + ll_hls=True, + min_segment_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS + - SEGMENT_DURATION_ADJUSTER, + part_target_duration=TARGET_SEGMENT_DURATION_NON_LL_HLS, + hls_advance_part_limit=3, + hls_part_timeout=TARGET_SEGMENT_DURATION_NON_LL_HLS, + ) + py_av = MockPyAv() + py_av.container.format.name = "hls" + await async_decode_stream( + hass, + PacketSequence(TEST_SEQUENCE_LENGTH), + py_av=py_av, + stream_settings=stream_settings, + ) + assert stream_settings.ll_hls is False From 187d56b88ba5a5f5656937d6c6a687e2de96d600 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 16 Jun 2022 20:12:30 +0200 Subject: [PATCH 460/947] Add ability to run plugin on unannotated functions (#73520) * Add ability to run plugin on unannotated functions * Use options * Adjust help text * Add test for the option --- pylint/plugins/hass_enforce_type_hints.py | 19 ++++++++++-- tests/pylint/test_enforce_type_hints.py | 37 ++++++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 8194bb72ca5..fc7be8ba8e8 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -588,7 +588,18 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] "Used when method return type is incorrect", ), } - options = () + options = ( + ( + "ignore-missing-annotations", + { + "default": True, + "type": "yn", + "metavar": "", + "help": "Set to ``no`` if you wish to check functions that do not " + "have any type hints.", + }, + ), + ) def __init__(self, linter: PyLinter | None = None) -> None: super().__init__(linter) @@ -641,7 +652,11 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] def _check_function(self, node: nodes.FunctionDef, match: TypeHintMatch) -> None: # Check that at least one argument is annotated. annotations = _get_all_annotations(node) - if node.returns is None and not _has_valid_annotations(annotations): + if ( + self.linter.config.ignore_missing_annotations + and node.returns is None + and not _has_valid_annotations(annotations) + ): return # Check that all arguments are correctly annotated. diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 86b06a894d0..c07014add7f 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -116,7 +116,7 @@ def test_regex_a_or_b( """ ], ) -def test_ignore_not_annotations( +def test_ignore_no_annotations( hass_enforce_type_hints: ModuleType, type_hint_checker: BaseChecker, code: str ) -> None: """Ensure that _is_valid_type is not run if there are no annotations.""" @@ -133,6 +133,41 @@ def test_ignore_not_annotations( is_valid_type.assert_not_called() +@pytest.mark.parametrize( + "code", + [ + """ + async def setup( #@ + arg1, arg2 + ): + pass + """ + ], +) +def test_bypass_ignore_no_annotations( + hass_enforce_type_hints: ModuleType, type_hint_checker: BaseChecker, code: str +) -> None: + """Test `ignore-missing-annotations` option. + + Ensure that `_is_valid_type` is run if there are no annotations + but `ignore-missing-annotations` option is forced to False. + """ + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + func_node = astroid.extract_node( + code, + "homeassistant.components.pylint_test", + ) + type_hint_checker.visit_module(func_node.parent) + + with patch.object( + hass_enforce_type_hints, "_is_valid_type", return_value=True + ) as is_valid_type: + type_hint_checker.visit_asyncfunctiondef(func_node) + is_valid_type.assert_called() + + @pytest.mark.parametrize( "code", [ From ea716307689310322dd42038b065bcba39f34a9d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 16 Jun 2022 22:19:47 +0200 Subject: [PATCH 461/947] Musllinux legacy resolver & cargo git (#73614) --- .github/workflows/wheels.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index c499f7ee1a4..ef037ef9fb8 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -47,6 +47,9 @@ jobs: # execinfo-dev when building wheels. The setuptools build setup does not have an option for # adding a single LDFLAG so copy all relevant linux flags here (as of 1.43.0) echo "GRPC_PYTHON_LDFLAGS=-lpthread -Wl,-wrap,memcpy -static-libgcc -lexecinfo" + + # Fix out of memory issues with rust + echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" ) > .env_file - name: Upload env_file @@ -137,7 +140,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.1 + uses: home-assistant/wheels@2022.06.2 with: abi: cp310 tag: musllinux_1_2 @@ -270,7 +273,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.1 + uses: home-assistant/wheels@2022.06.2 with: abi: cp310 tag: musllinux_1_2 @@ -279,6 +282,7 @@ jobs: env-file: true apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev" skip-binary: aiohttp;grpcio + legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txt" From 1c6337d548fb54b07245c3832dad3043c97a94bf Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 16 Jun 2022 22:47:02 +0200 Subject: [PATCH 462/947] Update wheels builder to 2022.06.3 (#73615) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index ef037ef9fb8..0c14459b487 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -140,7 +140,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.2 + uses: home-assistant/wheels@2022.06.3 with: abi: cp310 tag: musllinux_1_2 @@ -273,7 +273,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.2 + uses: home-assistant/wheels@2022.06.3 with: abi: cp310 tag: musllinux_1_2 From d43178db06cbaa84d88c1da183e0e04af38aee97 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 17 Jun 2022 00:20:34 +0000 Subject: [PATCH 463/947] [ci skip] Translation update --- .../eight_sleep/translations/it.json | 19 ++++++++++++ .../components/google/translations/it.json | 3 ++ .../components/nest/translations/ca.json | 9 ++++++ .../components/nest/translations/de.json | 2 +- .../components/nest/translations/fr.json | 23 +++++++++++++- .../components/nest/translations/id.json | 9 ++++++ .../components/nest/translations/it.json | 31 ++++++++++++++++++- 7 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/eight_sleep/translations/it.json diff --git a/homeassistant/components/eight_sleep/translations/it.json b/homeassistant/components/eight_sleep/translations/it.json new file mode 100644 index 00000000000..c849a5f3504 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi al cloud Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "Impossibile connettersi al cloud Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/it.json b/homeassistant/components/google/translations/it.json index d57998894d9..a0f9d140329 100644 --- a/homeassistant/components/google/translations/it.json +++ b/homeassistant/components/google/translations/it.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Segui le [istruzioni]({more_info_url}) per la [Schermata di consenso OAuth]({oauth_consent_url}) per consentire ad Home Assistant di accedere al tuo Google Calendar. Devi anche creare le credenziali dell'applicazione collegate al tuo calendario:\n 1. Vai a [Credenziali]({oauth_creds_url}) e fai clic su **Crea credenziali**.\n 2. Dall'elenco a discesa selezionare **ID client OAuth**.\n 3. Selezionare **TV e dispositivi con ingresso limitato** come Tipo di applicazione. \n\n" + }, "config": { "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index c8d6238e742..c7b617f01d6 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -38,6 +38,15 @@ "create_cloud_project": { "title": "Nest: crea i configura el projecte Cloud" }, + "device_project": { + "data": { + "project_id": "ID de projecte Device Access" + }, + "title": "Nest: crea un projecte Device Access" + }, + "device_project_upgrade": { + "title": "Nest: actualitza projecte Device Access" + }, "init": { "data": { "flow_impl": "Prove\u00efdor" diff --git a/homeassistant/components/nest/translations/de.json b/homeassistant/components/nest/translations/de.json index bee1bb547f2..659755fc6b4 100644 --- a/homeassistant/components/nest/translations/de.json +++ b/homeassistant/components/nest/translations/de.json @@ -22,7 +22,7 @@ "subscriber_error": "Unbekannter Abonnentenfehler, siehe Protokolle", "timeout": "Ein zeit\u00fcberschreitungs Fehler ist aufgetreten", "unknown": "Unerwarteter Fehler", - "wrong_project_id": "Bitte gib eine g\u00fcltige Cloud-Projekt-ID ein (gefundene Ger\u00e4tezugriffs-Projekt-ID)" + "wrong_project_id": "Gib eine g\u00fcltige Cloud-Projekt-ID ein (identisch mit der Projekt-ID f\u00fcr den Ger\u00e4tezugriff)." }, "step": { "auth": { diff --git a/homeassistant/components/nest/translations/fr.json b/homeassistant/components/nest/translations/fr.json index d639009dff8..30adccca440 100644 --- a/homeassistant/components/nest/translations/fr.json +++ b/homeassistant/components/nest/translations/fr.json @@ -19,7 +19,7 @@ "subscriber_error": "Erreur d'abonn\u00e9 inconnue, voir les journaux", "timeout": "D\u00e9lai de la validation du code expir\u00e9", "unknown": "Erreur inattendue", - "wrong_project_id": "Veuillez saisir un ID de projet Cloud valide (ID de projet d'acc\u00e8s \u00e0 l'appareil trouv\u00e9)" + "wrong_project_id": "Veuillez saisir un ID de projet Cloud valide (\u00e9tait identique \u00e0 l'ID de projet d'acc\u00e8s \u00e0 l'appareil)" }, "step": { "auth": { @@ -29,6 +29,27 @@ "description": "Pour lier votre compte Google, [autorisez votre compte]( {url} ). \n\n Apr\u00e8s autorisation, copiez-collez le code d'authentification fourni ci-dessous.", "title": "Associer un compte Google" }, + "auth_upgrade": { + "title": "Nest\u00a0: abandon de l'authentification d'application" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID de projet Google\u00a0Cloud" + }, + "title": "Nest\u00a0: saisissez l'ID du projet Cloud" + }, + "create_cloud_project": { + "title": "Nest\u00a0: cr\u00e9er et configurer un projet Cloud" + }, + "device_project": { + "data": { + "project_id": "ID de projet d'acc\u00e8s \u00e0 l'appareil" + }, + "title": "Nest\u00a0: cr\u00e9er un projet d'acc\u00e8s \u00e0 l'appareil" + }, + "device_project_upgrade": { + "title": "Nest\u00a0: mettre \u00e0 jour le projet d'acc\u00e8s \u00e0 l'appareil" + }, "init": { "data": { "flow_impl": "Fournisseur" diff --git a/homeassistant/components/nest/translations/id.json b/homeassistant/components/nest/translations/id.json index cc7cdc600a8..ab7aaa2d459 100644 --- a/homeassistant/components/nest/translations/id.json +++ b/homeassistant/components/nest/translations/id.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Ikuti [petunjuk]({more_info_url}) untuk mengonfigurasi Konsol Cloud:\n\n1. Buka [Layar persetujuan OAuth]({oauth_consent_url}) dan konfigurasikan\n1. Buka [Kredentsial]({oauth_creds_url}) dan klik **Buat Kredensial**.\n1. Dari daftar pilihan, pilih **ID klien OAuth**.\n1. Pilih **Aplikasi Web** untuk Jenis Aplikasi.\n1. Tambahkan '{redirect_url}' di bawah *URI pengarahan ulang yand diotorisasi*." + }, "config": { "abort": { "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", @@ -29,6 +32,12 @@ "description": "Untuk menautkan akun Google Anda, [otorisasi akun Anda]({url}).\n\nSetelah otorisasi, salin dan tempel Token Auth yang disediakan di bawah ini.", "title": "Tautkan Akun Google" }, + "cloud_project": { + "title": "Nest: Masukkan ID Proyek Cloud" + }, + "device_project_upgrade": { + "title": "Nest: Perbarui Proyek Akses Perangkat" + }, "init": { "data": { "flow_impl": "Penyedia" diff --git a/homeassistant/components/nest/translations/it.json b/homeassistant/components/nest/translations/it.json index 5942414bcf3..8fd83483290 100644 --- a/homeassistant/components/nest/translations/it.json +++ b/homeassistant/components/nest/translations/it.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Segui le [istruzioni]({more_info_url}) per configurare la Cloud Console: \n\n 1. Vai alla [schermata di consenso OAuth]({oauth_consent_url}) e configura\n 2. Vai a [Credenziali]({oauth_creds_url}) e fai clic su **Crea credenziali**.\n 3. Dall'elenco a discesa selezionare **ID client OAuth**.\n 4. Selezionare **Applicazione Web** come Tipo di applicazione.\n 5. Aggiungi `{redirect_url}` sotto *URI di reindirizzamento autorizzato*." + }, "config": { "abort": { "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.", @@ -19,7 +22,7 @@ "subscriber_error": "Errore di abbonato sconosciuto, vedere i registri", "timeout": "Tempo scaduto per l'inserimento del codice di convalida", "unknown": "Errore imprevisto", - "wrong_project_id": "Inserisci un ID di progetto Cloud valido (trovato ID di progetto di accesso al dispositivo)" + "wrong_project_id": "Inserisci un ID progetto cloud valido (uguale all'ID progetto di accesso al dispositivo)" }, "step": { "auth": { @@ -29,6 +32,32 @@ "description": "Per collegare l'account Google, [authorize your account]({url}).\n\nDopo l'autorizzazione, copia-incolla il codice PIN fornito.", "title": "Connetti l'account Google" }, + "auth_upgrade": { + "description": "App Auth \u00e8 stato ritirato da Google per migliorare la sicurezza e devi agire creando nuove credenziali per l'applicazione. \n\nApri la [documentazione]({more_info_url}) per seguire i passaggi successivi che ti guideranno attraverso i passaggi necessari per ripristinare l'accesso ai tuoi dispositivi Nest.", + "title": "Nest: ritiro dell'autenticazione dell'app" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID progetto Google Cloud" + }, + "description": "Immetti l'ID progetto cloud di seguito, ad esempio *example-project-12345*. Consulta la [Google Cloud Console]({cloud_console_url}) o la documentazione per [maggiori informazioni]({more_info_url}).", + "title": "Nest: inserisci l'ID del progetto Cloud" + }, + "create_cloud_project": { + "description": "L'integrazione Nest ti consente di integrare i tuoi termostati, videocamere e campanelli Nest utilizzando l'API Smart Device Management. L'API SDM **richiede una tariffa di configurazione una tantum di US $ 5**. Consulta la documentazione per [maggiori informazioni]({more_info_url}). \n\n 1. Vai a [Google Cloud Console]( {cloud_console_url} ).\n 2. Se questo \u00e8 il tuo primo progetto, fai clic su **Crea progetto** e poi su **Nuovo progetto**.\n 3. Assegna un nome al tuo progetto cloud, quindi fai clic su **Crea**.\n 4. Salva l'ID del progetto cloud, ad es. *example-project-12345*, poich\u00e9 ti servir\u00e0 in seguito\n 5. Vai a Libreria API per [API Smart Device Management]({sdm_api_url}) e fai clic su **Abilita**.\n 6. Vai a Libreria API per [Cloud Pub/Sub API]({pubsub_api_url}) e fai clic su **Abilita**. \n\n Procedi quando il tuo progetto cloud \u00e8 impostato.", + "title": "Nest: crea e configura un progetto cloud" + }, + "device_project": { + "data": { + "project_id": "ID progetto di accesso al dispositivo" + }, + "description": "Crea un progetto di accesso al dispositivo Nest la cui configurazione **richiede una commissione di $ 5 USD**.\n 1. Vai alla [Console di accesso al dispositivo]({device_access_console_url}) e attraverso il flusso di pagamento.\n 2. Clicca su **Crea progetto**\n 3. Assegna un nome al progetto di accesso al dispositivo e fai clic su **Avanti**.\n 4. Inserisci il tuo ID cliente OAuth\n 5. Abilita gli eventi facendo clic su **Abilita** e **Crea progetto**. \n\n Inserisci il tuo ID progetto di accesso al dispositivo di seguito ([maggiori informazioni]({more_info_url})).\n", + "title": "Nest: crea un progetto di accesso al dispositivo" + }, + "device_project_upgrade": { + "description": "Aggiorna il progetto di accesso al dispositivo Nest con il nuovo ID client OAuth ([ulteriori informazioni]({more_info_url}))\n1. Vai a [Console di accesso al dispositivo]({device_access_console_url}).\n2. Fai clic sull'icona del cestino accanto a *OAuth Client ID*.\n3. Fai clic sul menu di overflow '...' e *Aggiungi ID client*.\n4. Immettere il nuovo ID client OAuth e fare clic su **Aggiungi**.\n\nIl tuo ID client OAuth \u00e8: '{client_id}'", + "title": "Nest: aggiorna il progetto di accesso al dispositivo" + }, "init": { "data": { "flow_impl": "Provider" From f276523ef32fc4fc03e8d36e3a73d5bebfad30ba Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Fri, 17 Jun 2022 11:07:08 +1000 Subject: [PATCH 464/947] Ignore in progress segment when adding stream recorder lookback (#73604) --- homeassistant/components/stream/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 19ef009a845..3766d981da5 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -510,7 +510,7 @@ class Stream: num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS) # Wait for latest segment, then add the lookback await hls.recv() - recorder.prepend(list(hls.get_segments())[-num_segments:]) + recorder.prepend(list(hls.get_segments())[-num_segments - 1 : -1]) async def async_get_image( self, From cdd5a5f68b3293c295c2230ac25676f51c5aed53 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Fri, 17 Jun 2022 06:07:21 +0100 Subject: [PATCH 465/947] Generic ipcam configflow2 followup (#73511) * Address code review comments * Add type hints * Remvoe unused strings * Remove persistent notification setup * Patch async_configre * Fix pylint warning * Address review comments * Clean types * Code review: defer local var assignment Co-authored-by: Dave T Co-authored-by: Martin Hjelmare --- homeassistant/components/generic/camera.py | 26 ++++--- .../components/generic/config_flow.py | 44 +++++++----- homeassistant/components/generic/strings.json | 3 +- .../components/generic/translations/en.json | 7 -- tests/components/generic/conftest.py | 3 +- tests/components/generic/test_config_flow.py | 70 +++++++++++-------- 6 files changed, 89 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index edc51430f0d..5f1f9ba9c2c 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -1,7 +1,9 @@ """Support for IP Cameras.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any import httpx import voluptuous as vol @@ -115,12 +117,12 @@ async def async_setup_entry( ) -def generate_auth(device_info) -> httpx.Auth | None: +def generate_auth(device_info: Mapping[str, Any]) -> httpx.Auth | None: """Generate httpx.Auth object from credentials.""" - username = device_info.get(CONF_USERNAME) - password = device_info.get(CONF_PASSWORD) + username: str | None = device_info.get(CONF_USERNAME) + password: str | None = device_info.get(CONF_PASSWORD) authentication = device_info.get(CONF_AUTHENTICATION) - if username: + if username and password: if authentication == HTTP_DIGEST_AUTHENTICATION: return httpx.DigestAuth(username=username, password=password) return httpx.BasicAuth(username=username, password=password) @@ -130,7 +132,15 @@ def generate_auth(device_info) -> httpx.Auth | None: class GenericCamera(Camera): """A generic implementation of an IP camera.""" - def __init__(self, hass, device_info, identifier, title): + _last_image: bytes | None + + def __init__( + self, + hass: HomeAssistant, + device_info: Mapping[str, Any], + identifier: str, + title: str, + ) -> None: """Initialize a generic camera.""" super().__init__() self.hass = hass @@ -143,10 +153,10 @@ class GenericCamera(Camera): and self._still_image_url ): self._still_image_url = cv.template(self._still_image_url) - if self._still_image_url not in [None, ""]: + if self._still_image_url: self._still_image_url.hass = hass self._stream_source = device_info.get(CONF_STREAM_SOURCE) - if self._stream_source not in (None, ""): + if self._stream_source: if not isinstance(self._stream_source, template_helper.Template): self._stream_source = cv.template(self._stream_source) self._stream_source.hass = hass @@ -207,7 +217,7 @@ class GenericCamera(Camera): """Return the name of this device.""" return self._name - async def stream_source(self): + async def stream_source(self) -> str | None: """Return the source of the stream.""" if self._stream_source is None: return None diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 3cc78fca406..b6abdc5eec8 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -1,11 +1,11 @@ """Config flow for generic (IP Camera).""" from __future__ import annotations +from collections.abc import Mapping import contextlib from errno import EHOSTUNREACH, EIO import io import logging -from types import MappingProxyType from typing import Any import PIL @@ -32,6 +32,7 @@ from homeassistant.const import ( HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, ) +from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv, template as template_helper @@ -64,7 +65,7 @@ SUPPORTED_IMAGE_TYPES = {"png", "jpeg", "gif", "svg+xml", "webp"} def build_schema( - user_input: dict[str, Any] | MappingProxyType[str, Any], + user_input: Mapping[str, Any], is_options_flow: bool = False, show_advanced_options=False, ): @@ -119,7 +120,7 @@ def build_schema( return vol.Schema(spec) -def get_image_type(image): +def get_image_type(image: bytes) -> str | None: """Get the format of downloaded bytes that could be an image.""" fmt = None imagefile = io.BytesIO(image) @@ -135,7 +136,9 @@ def get_image_type(image): return fmt -async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]: +async def async_test_still( + hass: HomeAssistant, info: Mapping[str, Any] +) -> tuple[dict[str, str], str | None]: """Verify that the still image is valid before we create an entity.""" fmt = None if not (url := info.get(CONF_STILL_IMAGE_URL)): @@ -147,7 +150,7 @@ async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]: except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", url, err) return {CONF_STILL_IMAGE_URL: "template_error"}, None - verify_ssl = info.get(CONF_VERIFY_SSL) + verify_ssl = info[CONF_VERIFY_SSL] auth = generate_auth(info) try: async_client = get_async_client(hass, verify_ssl=verify_ssl) @@ -177,7 +180,9 @@ async def async_test_still(hass, info) -> tuple[dict[str, str], str | None]: return {}, f"image/{fmt}" -def slug(hass, template) -> str | None: +def slug( + hass: HomeAssistant, template: str | template_helper.Template | None +) -> str | None: """Convert a camera url into a string suitable for a camera name.""" if not template: return None @@ -193,7 +198,9 @@ def slug(hass, template) -> str | None: return None -async def async_test_stream(hass, info) -> dict[str, str]: +async def async_test_stream( + hass: HomeAssistant, info: Mapping[str, Any] +) -> dict[str, str]: """Verify that the stream is valid before we create an entity.""" if not (stream_source := info.get(CONF_STREAM_SOURCE)): return {} @@ -240,7 +247,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize Generic ConfigFlow.""" self.cached_user_input: dict[str, Any] = {} self.cached_title = "" @@ -252,7 +259,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return GenericOptionsFlowHandler(config_entry) - def check_for_existing(self, options): + def check_for_existing(self, options: dict[str, Any]) -> bool: """Check whether an existing entry is using the same URLs.""" return any( entry.options.get(CONF_STILL_IMAGE_URL) == options.get(CONF_STILL_IMAGE_URL) @@ -273,14 +280,16 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): ): errors["base"] = "no_still_image_or_stream_url" else: - errors, still_format = await async_test_still(self.hass, user_input) - errors = errors | await async_test_stream(self.hass, user_input) - still_url = user_input.get(CONF_STILL_IMAGE_URL) - stream_url = user_input.get(CONF_STREAM_SOURCE) - name = slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME + errors, still_format = await async_test_still(hass, user_input) + errors = errors | await async_test_stream(hass, user_input) if not errors: user_input[CONF_CONTENT_TYPE] = still_format user_input[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False + still_url = user_input.get(CONF_STILL_IMAGE_URL) + stream_url = user_input.get(CONF_STREAM_SOURCE) + name = ( + slug(hass, still_url) or slug(hass, stream_url) or DEFAULT_NAME + ) if still_url is None: # If user didn't specify a still image URL, # The automatically generated still image that stream generates @@ -299,7 +308,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, import_config) -> FlowResult: + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: """Handle config import from yaml.""" # abort if we've already got this one. if self.check_for_existing(import_config): @@ -311,6 +320,7 @@ class GenericIPCamConfigFlow(ConfigFlow, domain=DOMAIN): CONF_NAME, slug(self.hass, still_url) or slug(self.hass, stream_url) or DEFAULT_NAME, ) + if CONF_LIMIT_REFETCH_TO_URL_CHANGE not in import_config: import_config[CONF_LIMIT_REFETCH_TO_URL_CHANGE] = False still_format = import_config.get(CONF_CONTENT_TYPE, "image/jpeg") @@ -336,9 +346,9 @@ class GenericOptionsFlowHandler(OptionsFlow): if user_input is not None: errors, still_format = await async_test_still( - self.hass, self.config_entry.options | user_input + hass, self.config_entry.options | user_input ) - errors = errors | await async_test_stream(self.hass, user_input) + errors = errors | await async_test_stream(hass, user_input) still_url = user_input.get(CONF_STILL_IMAGE_URL) stream_url = user_input.get(CONF_STREAM_SOURCE) if not errors: diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 7cb1135fe56..7d3cab19aa5 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -15,8 +15,7 @@ "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", - "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" }, "step": { "user": { diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index d01e6e59a4b..4b10ed2d8ac 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -1,7 +1,6 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { @@ -12,9 +11,7 @@ "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", @@ -51,13 +48,9 @@ "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", - "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", - "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", diff --git a/tests/components/generic/conftest.py b/tests/components/generic/conftest.py index 266b848ebe2..808e858b259 100644 --- a/tests/components/generic/conftest.py +++ b/tests/components/generic/conftest.py @@ -7,7 +7,7 @@ from PIL import Image import pytest import respx -from homeassistant import config_entries, setup +from homeassistant import config_entries from homeassistant.components.generic.const import DOMAIN from tests.common import MockConfigEntry @@ -79,7 +79,6 @@ def mock_create_stream(): async def user_flow(hass): """Initiate a user flow.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index ee12056b191..2979513e5c0 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -8,7 +8,7 @@ import httpx import pytest import respx -from homeassistant import config_entries, data_entry_flow, setup +from homeassistant import config_entries, data_entry_flow from homeassistant.components.camera import async_get_image from homeassistant.components.generic.const import ( CONF_CONTENT_TYPE, @@ -60,7 +60,9 @@ TESTDATA_YAML = { async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): """Test the form with a normal set of settings.""" - with mock_create_stream as mock_setup: + with mock_create_stream as mock_setup, patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], TESTDATA, @@ -81,12 +83,12 @@ async def test_form(hass, fakeimg_png, user_flow, mock_create_stream): await hass.async_block_till_done() assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 @respx.mock async def test_form_only_stillimage(hass, fakeimg_png, user_flow): """Test we complete ok if the user wants still images only.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -95,10 +97,12 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "127_0_0_1" assert result2["options"] == { @@ -112,7 +116,6 @@ async def test_form_only_stillimage(hass, fakeimg_png, user_flow): CONF_VERIFY_SSL: False, } - await hass.async_block_till_done() assert respx.calls.call_count == 1 @@ -121,10 +124,12 @@ async def test_form_only_stillimage_gif(hass, fakeimg_gif, user_flow): """Test we complete ok if the user wants a gif.""" data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result2["options"][CONF_CONTENT_TYPE] == "image/gif" @@ -136,10 +141,12 @@ async def test_form_only_svg_whitespace(hass, fakeimgbytes_svg, user_flow): respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_wspace_svg) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -161,10 +168,12 @@ async def test_form_only_still_sample(hass, user_flow, image_file): respx.get("http://127.0.0.1/testurl/1").respond(stream=image.read()) data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -203,10 +212,12 @@ async def test_still_template( data = TESTDATA.copy() data.pop(CONF_STREAM_SOURCE) data[CONF_STILL_IMAGE_URL] = template - result2 = await hass.config_entries.flow.async_configure( - user_flow["flow_id"], - data, - ) + with patch("homeassistant.components.generic.async_setup_entry", return_value=True): + result2 = await hass.config_entries.flow.async_configure( + user_flow["flow_id"], + data, + ) + await hass.async_block_till_done() assert result2["type"] == expected_result @@ -216,7 +227,9 @@ async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): data = TESTDATA.copy() data[CONF_RTSP_TRANSPORT] = "tcp" data[CONF_STREAM_SOURCE] = "rtsp://127.0.0.1/testurl/2" - with mock_create_stream as mock_setup: + with mock_create_stream as mock_setup, patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ): result2 = await hass.config_entries.flow.async_configure( user_flow["flow_id"], data ) @@ -242,7 +255,6 @@ async def test_form_rtsp_mode(hass, fakeimg_png, user_flow, mock_create_stream): async def test_form_only_stream(hass, fakeimgbytes_jpg, mock_create_stream): """Test we complete ok if the user wants stream only.""" - await setup.async_setup_component(hass, "persistent_notification", {}) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -254,6 +266,7 @@ async def test_form_only_stream(hass, fakeimgbytes_jpg, mock_create_stream): result["flow_id"], data, ) + await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result3["title"] == "127_0_0_1" @@ -454,7 +467,6 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream """Test the options flow with a template error.""" respx.get("http://127.0.0.1/testurl/1").respond(stream=fakeimgbytes_png) respx.get("http://127.0.0.1/testurl/2").respond(stream=fakeimgbytes_png) - await setup.async_setup_component(hass, "persistent_notification", {}) mock_entry = MockConfigEntry( title="Test Camera", @@ -649,7 +661,9 @@ async def test_use_wallclock_as_timestamps_option( ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" - with mock_create_stream: + with patch( + "homeassistant.components.generic.async_setup_entry", return_value=True + ), mock_create_stream: result2 = await hass.config_entries.options.async_configure( result["flow_id"], user_input={CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, **TESTDATA}, From e30478457b4ac4eeb5abf54f44caa76985a9fc62 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Fri, 17 Jun 2022 07:40:02 +0200 Subject: [PATCH 466/947] Fix voltage and current values for Fritz!DECT smart plugs (#73608) fix voltage and current values --- homeassistant/components/fritzbox/sensor.py | 4 +- tests/components/fritzbox/test_switch.py | 65 +++++++++++++++------ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py index 2ae7f9dccc8..161dfc196d2 100644 --- a/homeassistant/components/fritzbox/sensor.py +++ b/homeassistant/components/fritzbox/sensor.py @@ -96,7 +96,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.VOLTAGE, state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=lambda device: device.voltage / 1000 + native_value=lambda device: device.voltage if getattr(device, "voltage", None) else 0.0, ), @@ -107,7 +107,7 @@ SENSOR_TYPES: Final[tuple[FritzSensorEntityDescription, ...]] = ( device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, suitable=lambda device: device.has_powermeter, # type: ignore[no-any-return] - native_value=lambda device: device.power / device.voltage + native_value=lambda device: device.power / device.voltage / 1000 if device.power and getattr(device, "voltage", None) else 0.0, ), diff --git a/tests/components/fritzbox/test_switch.py b/tests/components/fritzbox/test_switch.py index a3135dd61f3..75799a08d48 100644 --- a/tests/components/fritzbox/test_switch.py +++ b/tests/components/fritzbox/test_switch.py @@ -16,6 +16,8 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_DEVICES, + ELECTRIC_CURRENT_AMPERE, + ELECTRIC_POTENTIAL_VOLT, ENERGY_KILO_WATT_HOUR, POWER_WATT, SERVICE_TURN_OFF, @@ -48,29 +50,54 @@ async def test_setup(hass: HomeAssistant, fritz: Mock): assert state.attributes[ATTR_FRIENDLY_NAME] == CONF_FAKE_NAME assert ATTR_STATE_CLASS not in state.attributes - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature") - assert state - assert state.state == "1.23" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Temperature" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - state = hass.states.get(f"{ENTITY_ID}_humidity") assert state is None - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power_consumption") - assert state - assert state.state == "5.678" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Power Consumption" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == POWER_WATT - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + sensors = ( + [ + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_temperature", + "1.23", + f"{CONF_FAKE_NAME} Temperature", + TEMP_CELSIUS, + SensorStateClass.MEASUREMENT, + ], + [ + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_power_consumption", + "5.678", + f"{CONF_FAKE_NAME} Power Consumption", + POWER_WATT, + SensorStateClass.MEASUREMENT, + ], + [ + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_total_energy", + "1.234", + f"{CONF_FAKE_NAME} Total Energy", + ENERGY_KILO_WATT_HOUR, + SensorStateClass.TOTAL_INCREASING, + ], + [ + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_voltage", + "230", + f"{CONF_FAKE_NAME} Voltage", + ELECTRIC_POTENTIAL_VOLT, + SensorStateClass.MEASUREMENT, + ], + [ + f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_electric_current", + "0.0246869565217391", + f"{CONF_FAKE_NAME} Electric Current", + ELECTRIC_CURRENT_AMPERE, + SensorStateClass.MEASUREMENT, + ], + ) - state = hass.states.get(f"{SENSOR_DOMAIN}.{CONF_FAKE_NAME}_total_energy") - assert state - assert state.state == "1.234" - assert state.attributes[ATTR_FRIENDLY_NAME] == f"{CONF_FAKE_NAME} Total Energy" - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == ENERGY_KILO_WATT_HOUR - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING + for sensor in sensors: + state = hass.states.get(sensor[0]) + assert state + assert state.state == sensor[1] + assert state.attributes[ATTR_FRIENDLY_NAME] == sensor[2] + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == sensor[3] + assert state.attributes[ATTR_STATE_CLASS] == sensor[4] async def test_turn_on(hass: HomeAssistant, fritz: Mock): From ea21a36e52a3a157bf86c75de13c8e78ec006ea2 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Fri, 17 Jun 2022 00:04:41 -0700 Subject: [PATCH 467/947] Remove default use of google calendars yaml file in tests (#73621) Remove default use of google_calendars.yaml in tests --- tests/components/google/conftest.py | 2 +- tests/components/google/test_calendar.py | 19 ++++++++++++------- tests/components/google/test_init.py | 11 +++-------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 68176493445..27fb6c993ff 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -99,7 +99,7 @@ def calendars_config(calendars_config_entity: dict[str, Any]) -> list[dict[str, ] -@pytest.fixture(autouse=True) +@pytest.fixture def mock_calendars_yaml( hass: HomeAssistant, calendars_config: list[dict[str, Any]], diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 794065ca09a..85711014e72 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -17,13 +17,18 @@ from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.template import DATE_STR_FORMAT import homeassistant.util.dt as dt_util -from .conftest import CALENDAR_ID, TEST_YAML_ENTITY, TEST_YAML_ENTITY_NAME +from .conftest import ( + CALENDAR_ID, + TEST_API_ENTITY, + TEST_API_ENTITY_NAME, + TEST_YAML_ENTITY, +) from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMockResponse -TEST_ENTITY = TEST_YAML_ENTITY -TEST_ENTITY_NAME = TEST_YAML_ENTITY_NAME +TEST_ENTITY = TEST_API_ENTITY +TEST_ENTITY_NAME = TEST_API_ENTITY_NAME TEST_EVENT = { "summary": "Test All Day Event", @@ -58,7 +63,6 @@ TEST_EVENT = { @pytest.fixture(autouse=True) def mock_test_setup( hass, - mock_calendars_yaml, test_api_calendar, mock_calendars_list, config_entry, @@ -87,12 +91,12 @@ def upcoming_date() -> dict[str, Any]: } -def upcoming_event_url() -> str: +def upcoming_event_url(entity: str = TEST_ENTITY) -> str: """Return a calendar API to return events created by upcoming().""" now = dt_util.now() start = (now - datetime.timedelta(minutes=60)).isoformat() end = (now + datetime.timedelta(minutes=60)).isoformat() - return f"/api/calendars/{TEST_ENTITY}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" + return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" async def test_all_day_event( @@ -551,6 +555,7 @@ async def test_http_api_event_paging( async def test_opaque_event( hass, hass_client, + mock_calendars_yaml, mock_events_list_items, component_setup, transparency, @@ -566,7 +571,7 @@ async def test_opaque_event( assert await component_setup() client = await hass_client() - response = await client.get(upcoming_event_url()) + response = await client.get(upcoming_event_url(TEST_YAML_ENTITY)) assert response.status == HTTPStatus.OK events = await response.json() assert (len(events) > 0) == expect_visible_event diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index b2a81b47718..f9391a82b6a 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -74,7 +74,7 @@ def setup_config_entry( ( SERVICE_CREATE_EVENT, {}, - {"entity_id": TEST_YAML_ENTITY}, + {"entity_id": TEST_API_ENTITY}, ), ], ids=("add_event", "create_event"), @@ -245,13 +245,12 @@ async def test_found_calendar_from_api( @pytest.mark.parametrize( - "calendars_config,google_config,config_entry_options", - [([], {}, {CONF_CALENDAR_ACCESS: "read_write"})], + "google_config,config_entry_options", + [({}, {CONF_CALENDAR_ACCESS: "read_write"})], ) async def test_load_application_credentials( hass: HomeAssistant, component_setup: ComponentSetup, - mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, @@ -464,7 +463,6 @@ async def test_add_event_invalid_params( component_setup: ComponentSetup, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], - mock_calendars_yaml: None, mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, add_event_call_service: Callable[dict[str, Any], Awaitable[None]], @@ -504,7 +502,6 @@ async def test_add_event_date_in_x( mock_calendars_list: ApiResult, mock_insert_event: Callable[[..., dict[str, Any]], None], test_api_calendar: dict[str, Any], - mock_calendars_yaml: None, mock_events_list: ApiResult, date_fields: dict[str, Any], start_timedelta: datetime.timedelta, @@ -544,7 +541,6 @@ async def test_add_event_date( mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_insert_event: Callable[[str, dict[str, Any]], None], - mock_calendars_yaml: None, mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, @@ -586,7 +582,6 @@ async def test_add_event_date_time( mock_calendars_list: ApiResult, mock_insert_event: Callable[[str, dict[str, Any]], None], test_api_calendar: dict[str, Any], - mock_calendars_yaml: None, mock_events_list: ApiResult, setup_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, From 01ccf721e7a68c9dcbf7ad937960b54f47a17d11 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 17 Jun 2022 10:09:41 +0200 Subject: [PATCH 468/947] Update wheels builder to 2022.06.4 (#73628) --- .github/workflows/wheels.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 0c14459b487..473add9781e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -140,7 +140,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.3 + uses: home-assistant/wheels@2022.06.4 with: abi: cp310 tag: musllinux_1_2 @@ -273,14 +273,14 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.3 + uses: home-assistant/wheels@2022.06.4 with: abi: cp310 tag: musllinux_1_2 arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev" + apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran" skip-binary: aiohttp;grpcio legacy: true constraints: "homeassistant/package_constraints.txt" From 546d342604ca742a5fe75457356f50d88b32b7a3 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 17 Jun 2022 11:44:56 +0200 Subject: [PATCH 469/947] Update wheels builder to 2022.06.5 (#73633) --- .github/workflows/wheels.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 473add9781e..e86f8235582 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -140,7 +140,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.4 + uses: home-assistant/wheels@2022.06.5 with: abi: cp310 tag: musllinux_1_2 @@ -273,7 +273,7 @@ jobs: done - name: Build wheels - uses: home-assistant/wheels@2022.06.4 + uses: home-assistant/wheels@2022.06.5 with: abi: cp310 tag: musllinux_1_2 From e0b362ef3bb0f7550b5a2f912b8084ba0fb11789 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 17 Jun 2022 12:13:16 +0200 Subject: [PATCH 470/947] Fix zha log message (#73626) --- homeassistant/components/zha/core/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 642a5b3ec55..02b2b21c835 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -672,7 +672,7 @@ class ZHAGateway: async def async_remove_zigpy_group(self, group_id: int) -> None: """Remove a Zigbee group from Zigpy.""" if not (group := self.groups.get(group_id)): - _LOGGER.debug("Group: %s:0x%04x could not be found", group.name, group_id) + _LOGGER.debug("Group: 0x%04x could not be found", group_id) return if group.members: tasks = [] From d90f0297723720d72d685eba06daee735f29c32a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jun 2022 12:14:11 +0200 Subject: [PATCH 471/947] Finish migrating demo NumberEntity to native_value (#73581) --- homeassistant/components/demo/number.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/demo/number.py b/homeassistant/components/demo/number.py index 7a9baf045e5..02ab1a2a989 100644 --- a/homeassistant/components/demo/number.py +++ b/homeassistant/components/demo/number.py @@ -99,11 +99,11 @@ class DemoNumber(NumberEntity): self._attr_mode = mode if native_min_value is not None: - self._attr_min_value = native_min_value + self._attr_native_min_value = native_min_value if native_max_value is not None: - self._attr_max_value = native_max_value + self._attr_native_max_value = native_max_value if native_step is not None: - self._attr_step = native_step + self._attr_native_step = native_step self._attr_device_info = DeviceInfo( identifiers={ From 2107966fa882a4309d13f898b65146b7f468e7d8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jun 2022 12:14:35 +0200 Subject: [PATCH 472/947] Finish migrating sleepiq NumberEntity to native_value (#73582) --- homeassistant/components/sleepiq/number.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sleepiq/number.py b/homeassistant/components/sleepiq/number.py index a0ade257335..01ba90360ba 100644 --- a/homeassistant/components/sleepiq/number.py +++ b/homeassistant/components/sleepiq/number.py @@ -157,5 +157,5 @@ class SleepIQNumberEntity(SleepIQBedEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set the number value.""" await self.entity_description.set_value_fn(self.device, int(value)) - self._attr_value = value + self._attr_native_value = value self.async_write_ha_state() From 66feac2257c3dbc73f31de5ccbbc92896f81474e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jun 2022 12:15:59 +0200 Subject: [PATCH 473/947] Finish migrating zha NumberEntity to native_value (#73580) --- homeassistant/components/zha/number.py | 38 +++++++++++++------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 2f674df168e..9103dd2e364 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -363,7 +363,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """Representation of a ZHA number configuration entity.""" _attr_entity_category = EntityCategory.CONFIG - _attr_step: float = 1.0 + _attr_native_step: float = 1.0 _zcl_attribute: str @classmethod @@ -404,11 +404,11 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): super().__init__(unique_id, zha_device, channels, **kwargs) @property - def value(self) -> float: + def native_value(self) -> float: """Return the current value.""" return self._channel.cluster.get(self._zcl_attribute) - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Update the current value from HA.""" try: res = await self._channel.cluster.write_attributes( @@ -439,8 +439,8 @@ class AqaraMotionDetectionInterval( ): """Representation of a ZHA on off transition time configuration entity.""" - _attr_min_value: float = 2 - _attr_max_value: float = 65535 + _attr_native_min_value: float = 2 + _attr_native_max_value: float = 65535 _zcl_attribute: str = "detection_interval" @@ -450,8 +450,8 @@ class OnOffTransitionTimeConfigurationEntity( ): """Representation of a ZHA on off transition time configuration entity.""" - _attr_min_value: float = 0x0000 - _attr_max_value: float = 0xFFFF + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFF _zcl_attribute: str = "on_off_transition_time" @@ -459,8 +459,8 @@ class OnOffTransitionTimeConfigurationEntity( class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): """Representation of a ZHA on level configuration entity.""" - _attr_min_value: float = 0x00 - _attr_max_value: float = 0xFF + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFF _zcl_attribute: str = "on_level" @@ -470,8 +470,8 @@ class OnTransitionTimeConfigurationEntity( ): """Representation of a ZHA on transition time configuration entity.""" - _attr_min_value: float = 0x0000 - _attr_max_value: float = 0xFFFE + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "on_transition_time" @@ -481,8 +481,8 @@ class OffTransitionTimeConfigurationEntity( ): """Representation of a ZHA off transition time configuration entity.""" - _attr_min_value: float = 0x0000 - _attr_max_value: float = 0xFFFE + _attr_native_min_value: float = 0x0000 + _attr_native_max_value: float = 0xFFFE _zcl_attribute: str = "off_transition_time" @@ -492,8 +492,8 @@ class DefaultMoveRateConfigurationEntity( ): """Representation of a ZHA default move rate configuration entity.""" - _attr_min_value: float = 0x00 - _attr_max_value: float = 0xFE + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFE _zcl_attribute: str = "default_move_rate" @@ -503,8 +503,8 @@ class StartUpCurrentLevelConfigurationEntity( ): """Representation of a ZHA startup current level configuration entity.""" - _attr_min_value: float = 0x00 - _attr_max_value: float = 0xFF + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFF _zcl_attribute: str = "start_up_current_level" @@ -519,7 +519,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_entity_category = EntityCategory.CONFIG _attr_icon: str = ICONS[14] - _attr_min_value: float = 0x00 - _attr_max_value: float = 0x257 + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0x257 _attr_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "timer_duration" From baa810aabbee430643229ee4b3e9f4c467d2878e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 17 Jun 2022 12:17:13 +0200 Subject: [PATCH 474/947] Improve warnings for datetime and date sensors with invalid states (#73598) --- homeassistant/components/sensor/__init__.py | 10 +++++----- tests/components/sensor/test_init.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 2bbcf3b119d..2c0d75ff471 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -404,10 +404,10 @@ class SensorEntity(Entity): value = value.astimezone(timezone.utc) return value.isoformat(timespec="seconds") - except (AttributeError, TypeError) as err: + except (AttributeError, OverflowError, TypeError) as err: raise ValueError( - f"Invalid datetime: {self.entity_id} has a timestamp device class " - f"but does not provide a datetime state but {type(value)}" + f"Invalid datetime: {self.entity_id} has timestamp device class " + f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err # Received a date value @@ -419,8 +419,8 @@ class SensorEntity(Entity): return value.isoformat() except (AttributeError, TypeError) as err: raise ValueError( - f"Invalid date: {self.entity_id} has a date device class " - f"but does not provide a date state but {type(value)}" + f"Invalid date: {self.entity_id} has date device class " + f"but provides state {value}:{type(value)} resulting in '{err}'" ) from err if ( diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 4a3d0202c91..0bae8235ff9 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -183,8 +183,8 @@ async def test_deprecated_datetime_str( await hass.async_block_till_done() assert ( - f"Invalid {provides}: sensor.test has a {device_class} device class " - f"but does not provide a {provides} state but {type(state_value)}" + f"Invalid {provides}: sensor.test has {device_class} device class " + f"but provides state {state_value}:{type(state_value)}" ) in caplog.text From 2be54de4486112a4b16fe8a85bb5d79bb1d42641 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Fri, 17 Jun 2022 18:26:25 +0200 Subject: [PATCH 475/947] Don't verify ssl certificates for ssdp/upnp devices (#73647) --- homeassistant/components/ssdp/__init__.py | 2 +- homeassistant/components/upnp/device.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index e854472f21f..d221cb162f4 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -351,7 +351,7 @@ class Scanner: async def async_start(self) -> None: """Start the scanners.""" - session = async_get_clientsession(self.hass) + session = async_get_clientsession(self.hass, verify_ssl=False) requester = AiohttpSessionRequester(session, True, 10) self._description_cache = DescriptionCache(requester) diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index 334d870939f..3a688b8571d 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -48,7 +48,7 @@ async def async_get_mac_address_from_host(hass: HomeAssistant, host: str) -> str async def async_create_device(hass: HomeAssistant, ssdp_location: str) -> Device: """Create UPnP/IGD device.""" - session = async_get_clientsession(hass) + session = async_get_clientsession(hass, verify_ssl=False) requester = AiohttpSessionRequester(session, with_sleep=True, timeout=20) factory = UpnpFactory(requester, disable_state_variable_validation=True) From bf15df75dd46307b558b33c4b261b0e7771a71d3 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Fri, 17 Jun 2022 18:26:45 +0200 Subject: [PATCH 476/947] Ignore fake upnp/IGD devices when upnp is discovered (#73645) --- homeassistant/components/upnp/config_flow.py | 13 +++++++++++ tests/components/upnp/test_config_flow.py | 23 ++++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py index 7fa37f589bf..7d4e768e855 100644 --- a/homeassistant/components/upnp/config_flow.py +++ b/homeassistant/components/upnp/config_flow.py @@ -63,6 +63,12 @@ async def _async_mac_address_from_discovery( return await async_get_mac_address_from_host(hass, host) +def _is_igd_device(discovery_info: ssdp.SsdpServiceInfo) -> bool: + """Test if discovery is a complete IGD device.""" + root_device_info = discovery_info.upnp + return root_device_info.get(ssdp.ATTR_UPNP_DEVICE_TYPE) in {ST_IGD_V1, ST_IGD_V2} + + class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a UPnP/IGD config flow.""" @@ -108,6 +114,7 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): for discovery in discoveries if ( _is_complete_discovery(discovery) + and _is_igd_device(discovery) and discovery.ssdp_usn not in current_unique_ids ) ] @@ -144,6 +151,12 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("Incomplete discovery, ignoring") return self.async_abort(reason="incomplete_discovery") + # Ensure device is usable. Ideally we would use IgdDevice.is_profile_device, + # but that requires constructing the device completely. + if not _is_igd_device(discovery_info): + LOGGER.debug("Non IGD device, ignoring") + return self.async_abort(reason="non_igd_device") + # Ensure not already configuring/configured. unique_id = discovery_info.ssdp_usn await self.async_set_unique_id(unique_id) diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py index 80847ec2737..66d84fe0862 100644 --- a/tests/components/upnp/test_config_flow.py +++ b/tests/components/upnp/test_config_flow.py @@ -14,6 +14,7 @@ from homeassistant.components.upnp.const import ( CONFIG_ENTRY_ST, CONFIG_ENTRY_UDN, DOMAIN, + ST_IGD_V1, ) from homeassistant.core import HomeAssistant @@ -75,6 +76,7 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): ssdp_st=TEST_ST, ssdp_location=TEST_LOCATION, upnp={ + ssdp.ATTR_UPNP_DEVICE_TYPE: ST_IGD_V1, # ssdp.ATTR_UPNP_UDN: TEST_UDN, # Not provided. }, ), @@ -83,6 +85,27 @@ async def test_flow_ssdp_incomplete_discovery(hass: HomeAssistant): assert result["reason"] == "incomplete_discovery" +@pytest.mark.usefixtures("mock_get_source_ip") +async def test_flow_ssdp_non_igd_device(hass: HomeAssistant): + """Test config flow: incomplete discovery through ssdp.""" + # Discovered via step ssdp. + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=ssdp.SsdpServiceInfo( + ssdp_usn=TEST_USN, + ssdp_st=TEST_ST, + ssdp_location=TEST_LOCATION, + upnp={ + ssdp.ATTR_UPNP_DEVICE_TYPE: "urn:schemas-upnp-org:device:WFADevice:1", # Non-IGD + ssdp.ATTR_UPNP_UDN: TEST_UDN, + }, + ), + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "non_igd_device" + + @pytest.mark.usefixtures( "ssdp_instant_discovery", "mock_setup_entry", From 4bc5d7bfed07c20d6f3438ab91c734a620505a33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jun 2022 11:41:10 -0500 Subject: [PATCH 477/947] Speed up zha tests (#73627) --- .../zha/test_alarm_control_panel.py | 15 ++++++++++++++ tests/components/zha/test_api.py | 16 ++++++++++++++- tests/components/zha/test_binary_sensor.py | 17 ++++++++++++++++ tests/components/zha/test_button.py | 18 +++++++++++++++++ tests/components/zha/test_channels.py | 7 +++++++ tests/components/zha/test_climate.py | 18 +++++++++++++++++ tests/components/zha/test_config_flow.py | 7 +++++++ tests/components/zha/test_cover.py | 15 ++++++++++++++ tests/components/zha/test_device.py | 18 ++++++++++++++++- tests/components/zha/test_device_action.py | 17 ++++++++++++++++ tests/components/zha/test_device_tracker.py | 18 +++++++++++++++++ tests/components/zha/test_device_trigger.py | 9 +++++++++ tests/components/zha/test_diagnostics.py | 12 +++++++++++ tests/components/zha/test_fan.py | 16 +++++++++++++++ tests/components/zha/test_gateway.py | 16 +++++++++++++++ tests/components/zha/test_init.py | 7 +++++++ tests/components/zha/test_light.py | 18 +++++++++++++++++ tests/components/zha/test_lock.py | 14 +++++++++++++ tests/components/zha/test_logbook.py | 11 +++++++++- tests/components/zha/test_number.py | 17 ++++++++++++++++ tests/components/zha/test_select.py | 20 ++++++++++++++++++- tests/components/zha/test_sensor.py | 14 +++++++++++++ tests/components/zha/test_siren.py | 16 +++++++++++++++ tests/components/zha/test_switch.py | 15 ++++++++++++++ 24 files changed, 347 insertions(+), 4 deletions(-) diff --git a/tests/components/zha/test_alarm_control_panel.py b/tests/components/zha/test_alarm_control_panel.py index e3742a3132f..0d3b8ffa9f1 100644 --- a/tests/components/zha/test_alarm_control_panel.py +++ b/tests/components/zha/test_alarm_control_panel.py @@ -22,6 +22,21 @@ from .common import async_enable_traffic, find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +@pytest.fixture(autouse=True) +def alarm_control_panel_platform_only(): + """Only setup the alarm_control_panel and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.ALARM_CONTROL_PANEL, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index dac9855148a..08766bc74ac 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -37,7 +37,7 @@ from homeassistant.components.zha.core.const import ( GROUP_IDS, GROUP_NAME, ) -from homeassistant.const import ATTR_NAME +from homeassistant.const import ATTR_NAME, Platform from homeassistant.core import Context from .conftest import ( @@ -53,6 +53,20 @@ IEEE_SWITCH_DEVICE = "01:2d:6f:00:0a:90:69:e7" IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only setup the required and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ), + ): + yield + + @pytest.fixture async def device_switch(hass, zigpy_device_mock, zha_device_joined): """Test zha switch platform.""" diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index bfe2a3ce4f5..c27c8be16d8 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -1,4 +1,6 @@ """Test zha binary sensor.""" +from unittest.mock import patch + import pytest import zigpy.profiles.zha import zigpy.zcl.clusters.measurement as measurement @@ -34,6 +36,21 @@ DEVICE_OCCUPANCY = { } +@pytest.fixture(autouse=True) +def binary_sensor_platform_only(): + """Only setup the binary_sensor and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + async def async_test_binary_sensor_on_off(hass, cluster, entity_id): """Test getting on and off messages for binary sensors.""" # binary sensor on diff --git a/tests/components/zha/test_button.py b/tests/components/zha/test_button.py index f692528203f..2fdc263d732 100644 --- a/tests/components/zha/test_button.py +++ b/tests/components/zha/test_button.py @@ -28,6 +28,7 @@ from homeassistant.const import ( ENTITY_CATEGORY_CONFIG, ENTITY_CATEGORY_DIAGNOSTIC, STATE_UNKNOWN, + Platform, ) from homeassistant.helpers import entity_registry as er @@ -37,6 +38,23 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import mock_coro +@pytest.fixture(autouse=True) +def button_platform_only(): + """Only setup the button and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def contact_sensor(hass, zigpy_device_mock, zha_device_joined_restored): """Contact sensor fixture.""" diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index e55e10bd7ae..7701992cab4 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -20,6 +20,13 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_capture_events +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", []): + yield + + @pytest.fixture def ieee(): """IEEE fixture.""" diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py index 7866bba076c..a04b2c116e3 100644 --- a/tests/components/zha/test_climate.py +++ b/tests/components/zha/test_climate.py @@ -171,6 +171,24 @@ ZCL_ATTR_PLUG = { } +@pytest.fixture(autouse=True) +def climate_platform_only(): + """Only setup the climate and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.CLIMATE, + Platform.BINARY_SENSOR, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SWITCH, + ), + ): + yield + + @pytest.fixture def device_climate_mock(hass, zigpy_device_mock, zha_device_joined): """Test regular thermostat device.""" diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index df68c21b6c0..285c08d8a3e 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -34,6 +34,13 @@ from homeassistant.data_entry_flow import ( from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", []): + yield + + def com_port(): """Mock of a serial port.""" port = serial.tools.list_ports_common.ListPortInfo("/dev/ttyUSB1234") diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index d5b07e16685..3dab405151d 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -39,6 +39,21 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_capture_events, mock_coro, mock_restore_cache +@pytest.fixture(autouse=True) +def cover_platform_only(): + """Only setup the cover and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.COVER, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture def zigpy_cover_device(zigpy_device_mock): """Zigpy cover device.""" diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index 0f2caceada5..8b718635b6a 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -12,7 +12,7 @@ from homeassistant.components.zha.core.const import ( CONF_DEFAULT_CONSIDER_UNAVAILABLE_BATTERY, CONF_DEFAULT_CONSIDER_UNAVAILABLE_MAINS, ) -from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE +from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE, Platform import homeassistant.helpers.device_registry as dr import homeassistant.util.dt as dt_util @@ -22,6 +22,22 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_fire_time_changed +@pytest.fixture(autouse=True) +def required_platforms_only(): + """Only setup the required platform and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + Platform.BINARY_SENSOR, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 54cc7e9171d..fffb79fe0f2 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -24,6 +24,23 @@ COMMAND = "command" COMMAND_SINGLE = "single" +@pytest.fixture(autouse=True) +def required_platforms_only(): + """Only setup the required platforms and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + Platform.SIREN, + ), + ): + yield + + @pytest.fixture async def device_ias(hass, zigpy_device_mock, zha_device_joined_restored): """IAS device fixture.""" diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 06caac91cb2..59c36143a6e 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -1,6 +1,7 @@ """Test ZHA Device Tracker.""" from datetime import timedelta import time +from unittest.mock import patch import pytest import zigpy.profiles.zha @@ -24,6 +25,23 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import async_fire_time_changed +@pytest.fixture(autouse=True) +def device_tracker_platforms_only(): + """Only setup the device_tracker platforms and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.BUTTON, + Platform.SELECT, + Platform.NUMBER, + Platform.BINARY_SENSOR, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture def zigpy_device_dt(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index ac93cbe0c7f..1f5fa467a93 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -1,6 +1,7 @@ """ZHA device automation trigger tests.""" from datetime import timedelta import time +from unittest.mock import patch import pytest import zigpy.profiles.zha @@ -8,6 +9,7 @@ import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.const import Platform from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -36,6 +38,13 @@ LONG_PRESS = "remote_button_long_press" LONG_RELEASE = "remote_button_long_release" +@pytest.fixture(autouse=True) +def sensor_platforms_only(): + """Only setup the sensor platform and required base platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", (Platform.SENSOR,)): + yield + + def _same_lists(list_a, list_b): if len(list_a) != len(list_b): return False diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 804b6d73316..d88996c78f1 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by the ESPHome integration.""" +from unittest.mock import patch + import pytest import zigpy.profiles.zha as zha import zigpy.zcl.clusters.security as security @@ -8,6 +10,7 @@ import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics.const import REDACTED from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import async_get @@ -26,6 +29,15 @@ CONFIG_ENTRY_DIAGNOSTICS_KEYS = [ ] +@pytest.fixture(autouse=True) +def required_platforms_only(): + """Only setup the required platform and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", (Platform.ALARM_CONTROL_PANEL,) + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 19f5f39a22f..423634db035 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -51,6 +51,22 @@ IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" +@pytest.fixture(autouse=True) +def fan_platform_only(): + """Only setup the fan and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.FAN, + Platform.LIGHT, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 67ce6481542..b19c98548ce 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -35,6 +35,22 @@ def zigpy_dev_basic(zigpy_device_mock): ) +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only setup the required and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.SENSOR, + Platform.LIGHT, + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture async def zha_dev_basic(hass, zha_device_restored, zigpy_dev_basic): """ZHA device with just a basic cluster.""" diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 0615eeef623..262a743559f 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -20,6 +20,13 @@ DATA_RADIO_TYPE = "deconz" DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0" +@pytest.fixture(autouse=True) +def disable_platform_only(): + """Disable platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", []): + yield + + @pytest.fixture def config_entry_v1(hass): """Config entry version 1 fixture.""" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 8cf0e668503..dd6df0dff19 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -80,6 +80,24 @@ LIGHT_COLOR = { } +@pytest.fixture(autouse=True) +def light_platform_only(): + """Only setup the light and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BINARY_SENSOR, + Platform.DEVICE_TRACKER, + Platform.BUTTON, + Platform.LIGHT, + Platform.SENSOR, + Platform.NUMBER, + Platform.SELECT, + ), + ): + yield + + @pytest.fixture async def coordinator(hass, zigpy_device_mock, zha_device_joined): """Test zha light platform.""" diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index 0669cebf128..08b720b2ad7 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -27,6 +27,20 @@ CLEAR_PIN_CODE = 7 SET_USER_STATUS = 9 +@pytest.fixture(autouse=True) +def lock_platform_only(): + """Only setup the lock and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.LOCK, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def lock(hass, zigpy_device_mock, zha_device_joined_restored): """Lock cluster fixture.""" diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py index 00e1cc28ea6..33b758fd0a7 100644 --- a/tests/components/zha/test_logbook.py +++ b/tests/components/zha/test_logbook.py @@ -1,11 +1,13 @@ """ZHA logbook describe events tests.""" +from unittest.mock import patch + import pytest import zigpy.profiles.zha import zigpy.zcl.clusters.general as general from homeassistant.components.zha.core.const import ZHA_EVENT -from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID +from homeassistant.const import CONF_DEVICE_ID, CONF_UNIQUE_ID, Platform from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component @@ -29,6 +31,13 @@ UP = "up" DOWN = "down" +@pytest.fixture(autouse=True) +def sensor_platform_only(): + """Only setup the sensor and required base platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", (Platform.SENSOR,)): + yield + + @pytest.fixture async def mock_devices(hass, zigpy_device_mock, zha_device_joined): """IAS device fixture.""" diff --git a/tests/components/zha/test_number.py b/tests/components/zha/test_number.py index 01946c05f1a..f6b606ccbbf 100644 --- a/tests/components/zha/test_number.py +++ b/tests/components/zha/test_number.py @@ -24,6 +24,23 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import mock_coro +@pytest.fixture(autouse=True) +def number_platform_only(): + """Only setup the number and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture def zigpy_analog_output_device(zigpy_device_mock): """Zigpy analog_output device.""" diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index 70b943d5ea2..c883d648e8e 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -1,6 +1,6 @@ """Test ZHA select entities.""" -from unittest.mock import call +from unittest.mock import call, patch import pytest from zigpy.const import SIG_EP_PROFILE @@ -16,6 +16,24 @@ from .common import find_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +@pytest.fixture(autouse=True) +def select_select_only(): + """Only setup the select and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SIREN, + Platform.LIGHT, + Platform.NUMBER, + Platform.SELECT, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def siren(hass, zigpy_device_mock, zha_device_joined_restored): """Siren fixture.""" diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 0d476fb8bda..c638bdd8c48 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -1,5 +1,6 @@ """Test zha sensor.""" import math +from unittest.mock import patch import pytest import zigpy.profiles.zha @@ -50,6 +51,19 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_e769900a_{}" +@pytest.fixture(autouse=True) +def sensor_platform_only(): + """Only setup the sensor and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.SENSOR, + ), + ): + yield + + @pytest.fixture async def elec_measurement_zigpy_dev(hass, zigpy_device_mock): """Electric Measurement zigpy device.""" diff --git a/tests/components/zha/test_siren.py b/tests/components/zha/test_siren.py index 285bc1cd585..72a40a8323e 100644 --- a/tests/components/zha/test_siren.py +++ b/tests/components/zha/test_siren.py @@ -28,6 +28,22 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_fire_time_changed, mock_coro +@pytest.fixture(autouse=True) +def siren_platform_only(): + """Only setup the siren and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.NUMBER, + Platform.SENSOR, + Platform.SELECT, + Platform.SIREN, + ), + ): + yield + + @pytest.fixture async def siren(hass, zigpy_device_mock, zha_device_joined_restored): """Siren fixture.""" diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 99e8a681348..0b8fe658c28 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -41,6 +41,21 @@ IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" IEEE_GROUPABLE_DEVICE2 = "02:2d:6f:00:0a:90:69:e8" +@pytest.fixture(autouse=True) +def switch_platform_only(): + """Only setup the switch and required base platforms to speed up tests.""" + with patch( + "homeassistant.components.zha.PLATFORMS", + ( + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SELECT, + Platform.SWITCH, + ), + ): + yield + + @pytest.fixture def zigpy_device(zigpy_device_mock): """Device tracker zigpy device.""" From 600d23e0528a616afcf3fe18acdcfdce9bfeab07 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jun 2022 14:03:42 -0500 Subject: [PATCH 478/947] Retry on SenseAPIException during sense config entry setup (#73651) --- homeassistant/components/sense/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index 2e0ed4622e8..e938f7132e0 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -5,6 +5,7 @@ import logging from sense_energy import ( ASyncSenseable, + SenseAPIException, SenseAuthenticationException, SenseMFARequiredException, ) @@ -84,6 +85,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( str(err) or "Timed out during authentication" ) from err + except SenseAPIException as err: + raise ConfigEntryNotReady(str(err)) from err sense_devices_data = SenseDevicesData() try: From 7a3f632c1d1b6f99ccefd637521ca20373a9fd38 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Sat, 18 Jun 2022 05:13:07 +1000 Subject: [PATCH 479/947] Make stream recorder work concurrently (#73478) --- homeassistant/components/stream/__init__.py | 4 +- homeassistant/components/stream/core.py | 1 - homeassistant/components/stream/hls.py | 5 + homeassistant/components/stream/recorder.py | 219 ++++++++++--------- tests/components/stream/conftest.py | 59 +----- tests/components/stream/test_hls.py | 14 +- tests/components/stream/test_recorder.py | 221 ++++++++++---------- tests/components/stream/test_worker.py | 80 ++++--- 8 files changed, 297 insertions(+), 306 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 3766d981da5..b842eb7fb78 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -502,7 +502,6 @@ class Stream: recorder.video_path = video_path await self.start() - self._logger.debug("Started a stream recording of %s seconds", duration) # Take advantage of lookback hls: HlsStreamOutput = cast(HlsStreamOutput, self.outputs().get(HLS_PROVIDER)) @@ -512,6 +511,9 @@ class Stream: await hls.recv() recorder.prepend(list(hls.get_segments())[-num_segments - 1 : -1]) + self._logger.debug("Started a stream recording of %s seconds", duration) + await recorder.async_record() + async def async_get_image( self, width: int | None = None, diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index c8d831157a8..09d9a9d5031 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -327,7 +327,6 @@ class StreamOutput: """Handle cleanup.""" self._event.set() self.idle_timer.clear() - self._segments = deque(maxlen=self._segments.maxlen) class StreamView(HomeAssistantView): diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index efecdcbe9dc..d3bcbb360a6 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -60,6 +60,11 @@ class HlsStreamOutput(StreamOutput): """Return provider name.""" return HLS_PROVIDER + def cleanup(self) -> None: + """Handle cleanup.""" + super().cleanup() + self._segments.clear() + @property def target_duration(self) -> float: """Return the target duration.""" diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py index 4d97c0d683d..b33a5fbbf84 100644 --- a/homeassistant/components/stream/recorder.py +++ b/homeassistant/components/stream/recorder.py @@ -1,14 +1,11 @@ """Provide functionality to record stream.""" from __future__ import annotations -from collections import deque from io import BytesIO import logging import os -import threading import av -from av.container import OutputContainer from homeassistant.core import HomeAssistant, callback @@ -27,99 +24,9 @@ def async_setup_recorder(hass: HomeAssistant) -> None: """Only here so Provider Registry works.""" -def recorder_save_worker(file_out: str, segments: deque[Segment]) -> None: - """Handle saving stream.""" - - if not segments: - _LOGGER.error("Recording failed to capture anything") - return - - os.makedirs(os.path.dirname(file_out), exist_ok=True) - - pts_adjuster: dict[str, int | None] = {"video": None, "audio": None} - output: OutputContainer | None = None - output_v = None - output_a = None - - last_stream_id = None - # The running duration of processed segments. Note that this is in av.time_base - # units which seem to be defined inversely to how stream time_bases are defined - running_duration = 0 - - last_sequence = float("-inf") - for segment in segments: - # Because the stream_worker is in a different thread from the record service, - # the lookback segments may still have some overlap with the recorder segments - if segment.sequence <= last_sequence: - continue - last_sequence = segment.sequence - - # Open segment - source = av.open( - BytesIO(segment.init + segment.get_data()), - "r", - format=SEGMENT_CONTAINER_FORMAT, - ) - # Skip this segment if it doesn't have data - if source.duration is None: - source.close() - continue - source_v = source.streams.video[0] - source_a = source.streams.audio[0] if len(source.streams.audio) > 0 else None - - # Create output on first segment - if not output: - output = av.open( - file_out, - "w", - format=RECORDER_CONTAINER_FORMAT, - container_options={ - "video_track_timescale": str(int(1 / source_v.time_base)) - }, - ) - - # Add output streams if necessary - if not output_v: - output_v = output.add_stream(template=source_v) - context = output_v.codec_context - context.flags |= "GLOBAL_HEADER" - if source_a and not output_a: - output_a = output.add_stream(template=source_a) - - # Recalculate pts adjustments on first segment and on any discontinuity - # We are assuming time base is the same across all discontinuities - if last_stream_id != segment.stream_id: - last_stream_id = segment.stream_id - pts_adjuster["video"] = int( - (running_duration - source.start_time) - / (av.time_base * source_v.time_base) - ) - if source_a: - pts_adjuster["audio"] = int( - (running_duration - source.start_time) - / (av.time_base * source_a.time_base) - ) - - # Remux video - for packet in source.demux(): - if packet.dts is None: - continue - packet.pts += pts_adjuster[packet.stream.type] - packet.dts += pts_adjuster[packet.stream.type] - packet.stream = output_v if packet.stream.type == "video" else output_a - output.mux(packet) - - running_duration += source.duration - source.start_time - - source.close() - - if output is not None: - output.close() - - @PROVIDERS.register(RECORDER_PROVIDER) class RecorderOutput(StreamOutput): - """Represents HLS Output formats.""" + """Represents the Recorder Output format.""" def __init__( self, @@ -141,13 +48,119 @@ class RecorderOutput(StreamOutput): self._segments.extendleft(reversed(segments)) def cleanup(self) -> None: - """Write recording and clean up.""" - _LOGGER.debug("Starting recorder worker thread") - thread = threading.Thread( - name="recorder_save_worker", - target=recorder_save_worker, - args=(self.video_path, self._segments.copy()), - ) - thread.start() - + """Handle cleanup.""" + self.idle_timer.idle = True super().cleanup() + + async def async_record(self) -> None: + """Handle saving stream.""" + + os.makedirs(os.path.dirname(self.video_path), exist_ok=True) + + pts_adjuster: dict[str, int | None] = {"video": None, "audio": None} + output: av.container.OutputContainer | None = None + output_v = None + output_a = None + + last_stream_id = -1 + # The running duration of processed segments. Note that this is in av.time_base + # units which seem to be defined inversely to how stream time_bases are defined + running_duration = 0 + + last_sequence = float("-inf") + + def write_segment(segment: Segment) -> None: + """Write a segment to output.""" + nonlocal output, output_v, output_a, last_stream_id, running_duration, last_sequence + # Because the stream_worker is in a different thread from the record service, + # the lookback segments may still have some overlap with the recorder segments + if segment.sequence <= last_sequence: + return + last_sequence = segment.sequence + + # Open segment + source = av.open( + BytesIO(segment.init + segment.get_data()), + "r", + format=SEGMENT_CONTAINER_FORMAT, + ) + # Skip this segment if it doesn't have data + if source.duration is None: + source.close() + return + source_v = source.streams.video[0] + source_a = ( + source.streams.audio[0] if len(source.streams.audio) > 0 else None + ) + + # Create output on first segment + if not output: + output = av.open( + self.video_path + ".tmp", + "w", + format=RECORDER_CONTAINER_FORMAT, + container_options={ + "video_track_timescale": str(int(1 / source_v.time_base)) + }, + ) + + # Add output streams if necessary + if not output_v: + output_v = output.add_stream(template=source_v) + context = output_v.codec_context + context.flags |= "GLOBAL_HEADER" + if source_a and not output_a: + output_a = output.add_stream(template=source_a) + + # Recalculate pts adjustments on first segment and on any discontinuity + # We are assuming time base is the same across all discontinuities + if last_stream_id != segment.stream_id: + last_stream_id = segment.stream_id + pts_adjuster["video"] = int( + (running_duration - source.start_time) + / (av.time_base * source_v.time_base) + ) + if source_a: + pts_adjuster["audio"] = int( + (running_duration - source.start_time) + / (av.time_base * source_a.time_base) + ) + + # Remux video + for packet in source.demux(): + if packet.dts is None: + continue + packet.pts += pts_adjuster[packet.stream.type] + packet.dts += pts_adjuster[packet.stream.type] + packet.stream = output_v if packet.stream.type == "video" else output_a + output.mux(packet) + + running_duration += source.duration - source.start_time + + source.close() + + # Write lookback segments + while len(self._segments) > 1: # The last segment is in progress + await self._hass.async_add_executor_job( + write_segment, self._segments.popleft() + ) + # Make sure the first segment has been added + if not self._segments: + await self.recv() + # Write segments as soon as they are completed + while not self.idle: + await self.recv() + await self._hass.async_add_executor_job( + write_segment, self._segments.popleft() + ) + # Write remaining segments + # Should only have 0 or 1 segments, but loop through just in case + while self._segments: + await self._hass.async_add_executor_job( + write_segment, self._segments.popleft() + ) + if output is None: + _LOGGER.error("Recording failed to capture anything") + else: + output.close() + os.rename(self.video_path + ".tmp", self.video_path) diff --git a/tests/components/stream/conftest.py b/tests/components/stream/conftest.py index a3d2da8bd52..91b4106c1f4 100644 --- a/tests/components/stream/conftest.py +++ b/tests/components/stream/conftest.py @@ -12,7 +12,6 @@ so that it can inspect the output. from __future__ import annotations import asyncio -from collections import deque from http import HTTPStatus import logging import threading @@ -20,10 +19,9 @@ from typing import Generator from unittest.mock import Mock, patch from aiohttp import web -import async_timeout import pytest -from homeassistant.components.stream.core import Segment, StreamOutput +from homeassistant.components.stream.core import StreamOutput from homeassistant.components.stream.worker import StreamState from .common import generate_h264_video, stream_teardown @@ -73,61 +71,6 @@ def stream_worker_sync(hass): yield sync -class SaveRecordWorkerSync: - """ - Test fixture to manage RecordOutput thread for recorder_save_worker. - - This is used to assert that the worker is started and stopped cleanly - to avoid thread leaks in tests. - """ - - def __init__(self, hass): - """Initialize SaveRecordWorkerSync.""" - self._hass = hass - self._save_event = None - self._segments = None - self._save_thread = None - self.reset() - - def recorder_save_worker(self, file_out: str, segments: deque[Segment]): - """Mock method for patch.""" - logging.debug("recorder_save_worker thread started") - assert self._save_thread is None - self._segments = segments - self._save_thread = threading.current_thread() - self._hass.loop.call_soon_threadsafe(self._save_event.set) - - async def get_segments(self): - """Return the recorded video segments.""" - async with async_timeout.timeout(TEST_TIMEOUT): - await self._save_event.wait() - return self._segments - - async def join(self): - """Verify save worker was invoked and block on shutdown.""" - async with async_timeout.timeout(TEST_TIMEOUT): - await self._save_event.wait() - self._save_thread.join(timeout=TEST_TIMEOUT) - assert not self._save_thread.is_alive() - - def reset(self): - """Reset callback state for reuse in tests.""" - self._save_thread = None - self._save_event = asyncio.Event() - - -@pytest.fixture() -def record_worker_sync(hass): - """Patch recorder_save_worker for clean thread shutdown for test.""" - sync = SaveRecordWorkerSync(hass) - with patch( - "homeassistant.components.stream.recorder.recorder_save_worker", - side_effect=sync.recorder_save_worker, - autospec=True, - ): - yield sync - - class HLSSync: """Test fixture that intercepts stream worker calls to StreamOutput.""" diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py index 7343b96ef9a..715e69fb889 100644 --- a/tests/components/stream/test_hls.py +++ b/tests/components/stream/test_hls.py @@ -506,10 +506,12 @@ async def test_remove_incomplete_segment_on_exit( assert len(segments) == 3 assert not segments[-1].complete stream_worker_sync.resume() - stream._thread_quit.set() - stream._thread.join() - stream._thread = None - await hass.async_block_till_done() - assert segments[-1].complete - assert len(segments) == 2 + with patch("homeassistant.components.stream.Stream.remove_provider"): + # Patch remove_provider so the deque is not cleared + stream._thread_quit.set() + stream._thread.join() + stream._thread = None + await hass.async_block_till_done() + assert segments[-1].complete + assert len(segments) == 2 await stream.stop() diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py index 9433cbd449d..d7595b47679 100644 --- a/tests/components/stream/test_recorder.py +++ b/tests/components/stream/test_recorder.py @@ -1,4 +1,5 @@ -"""The tests for hls streams.""" +"""The tests for recording streams.""" +import asyncio from datetime import timedelta from io import BytesIO import os @@ -7,11 +8,14 @@ from unittest.mock import patch import av import pytest -from homeassistant.components.stream import create_stream -from homeassistant.components.stream.const import HLS_PROVIDER, RECORDER_PROVIDER +from homeassistant.components.stream import Stream, create_stream +from homeassistant.components.stream.const import ( + HLS_PROVIDER, + OUTPUT_IDLE_TIMEOUT, + RECORDER_PROVIDER, +) from homeassistant.components.stream.core import Part from homeassistant.components.stream.fmp4utils import find_box -from homeassistant.components.stream.recorder import recorder_save_worker from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -20,40 +24,55 @@ from .common import DefaultSegment as Segment, generate_h264_video, remux_with_a from tests.common import async_fire_time_changed -MAX_ABORT_SEGMENTS = 20 # Abort test to avoid looping forever - -async def test_record_stream(hass, hass_client, record_worker_sync, h264_video): - """ - Test record stream. - - Tests full integration with the stream component, and captures the - stream worker and save worker to allow for clean shutdown of background - threads. The actual save logic is tested in test_recorder_save below. - """ +@pytest.fixture(autouse=True) +async def stream_component(hass): + """Set up the component before each test.""" await async_setup_component(hass, "stream", {"stream": {}}) - # Setup demo track - stream = create_stream(hass, h264_video, {}) + +@pytest.fixture +def filename(tmpdir): + """Use this filename for the tests.""" + return f"{tmpdir}/test.mp4" + + +async def test_record_stream(hass, filename, h264_video): + """Test record stream.""" + + worker_finished = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch remove_provider.""" + + async def remove_provider(self, provider): + """Add a finished event to Stream.remove_provider.""" + await Stream.remove_provider(self, provider) + worker_finished.set() + + with patch("homeassistant.components.stream.Stream", wraps=MockStream): + stream = create_stream(hass, h264_video, {}) + with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + make_recording = hass.async_create_task(stream.async_record(filename)) - # After stream decoding finishes, the record worker thread starts - segments = await record_worker_sync.get_segments() - assert len(segments) >= 1 + # In general usage the recorder will only include what has already been + # processed by the worker. To guarantee we have some output for the test, + # wait until the worker has finished before firing + await worker_finished.wait() - # Verify that the save worker was invoked, then block until its - # thread completes and is shutdown completely to avoid thread leaks. - await record_worker_sync.join() + # Fire the IdleTimer + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) - await stream.stop() + await make_recording + + # Assert + assert os.path.exists(filename) -async def test_record_lookback( - hass, hass_client, stream_worker_sync, record_worker_sync, h264_video -): +async def test_record_lookback(hass, h264_video): """Exercise record with loopback.""" - await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, h264_video, {}) @@ -69,42 +88,8 @@ async def test_record_lookback( await stream.stop() -async def test_recorder_timeout(hass, hass_client, stream_worker_sync, h264_video): - """ - Test recorder timeout. - - Mocks out the cleanup to assert that it is invoked after a timeout. - This test does not start the recorder save thread. - """ - await async_setup_component(hass, "stream", {"stream": {}}) - - stream_worker_sync.pause() - - with patch("homeassistant.components.stream.IdleTimer.fire") as mock_timeout: - # Setup demo track - stream = create_stream(hass, h264_video, {}) - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") - recorder = stream.add_provider(RECORDER_PROVIDER) - - await recorder.recv() - - # Wait a minute - future = dt_util.utcnow() + timedelta(minutes=1) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert mock_timeout.called - - stream_worker_sync.resume() - await stream.stop() - await hass.async_block_till_done() - await hass.async_block_till_done() - - -async def test_record_path_not_allowed(hass, hass_client, h264_video): +async def test_record_path_not_allowed(hass, h264_video): """Test where the output path is not allowed by home assistant configuration.""" - await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, h264_video, {}) with patch.object( @@ -127,25 +112,8 @@ def add_parts_to_segment(segment, source): ] -async def test_recorder_save(tmpdir, h264_video): - """Test recorder save.""" - # Setup - filename = f"{tmpdir}/test.mp4" - - # Run - segment = Segment(sequence=1) - add_parts_to_segment(segment, h264_video) - segment.duration = 4 - recorder_save_worker(filename, [segment]) - - # Assert - assert os.path.exists(filename) - - -async def test_recorder_discontinuity(tmpdir, h264_video): +async def test_recorder_discontinuity(hass, filename, h264_video): """Test recorder save across a discontinuity.""" - # Setup - filename = f"{tmpdir}/test.mp4" # Run segment_1 = Segment(sequence=1, stream_id=0) @@ -154,18 +122,50 @@ async def test_recorder_discontinuity(tmpdir, h264_video): segment_2 = Segment(sequence=2, stream_id=1) add_parts_to_segment(segment_2, h264_video) segment_2.duration = 4 - recorder_save_worker(filename, [segment_1, segment_2]) + + provider_ready = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch add_provider.""" + + async def start(self): + """Make Stream.start a noop that gives up async context.""" + await asyncio.sleep(0) + + def add_provider(self, fmt, timeout=OUTPUT_IDLE_TIMEOUT): + """Add a finished event to Stream.add_provider.""" + provider = Stream.add_provider(self, fmt, timeout) + provider_ready.set() + return provider + + with patch.object(hass.config, "is_allowed_path", return_value=True), patch( + "homeassistant.components.stream.Stream", wraps=MockStream + ), patch("homeassistant.components.stream.recorder.RecorderOutput.recv"): + stream = create_stream(hass, "blank", {}) + make_recording = hass.async_create_task(stream.async_record(filename)) + await provider_ready.wait() + + recorder_output = stream.outputs()[RECORDER_PROVIDER] + recorder_output.idle_timer.start() + recorder_output._segments.extend([segment_1, segment_2]) + + # Fire the IdleTimer + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + + await make_recording # Assert assert os.path.exists(filename) -async def test_recorder_no_segments(tmpdir): +async def test_recorder_no_segments(hass, filename): """Test recorder behavior with a stream failure which causes no segments.""" - # Setup - filename = f"{tmpdir}/test.mp4" + + stream = create_stream(hass, BytesIO(), {}) # Run - recorder_save_worker("unused-file", []) + with patch.object(hass.config, "is_allowed_path", return_value=True): + await stream.async_record(filename) # Assert assert not os.path.exists(filename) @@ -188,9 +188,7 @@ def h264_mov_video(): ) async def test_record_stream_audio( hass, - hass_client, - stream_worker_sync, - record_worker_sync, + filename, audio_codec, expected_audio_streams, h264_mov_video, @@ -201,28 +199,42 @@ async def test_record_stream_audio( Record stream output should have an audio channel when input has a valid codec and audio packets and no audio channel otherwise. """ - await async_setup_component(hass, "stream", {"stream": {}}) # Remux source video with new audio source = remux_with_audio(h264_mov_video, "mov", audio_codec) # mov can store PCM - record_worker_sync.reset() - stream_worker_sync.pause() + worker_finished = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch remove_provider.""" + + async def remove_provider(self, provider): + """Add a finished event to Stream.remove_provider.""" + await Stream.remove_provider(self, provider) + worker_finished.set() + + with patch("homeassistant.components.stream.Stream", wraps=MockStream): + stream = create_stream(hass, source, {}) - stream = create_stream(hass, source, {}) with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") - recorder = stream.add_provider(RECORDER_PROVIDER) + make_recording = hass.async_create_task(stream.async_record(filename)) - while True: - await recorder.recv() - if not (segment := recorder.last_segment): - break - last_segment = segment - stream_worker_sync.resume() + # In general usage the recorder will only include what has already been + # processed by the worker. To guarantee we have some output for the test, + # wait until the worker has finished before firing + await worker_finished.wait() + + # Fire the IdleTimer + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + + await make_recording + + # Assert + assert os.path.exists(filename) result = av.open( - BytesIO(last_segment.init + last_segment.get_data()), + filename, "r", format="mp4", ) @@ -232,14 +244,9 @@ async def test_record_stream_audio( await stream.stop() await hass.async_block_till_done() - # Verify that the save worker was invoked, then block until its - # thread completes and is shutdown completely to avoid thread leaks. - await record_worker_sync.join() - async def test_recorder_log(hass, caplog): """Test starting a stream to record logs the url without username and password.""" - await async_setup_component(hass, "stream", {"stream": {}}) stream = create_stream(hass, "https://abcd:efgh@foo.bar", {}) with patch.object(hass.config, "is_allowed_path", return_value=True): await stream.async_record("/example/path") diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 298d7287e69..863a289c2c5 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -13,6 +13,7 @@ pushed to the output streams. The packet sequence can be used to exercise failure modes or corner cases like how out of order packets are handled. """ +import asyncio import fractions import io import logging @@ -33,6 +34,7 @@ from homeassistant.components.stream.const import ( HLS_PROVIDER, MAX_MISSING_DTS, PACKETS_TO_WAIT_FOR_AUDIO, + RECORDER_PROVIDER, SEGMENT_DURATION_ADJUSTER, TARGET_SEGMENT_DURATION_NON_LL_HLS, ) @@ -732,7 +734,23 @@ async def test_worker_log(hass, caplog): assert "https://abcd:efgh@foo.bar" not in caplog.text -async def test_durations(hass, record_worker_sync): +@pytest.fixture +def worker_finished_stream(): + """Fixture that helps call a stream and wait for the worker to finish.""" + worker_finished = asyncio.Event() + + class MockStream(Stream): + """Mock Stream so we can patch remove_provider.""" + + async def remove_provider(self, provider): + """Add a finished event to Stream.remove_provider.""" + await Stream.remove_provider(self, provider) + worker_finished.set() + + return worker_finished, MockStream + + +async def test_durations(hass, worker_finished_stream): """Test that the duration metadata matches the media.""" # Use a target part duration which has a slight mismatch @@ -751,13 +769,17 @@ async def test_durations(hass, record_worker_sync): ) source = generate_h264_video(duration=SEGMENT_DURATION + 1) - stream = create_stream(hass, source, {}, stream_label="camera") + worker_finished, mock_stream = worker_finished_stream - # use record_worker_sync to grab output segments - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + with patch("homeassistant.components.stream.Stream", wraps=mock_stream): + stream = create_stream(hass, source, {}, stream_label="camera") + + recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) + await stream.start() + await worker_finished.wait() + + complete_segments = list(recorder_output.get_segments())[:-1] - complete_segments = list(await record_worker_sync.get_segments())[:-1] assert len(complete_segments) >= 1 # check that the Part duration metadata matches the durations in the media @@ -803,12 +825,10 @@ async def test_durations(hass, record_worker_sync): abs_tol=1e-6, ) - await record_worker_sync.join() - await stream.stop() -async def test_has_keyframe(hass, record_worker_sync, h264_video): +async def test_has_keyframe(hass, h264_video, worker_finished_stream): """Test that the has_keyframe metadata matches the media.""" await async_setup_component( hass, @@ -824,13 +844,17 @@ async def test_has_keyframe(hass, record_worker_sync, h264_video): }, ) - stream = create_stream(hass, h264_video, {}, stream_label="camera") + worker_finished, mock_stream = worker_finished_stream - # use record_worker_sync to grab output segments - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + with patch("homeassistant.components.stream.Stream", wraps=mock_stream): + stream = create_stream(hass, h264_video, {}, stream_label="camera") + + recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) + await stream.start() + await worker_finished.wait() + + complete_segments = list(recorder_output.get_segments())[:-1] - complete_segments = list(await record_worker_sync.get_segments())[:-1] assert len(complete_segments) >= 1 # check that the Part has_keyframe metadata matches the keyframes in the media @@ -843,12 +867,10 @@ async def test_has_keyframe(hass, record_worker_sync, h264_video): av_part.close() assert part.has_keyframe == media_has_keyframe - await record_worker_sync.join() - await stream.stop() -async def test_h265_video_is_hvc1(hass, record_worker_sync): +async def test_h265_video_is_hvc1(hass, worker_finished_stream): """Test that a h265 video gets muxed as hvc1.""" await async_setup_component( hass, @@ -863,13 +885,16 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): ) source = generate_h265_video() - stream = create_stream(hass, source, {}, stream_label="camera") - # use record_worker_sync to grab output segments - with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") + worker_finished, mock_stream = worker_finished_stream + with patch("homeassistant.components.stream.Stream", wraps=mock_stream): + stream = create_stream(hass, source, {}, stream_label="camera") - complete_segments = list(await record_worker_sync.get_segments())[:-1] + recorder_output = stream.add_provider(RECORDER_PROVIDER, timeout=30) + await stream.start() + await worker_finished.wait() + + complete_segments = list(recorder_output.get_segments())[:-1] assert len(complete_segments) >= 1 segment = complete_segments[0] @@ -878,8 +903,6 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): assert av_part.streams.video[0].codec_tag == "hvc1" av_part.close() - await record_worker_sync.join() - await stream.stop() assert stream.get_diagnostics() == { @@ -891,7 +914,7 @@ async def test_h265_video_is_hvc1(hass, record_worker_sync): } -async def test_get_image(hass, record_worker_sync): +async def test_get_image(hass): """Test that the has_keyframe metadata matches the media.""" await async_setup_component(hass, "stream", {"stream": {}}) @@ -904,14 +927,11 @@ async def test_get_image(hass, record_worker_sync): mock_turbo_jpeg_singleton.instance.return_value = mock_turbo_jpeg() stream = create_stream(hass, source, {}) - # use record_worker_sync to grab output segments with patch.object(hass.config, "is_allowed_path", return_value=True): - await stream.async_record("/example/path") - + make_recording = hass.async_create_task(stream.async_record("/example/path")) + await make_recording assert stream._keyframe_converter._image is None - await record_worker_sync.join() - assert await stream.async_get_image() == EMPTY_8_6_JPEG await stream.stop() From 027f54ca15f50f205eabf39e18ac7eaca63e5817 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 18 Jun 2022 00:24:33 +0000 Subject: [PATCH 480/947] [ci skip] Translation update --- .../components/generic/translations/en.json | 7 +++++++ .../components/group/translations/bg.json | 3 ++- .../components/nest/translations/ca.json | 2 +- .../components/nest/translations/ja.json | 21 +++++++++++++++++++ .../components/nest/translations/pl.json | 5 ++++- 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index 4b10ed2d8ac..d01e6e59a4b 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { @@ -11,7 +12,9 @@ "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", + "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", + "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", @@ -48,9 +51,13 @@ "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", + "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", + "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", + "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", + "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", diff --git a/homeassistant/components/group/translations/bg.json b/homeassistant/components/group/translations/bg.json index d0982fcfb66..5ecb4d66328 100644 --- a/homeassistant/components/group/translations/bg.json +++ b/homeassistant/components/group/translations/bg.json @@ -17,7 +17,8 @@ "data": { "entities": "\u0427\u043b\u0435\u043d\u043e\u0432\u0435", "name": "\u0418\u043c\u0435" - } + }, + "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0430" }, "media_player": { "data": { diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index c7b617f01d6..7b1a9c91bb4 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -19,7 +19,7 @@ "subscriber_error": "Error de subscriptor desconegut, consulta els registres", "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi.", "unknown": "Error inesperat", - "wrong_project_id": "Introdueix un ID de projecte Cloud v\u00e0lid (s'ha trobat un ID de projecte d'Acc\u00e9s de Dispositiu)" + "wrong_project_id": "Introdueix un ID de projecte Cloud v\u00e0lid (era el mateix que l'ID de projecte Device Access)" }, "step": { "auth": { diff --git a/homeassistant/components/nest/translations/ja.json b/homeassistant/components/nest/translations/ja.json index c2994de0532..37613407e4d 100644 --- a/homeassistant/components/nest/translations/ja.json +++ b/homeassistant/components/nest/translations/ja.json @@ -29,6 +29,27 @@ "description": "Google\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b\u306b\u306f\u3001 [authorize your account]({url}) \u3092\u30af\u30ea\u30c3\u30af\u3057\u3066\u304f\u3060\u3055\u3044\u3002\n\n\u8a8d\u8a3c\u5f8c\u3001\u63d0\u4f9b\u3055\u308c\u305f\u8a8d\u8a3c\u30c8\u30fc\u30af\u30f3\u306e\u30b3\u30fc\u30c9\u3092\u4ee5\u4e0b\u306b\u30b3\u30d4\u30fc\u30da\u30fc\u30b9\u30c8\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "title": "Google\u30a2\u30ab\u30a6\u30f3\u30c8\u3092\u30ea\u30f3\u30af\u3059\u308b" }, + "auth_upgrade": { + "title": "\u30cd\u30b9\u30c8: \u30a2\u30d7\u30ea\u8a8d\u8a3c\u306e\u975e\u63a8\u5968" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID" + }, + "title": "\u30cd\u30b9\u30c8: \u30af\u30e9\u30a6\u30c9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID\u3092\u5165\u529b" + }, + "create_cloud_project": { + "title": "\u30cd\u30b9\u30c8: \u30af\u30e9\u30a6\u30c9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u4f5c\u6210\u3068\u8a2d\u5b9a" + }, + "device_project": { + "data": { + "project_id": "\u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8ID" + }, + "title": "\u30cd\u30b9\u30c8: \u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u4f5c\u6210" + }, + "device_project_upgrade": { + "title": "\u30cd\u30b9\u30c8: \u30c7\u30d0\u30a4\u30b9\u30a2\u30af\u30bb\u30b9\u30d7\u30ed\u30b8\u30a7\u30af\u30c8\u306e\u66f4\u65b0" + }, "init": { "data": { "flow_impl": "\u30d7\u30ed\u30d0\u30a4\u30c0\u30fc" diff --git a/homeassistant/components/nest/translations/pl.json b/homeassistant/components/nest/translations/pl.json index 5e9b9895db4..4c53185e71f 100644 --- a/homeassistant/components/nest/translations/pl.json +++ b/homeassistant/components/nest/translations/pl.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Post\u0119puj zgodnie z [instrukcj\u0105]({more_info_url}), aby skonfigurowa\u0107 Cloud Console: \n\n1. Przejd\u017a do [ekranu akceptacji OAuth]( {oauth_consent_url} ) i skonfiguruj\n2. Przejd\u017a do [Po\u015bwiadczenia]({oauth_creds_url}) i kliknij **Utw\u00f3rz po\u015bwiadczenia**.\n3. Z listy rozwijanej wybierz **ID klienta OAuth**.\n4. Wybierz **Aplikacja internetowa** jako Typ aplikacji.\n5. Dodaj `{redirect_url}` pod *Autoryzowany URI przekierowania*." + }, "config": { "abort": { "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji", @@ -19,7 +22,7 @@ "subscriber_error": "Nieznany b\u0142\u0105d subskrybenta, zobacz logi", "timeout": "Przekroczono limit czasu sprawdzania poprawno\u015bci kodu", "unknown": "Nieoczekiwany b\u0142\u0105d", - "wrong_project_id": "Podaj prawid\u0142owy Identyfikator projektu chmury (znaleziono identyfikator projektu dost\u0119pu do urz\u0105dzenia)" + "wrong_project_id": "Podaj prawid\u0142owy Identyfikator projektu chmury (taki sam jak identyfikator projektu dost\u0119pu do urz\u0105dzenia)" }, "step": { "auth": { From 7a792b093f5a17491a97288f810b2fde5eb921f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jun 2022 21:57:44 -0500 Subject: [PATCH 481/947] Fix calling permanent off with nexia (#73623) * Fix calling permanent off with nexia Changelog: https://github.com/bdraco/nexia/compare/1.0.1...1.0.2 Fixes #73610 * one more --- homeassistant/components/nexia/climate.py | 4 +++- homeassistant/components/nexia/manifest.json | 2 +- homeassistant/components/nexia/switch.py | 6 +++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 20fcf5c6b85..33ad91e1561 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -391,7 +391,9 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the system mode (Auto, Heat_Cool, Cool, Heat, etc).""" - if hvac_mode == HVACMode.AUTO: + if hvac_mode == HVACMode.OFF: + await self._zone.call_permanent_off() + elif hvac_mode == HVACMode.AUTO: await self._zone.call_return_to_schedule() await self._zone.set_mode(mode=OPERATION_MODE_AUTO) else: diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index f9ca21d9e0b..4bae2d9a15d 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia/American Standard/Trane", - "requirements": ["nexia==1.0.1"], + "requirements": ["nexia==1.0.2"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/homeassistant/components/nexia/switch.py b/homeassistant/components/nexia/switch.py index 380fea8c4a0..e242032c947 100644 --- a/homeassistant/components/nexia/switch.py +++ b/homeassistant/components/nexia/switch.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import Any +from nexia.const import OPERATION_MODE_OFF from nexia.home import NexiaHome from nexia.thermostat import NexiaThermostat from nexia.zone import NexiaThermostatZone @@ -58,7 +59,10 @@ class NexiaHoldSwitch(NexiaThermostatZoneEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Enable permanent hold.""" - await self._zone.call_permanent_hold() + if self._zone.get_current_mode() == OPERATION_MODE_OFF: + await self._zone.call_permanent_off() + else: + await self._zone.call_permanent_hold() self._signal_zone_update() async def async_turn_off(self, **kwargs: Any) -> None: diff --git a/requirements_all.txt b/requirements_all.txt index 6ea4e85475a..79ba4689352 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1083,7 +1083,7 @@ nettigo-air-monitor==1.3.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==1.0.1 +nexia==1.0.2 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b696ce6829e..886c0921e27 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -748,7 +748,7 @@ netmap==0.7.0.2 nettigo-air-monitor==1.3.0 # homeassistant.components.nexia -nexia==1.0.1 +nexia==1.0.2 # homeassistant.components.discord nextcord==2.0.0a8 From 0a272113566f8f99f5370cc4ecca63466102fc6a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 17 Jun 2022 22:45:20 -0500 Subject: [PATCH 482/947] Switch bond data to use a dataclass (#73514) --- homeassistant/components/bond/__init__.py | 15 ++++++--------- homeassistant/components/bond/button.py | 12 +++++------- homeassistant/components/bond/const.py | 3 --- homeassistant/components/bond/cover.py | 10 ++++++---- homeassistant/components/bond/diagnostics.py | 8 ++++---- homeassistant/components/bond/fan.py | 9 +++++---- homeassistant/components/bond/light.py | 9 ++++----- homeassistant/components/bond/models.py | 16 ++++++++++++++++ homeassistant/components/bond/switch.py | 18 ++++++------------ 9 files changed, 52 insertions(+), 48 deletions(-) create mode 100644 homeassistant/components/bond/models.py diff --git a/homeassistant/components/bond/__init__.py b/homeassistant/components/bond/__init__.py index 476423631c3..7dca4db507d 100644 --- a/homeassistant/components/bond/__init__.py +++ b/homeassistant/components/bond/__init__.py @@ -20,7 +20,8 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import SLOW_UPDATE_WARNING -from .const import BPUP_SUBS, BRIDGE_MAKE, DOMAIN, HUB +from .const import BRIDGE_MAKE, DOMAIN +from .models import BondData from .utils import BondHub PLATFORMS = [ @@ -69,11 +70,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, _async_stop_event) ) - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = { - HUB: hub, - BPUP_SUBS: bpup_subs, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = BondData(hub, bpup_subs) if not entry.unique_id: hass.config_entries.async_update_entry(entry, unique_id=hub.bond_id) @@ -102,8 +99,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -125,7 +121,8 @@ async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: """Remove bond config entry from a device.""" - hub: BondHub = hass.data[DOMAIN][config_entry.entry_id][HUB] + data: BondData = hass.data[DOMAIN][config_entry.entry_id] + hub = data.hub for identifier in device_entry.identifiers: if identifier[0] != DOMAIN or len(identifier) != 3: continue diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index ffdb01b9d88..2c6ffc69693 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -import logging from typing import Any from bond_async import Action, BPUPSubscriptions @@ -12,12 +11,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import DOMAIN from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub -_LOGGER = logging.getLogger(__name__) - # The api requires a step size even though it does not # seem to matter what is is as the underlying device is likely # getting an increase/decrease signal only @@ -246,9 +244,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond button devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs entities: list[BondButtonEntity] = [] for device in hub.devices: diff --git a/homeassistant/components/bond/const.py b/homeassistant/components/bond/const.py index 778dcbc1a1f..91197763d23 100644 --- a/homeassistant/components/bond/const.py +++ b/homeassistant/components/bond/const.py @@ -7,9 +7,6 @@ DOMAIN = "bond" CONF_BOND_ID: str = "bond_id" -HUB = "hub" -BPUP_SUBS = "bpup_subs" - SERVICE_SET_FAN_SPEED_TRACKED_STATE = "set_fan_speed_tracked_state" SERVICE_SET_POWER_TRACKED_STATE = "set_switch_power_tracked_state" SERVICE_SET_LIGHT_POWER_TRACKED_STATE = "set_light_power_tracked_state" diff --git a/homeassistant/components/bond/cover.py b/homeassistant/components/bond/cover.py index efe72f947f4..0a3e9048451 100644 --- a/homeassistant/components/bond/cover.py +++ b/homeassistant/components/bond/cover.py @@ -15,8 +15,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import BPUP_SUBS, DOMAIN, HUB +from .const import DOMAIN from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub @@ -36,9 +37,10 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond cover devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs + async_add_entities( BondCover(hub, device, bpup_subs) for device in hub.devices diff --git a/homeassistant/components/bond/diagnostics.py b/homeassistant/components/bond/diagnostics.py index 6af62c3fb24..53e8b5c8225 100644 --- a/homeassistant/components/bond/diagnostics.py +++ b/homeassistant/components/bond/diagnostics.py @@ -7,8 +7,8 @@ from homeassistant.components.diagnostics import async_redact_data from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from .const import DOMAIN, HUB -from .utils import BondHub +from .const import DOMAIN +from .models import BondData TO_REDACT = {"access_token"} @@ -17,8 +17,8 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub return { "entry": { "title": entry.title, diff --git a/homeassistant/components/bond/fan.py b/homeassistant/components/bond/fan.py index 12eef9c44b0..d1121e4a3a8 100644 --- a/homeassistant/components/bond/fan.py +++ b/homeassistant/components/bond/fan.py @@ -26,8 +26,9 @@ from homeassistant.util.percentage import ( ranged_value_to_percentage, ) -from .const import BPUP_SUBS, DOMAIN, HUB, SERVICE_SET_FAN_SPEED_TRACKED_STATE +from .const import DOMAIN, SERVICE_SET_FAN_SPEED_TRACKED_STATE from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) @@ -41,9 +42,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond fan devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_FAN_SPEED_TRACKED_STATE, diff --git a/homeassistant/components/bond/light.py b/homeassistant/components/bond/light.py index 5a76ea6a13e..2fcff44ddc1 100644 --- a/homeassistant/components/bond/light.py +++ b/homeassistant/components/bond/light.py @@ -18,13 +18,12 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_POWER_STATE, - BPUP_SUBS, DOMAIN, - HUB, SERVICE_SET_LIGHT_BRIGHTNESS_TRACKED_STATE, SERVICE_SET_LIGHT_POWER_TRACKED_STATE, ) from .entity import BondEntity +from .models import BondData from .utils import BondDevice, BondHub _LOGGER = logging.getLogger(__name__) @@ -46,9 +45,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond light devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs platform = entity_platform.async_get_current_platform() platform = entity_platform.async_get_current_platform() diff --git a/homeassistant/components/bond/models.py b/homeassistant/components/bond/models.py new file mode 100644 index 00000000000..0caa01af7a0 --- /dev/null +++ b/homeassistant/components/bond/models.py @@ -0,0 +1,16 @@ +"""The bond integration models.""" +from __future__ import annotations + +from dataclasses import dataclass + +from bond_async import BPUPSubscriptions + +from .utils import BondHub + + +@dataclass +class BondData: + """Data for the bond integration.""" + + hub: BondHub + bpup_subs: BPUPSubscriptions diff --git a/homeassistant/components/bond/switch.py b/homeassistant/components/bond/switch.py index a88be924610..afa5e1cee10 100644 --- a/homeassistant/components/bond/switch.py +++ b/homeassistant/components/bond/switch.py @@ -4,7 +4,7 @@ from __future__ import annotations from typing import Any from aiohttp.client_exceptions import ClientResponseError -from bond_async import Action, BPUPSubscriptions, DeviceType +from bond_async import Action, DeviceType import voluptuous as vol from homeassistant.components.switch import SwitchEntity @@ -14,15 +14,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - ATTR_POWER_STATE, - BPUP_SUBS, - DOMAIN, - HUB, - SERVICE_SET_POWER_TRACKED_STATE, -) +from .const import ATTR_POWER_STATE, DOMAIN, SERVICE_SET_POWER_TRACKED_STATE from .entity import BondEntity -from .utils import BondHub +from .models import BondData async def async_setup_entry( @@ -31,9 +25,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up Bond generic devices.""" - data = hass.data[DOMAIN][entry.entry_id] - hub: BondHub = data[HUB] - bpup_subs: BPUPSubscriptions = data[BPUP_SUBS] + data: BondData = hass.data[DOMAIN][entry.entry_id] + hub = data.hub + bpup_subs = data.bpup_subs platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_SET_POWER_TRACKED_STATE, From f4c3bd7e0069e7a7d11a8ff08865b86815a500d3 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Sat, 18 Jun 2022 11:41:26 +0200 Subject: [PATCH 483/947] Fix issue with pandas wheels (#73669) * Fix issue with pandas wheels * Update builder --- .github/workflows/wheels.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e86f8235582..15aef8b752a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -138,9 +138,10 @@ jobs: for requirement_file in ${requirement_files}; do sed -i "s|numpy==1.21.6|numpy==1.22.4|g" ${requirement_file} done + echo "numpy==1.22.4" >> homeassistant/package_constraints.txt - name: Build wheels - uses: home-assistant/wheels@2022.06.5 + uses: home-assistant/wheels@2022.06.6 with: abi: cp310 tag: musllinux_1_2 @@ -271,9 +272,10 @@ jobs: for requirement_file in ${requirement_files}; do sed -i "s|numpy==1.21.6|numpy==1.22.4|g" ${requirement_file} done + echo "numpy==1.22.4" >> homeassistant/package_constraints.txt - name: Build wheels - uses: home-assistant/wheels@2022.06.5 + uses: home-assistant/wheels@2022.06.6 with: abi: cp310 tag: musllinux_1_2 From 691d49f23ba42c4ee7f053920b10b41322913730 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sat, 18 Jun 2022 13:56:28 -0400 Subject: [PATCH 484/947] Refactor migration code for UniFi Protect (#73499) --- .../components/unifiprotect/__init__.py | 60 +------ .../components/unifiprotect/migrate.py | 83 +++++++++ tests/components/unifiprotect/test_init.py | 144 ---------------- tests/components/unifiprotect/test_migrate.py | 158 ++++++++++++++++++ 4 files changed, 244 insertions(+), 201 deletions(-) create mode 100644 homeassistant/components/unifiprotect/migrate.py create mode 100644 tests/components/unifiprotect/test_migrate.py diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index 4ec11a899e3..d05f544ada1 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -17,11 +17,10 @@ from homeassistant.const import ( CONF_USERNAME, CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, - Platform, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_create_clientsession from .const import ( @@ -37,6 +36,7 @@ from .const import ( ) from .data import ProtectData, async_ufp_instance_for_config_entry_ids from .discovery import async_start_discovery +from .migrate import async_migrate_data from .services import async_cleanup_services, async_setup_services from .utils import _async_unifi_mac_from_hass, async_get_devices @@ -45,60 +45,6 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL) -async def _async_migrate_data( - hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient -) -> None: - - registry = er.async_get(hass) - to_migrate = [] - for entity in er.async_entries_for_config_entry(registry, entry.entry_id): - if entity.domain == Platform.BUTTON and "_" not in entity.unique_id: - _LOGGER.debug("Button %s needs migration", entity.entity_id) - to_migrate.append(entity) - - if len(to_migrate) == 0: - _LOGGER.debug("No entities need migration") - return - - _LOGGER.info("Migrating %s reboot button entities ", len(to_migrate)) - bootstrap = await protect.get_bootstrap() - count = 0 - for button in to_migrate: - device = None - for model in DEVICES_THAT_ADOPT: - attr = f"{model.value}s" - device = getattr(bootstrap, attr).get(button.unique_id) - if device is not None: - break - - if device is None: - continue - - new_unique_id = f"{device.id}_reboot" - _LOGGER.debug( - "Migrating entity %s (old unique_id: %s, new unique_id: %s)", - button.entity_id, - button.unique_id, - new_unique_id, - ) - try: - registry.async_update_entity(button.entity_id, new_unique_id=new_unique_id) - except ValueError: - _LOGGER.warning( - "Could not migrate entity %s (old unique_id: %s, new unique_id: %s)", - button.entity_id, - button.unique_id, - new_unique_id, - ) - else: - count += 1 - - if count < len(to_migrate): - _LOGGER.warning("Failed to migate %s reboot buttons", len(to_migrate) - count) - else: - _LOGGER.info("Migrated %s reboot button entities", count) - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the UniFi Protect config entries.""" @@ -133,7 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False - await _async_migrate_data(hass, entry, protect) + await async_migrate_data(hass, entry, protect) if entry.unique_id is None: hass.config_entries.async_update_entry(entry, unique_id=nvr_info.mac) diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py new file mode 100644 index 00000000000..dcba0b504c9 --- /dev/null +++ b/homeassistant/components/unifiprotect/migrate.py @@ -0,0 +1,83 @@ +"""UniFi Protect data migrations.""" +from __future__ import annotations + +import logging + +from pyunifiprotect import ProtectApiClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import DEVICES_THAT_ADOPT + +_LOGGER = logging.getLogger(__name__) + + +async def async_migrate_data( + hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient +) -> None: + """Run all valid UniFi Protect data migrations.""" + + _LOGGER.debug("Start Migrate: async_migrate_buttons") + await async_migrate_buttons(hass, entry, protect) + _LOGGER.debug("Completed Migrate: async_migrate_buttons") + + +async def async_migrate_buttons( + hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient +) -> None: + """ + Migrate existing Reboot button unique IDs from {device_id} to {deivce_id}_reboot. + + This allows for additional types of buttons that are outside of just a reboot button. + + Added in 2022.6.0. + """ + + registry = er.async_get(hass) + to_migrate = [] + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): + if entity.domain == Platform.BUTTON and "_" not in entity.unique_id: + _LOGGER.debug("Button %s needs migration", entity.entity_id) + to_migrate.append(entity) + + if len(to_migrate) == 0: + _LOGGER.debug("No button entities need migration") + return + + bootstrap = await protect.get_bootstrap() + count = 0 + for button in to_migrate: + device = None + for model in DEVICES_THAT_ADOPT: + attr = f"{model.value}s" + device = getattr(bootstrap, attr).get(button.unique_id) + if device is not None: + break + + if device is None: + continue + + new_unique_id = f"{device.id}_reboot" + _LOGGER.debug( + "Migrating entity %s (old unique_id: %s, new unique_id: %s)", + button.entity_id, + button.unique_id, + new_unique_id, + ) + try: + registry.async_update_entity(button.entity_id, new_unique_id=new_unique_id) + except ValueError: + _LOGGER.warning( + "Could not migrate entity %s (old unique_id: %s, new unique_id: %s)", + button.entity_id, + button.unique_id, + new_unique_id, + ) + else: + count += 1 + + if count < len(to_migrate): + _LOGGER.warning("Failed to migate %s reboot buttons", len(to_migrate) - count) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index cf899d854fd..68f171b52bf 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -11,7 +11,6 @@ from pyunifiprotect.data import NVR, Light from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component @@ -199,149 +198,6 @@ async def test_setup_starts_discovery( assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 -async def test_migrate_reboot_button( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Test migrating unique ID of reboot button.""" - - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - light1.id = "lightid1" - - light2 = mock_light.copy() - light2._api = mock_entry.api - light2.name = "Test Light 2" - light2.id = "lightid2" - mock_entry.api.bootstrap.lights = { - light1.id: light1, - light2.id: light2, - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - - registry = er.async_get(hass) - registry.async_get_or_create( - Platform.BUTTON, DOMAIN, light1.id, config_entry=mock_entry.entry - ) - registry.async_get_or_create( - Platform.BUTTON, - DOMAIN, - f"{light2.id}_reboot", - config_entry=mock_entry.entry, - ) - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac - - buttons = [] - for entity in er.async_entries_for_config_entry( - registry, mock_entry.entry.entry_id - ): - if entity.domain == Platform.BUTTON.value: - buttons.append(entity) - print(entity.entity_id) - assert len(buttons) == 2 - - assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") is None - assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid1") - assert light is not None - assert light.unique_id == f"{light1.id}_reboot" - - assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") is None - assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2_reboot") - assert light is not None - assert light.unique_id == f"{light2.id}_reboot" - - -async def test_migrate_reboot_button_no_device( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Test migrating unique ID of reboot button if UniFi Protect device ID changed.""" - - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - light1.id = "lightid1" - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - - registry = er.async_get(hass) - registry.async_get_or_create( - Platform.BUTTON, DOMAIN, "lightid2", config_entry=mock_entry.entry - ) - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac - - buttons = [] - for entity in er.async_entries_for_config_entry( - registry, mock_entry.entry.entry_id - ): - if entity.domain == Platform.BUTTON.value: - buttons.append(entity) - assert len(buttons) == 2 - - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2") - assert light is not None - assert light.unique_id == "lightid2" - - -async def test_migrate_reboot_button_fail( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Test migrating unique ID of reboot button.""" - - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - light1.id = "lightid1" - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - - registry = er.async_get(hass) - registry.async_get_or_create( - Platform.BUTTON, - DOMAIN, - light1.id, - config_entry=mock_entry.entry, - suggested_object_id=light1.name, - ) - registry.async_get_or_create( - Platform.BUTTON, - DOMAIN, - f"{light1.id}_reboot", - config_entry=mock_entry.entry, - suggested_object_id=light1.name, - ) - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac - - light = registry.async_get(f"{Platform.BUTTON}.test_light_1") - assert light is not None - assert light.unique_id == f"{light1.id}" - - async def test_device_remove_devices( hass: HomeAssistant, mock_entry: MockEntityFixture, diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py new file mode 100644 index 00000000000..756672bcbca --- /dev/null +++ b/tests/components/unifiprotect/test_migrate.py @@ -0,0 +1,158 @@ +"""Test the UniFi Protect setup flow.""" +# pylint: disable=protected-access +from __future__ import annotations + +from unittest.mock import AsyncMock + +from pyunifiprotect.data import Light + +from homeassistant.components.unifiprotect.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import MockEntityFixture + + +async def test_migrate_reboot_button( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Test migrating unique ID of reboot button.""" + + light1 = mock_light.copy() + light1._api = mock_entry.api + light1.name = "Test Light 1" + light1.id = "lightid1" + + light2 = mock_light.copy() + light2._api = mock_entry.api + light2.name = "Test Light 2" + light2.id = "lightid2" + mock_entry.api.bootstrap.lights = { + light1.id: light1, + light2.id: light2, + } + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, DOMAIN, light1.id, config_entry=mock_entry.entry + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light2.id}_reboot", + config_entry=mock_entry.entry, + ) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + buttons = [] + for entity in er.async_entries_for_config_entry( + registry, mock_entry.entry.entry_id + ): + if entity.domain == Platform.BUTTON.value: + buttons.append(entity) + print(entity.entity_id) + assert len(buttons) == 2 + + assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") is None + assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid1") + assert light is not None + assert light.unique_id == f"{light1.id}_reboot" + + assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") is None + assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2_reboot") + assert light is not None + assert light.unique_id == f"{light2.id}_reboot" + + +async def test_migrate_reboot_button_no_device( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Test migrating unique ID of reboot button if UniFi Protect device ID changed.""" + + light1 = mock_light.copy() + light1._api = mock_entry.api + light1.name = "Test Light 1" + light1.id = "lightid1" + + mock_entry.api.bootstrap.lights = { + light1.id: light1, + } + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, DOMAIN, "lightid2", config_entry=mock_entry.entry + ) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + buttons = [] + for entity in er.async_entries_for_config_entry( + registry, mock_entry.entry.entry_id + ): + if entity.domain == Platform.BUTTON.value: + buttons.append(entity) + assert len(buttons) == 2 + + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2") + assert light is not None + assert light.unique_id == "lightid2" + + +async def test_migrate_reboot_button_fail( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Test migrating unique ID of reboot button.""" + + light1 = mock_light.copy() + light1._api = mock_entry.api + light1.name = "Test Light 1" + light1.id = "lightid1" + + mock_entry.api.bootstrap.lights = { + light1.id: light1, + } + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + light1.id, + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light1.id}_reboot", + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + light = registry.async_get(f"{Platform.BUTTON}.test_light_1") + assert light is not None + assert light.unique_id == f"{light1.id}" From 046d7d2a239e0eab370ff9d11b1885ee9c69dffe Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 18 Jun 2022 19:58:10 +0200 Subject: [PATCH 485/947] Add tests for trafikverket_ferry (#71912) --- .coveragerc | 3 - .../components/trafikverket_ferry/__init__.py | 17 +++ .../components/trafikverket_ferry/conftest.py | 80 +++++++++++++ .../trafikverket_ferry/test_coordinator.py | 107 ++++++++++++++++++ .../trafikverket_ferry/test_init.py | 61 ++++++++++ .../trafikverket_ferry/test_sensor.py | 48 ++++++++ 6 files changed, 313 insertions(+), 3 deletions(-) create mode 100644 tests/components/trafikverket_ferry/conftest.py create mode 100644 tests/components/trafikverket_ferry/test_coordinator.py create mode 100644 tests/components/trafikverket_ferry/test_init.py create mode 100644 tests/components/trafikverket_ferry/test_sensor.py diff --git a/.coveragerc b/.coveragerc index fc40f0f35b9..46cbed3a0dc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1301,9 +1301,6 @@ omit = homeassistant/components/tradfri/light.py homeassistant/components/tradfri/sensor.py homeassistant/components/tradfri/switch.py - homeassistant/components/trafikverket_ferry/__init__.py - homeassistant/components/trafikverket_ferry/coordinator.py - homeassistant/components/trafikverket_ferry/sensor.py homeassistant/components/trafikverket_train/__init__.py homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/__init__.py diff --git a/tests/components/trafikverket_ferry/__init__.py b/tests/components/trafikverket_ferry/__init__.py index 4a1491c5bed..97bedb30281 100644 --- a/tests/components/trafikverket_ferry/__init__.py +++ b/tests/components/trafikverket_ferry/__init__.py @@ -1 +1,18 @@ """Tests for the Trafikverket Ferry integration.""" +from __future__ import annotations + +from homeassistant.components.trafikverket_ferry.const import ( + CONF_FROM, + CONF_TIME, + CONF_TO, +) +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS + +ENTRY_CONFIG = { + CONF_API_KEY: "1234567890", + CONF_FROM: "Harbor 1", + CONF_TO: "Harbor 2", + CONF_TIME: "00:00:00", + CONF_WEEKDAY: WEEKDAYS, + CONF_NAME: "Harbor1", +} diff --git a/tests/components/trafikverket_ferry/conftest.py b/tests/components/trafikverket_ferry/conftest.py new file mode 100644 index 00000000000..452c351ee5d --- /dev/null +++ b/tests/components/trafikverket_ferry/conftest.py @@ -0,0 +1,80 @@ +"""Fixtures for Trafikverket Ferry integration tests.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +import pytest +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant.components.trafikverket_ferry.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="load_int") +async def load_integration_from_entry( + hass: HomeAssistant, get_ferries: list[FerryStop] +) -> MockConfigEntry: + """Set up the Trafikverket Ferry integration in Home Assistant.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + ) + + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry + + +@pytest.fixture(name="get_ferries") +def fixture_get_ferries() -> list[FerryStop]: + """Construct FerryStop Mock.""" + + depart1 = FerryStop( + "13", + False, + datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC), + [""], + "0", + datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + "Harbor 1", + "Harbor 2", + ) + depart2 = FerryStop( + "14", + False, + datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=15), + [""], + "0", + datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + "Harbor 1", + "Harbor 2", + ) + depart3 = FerryStop( + "15", + False, + datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=30), + [""], + "0", + datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + "Harbor 1", + "Harbor 2", + ) + + return [depart1, depart2, depart3] diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py new file mode 100644 index 00000000000..7714e0c38f6 --- /dev/null +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -0,0 +1,107 @@ +"""The test for the Trafikverket Ferry coordinator.""" +from __future__ import annotations + +from datetime import date, datetime, timedelta +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant.components.trafikverket_ferry.const import DOMAIN +from homeassistant.components.trafikverket_ferry.coordinator import next_departuredate +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import STATE_UNAVAILABLE, WEEKDAYS +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + monkeypatch: pytest.MonkeyPatch, + get_ferries: list[FerryStop], +) -> None: + """Test the Trafikverket Ferry coordinator.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ) as mock_data: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + state2 = hass.states.get("sensor.harbor1_departure_to") + state3 = hass.states.get("sensor.harbor1_departure_time") + assert state1.state == "Harbor 1" + assert state2.state == "Harbor 2" + assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + mock_data.reset_mock() + + monkeypatch.setattr( + get_ferries[0], + "departure_time", + datetime(dt.now().year + 2, 5, 1, 12, 0, tzinfo=dt.UTC), + ) + + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + state2 = hass.states.get("sensor.harbor1_departure_to") + state3 = hass.states.get("sensor.harbor1_departure_time") + assert state1.state == "Harbor 1" + assert state2.state == "Harbor 2" + assert state3.state == str(dt.now().year + 2) + "-05-01T12:00:00+00:00" + mock_data.reset_mock() + + mock_data.side_effect = ValueError("info") + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == STATE_UNAVAILABLE + mock_data.reset_mock() + + mock_data.return_value = get_ferries + mock_data.side_effect = None + async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == "Harbor 1" + mock_data.reset_mock() + + +async def test_coordinator_next_departuredate(freezer: FrozenDateTimeFactory) -> None: + """Test the Trafikverket Ferry next_departuredate calculation.""" + freezer.move_to("2022-05-15") + today = date.today() + day_list = ["wed", "thu", "fri", "sat"] + test = next_departuredate(day_list) + assert test == today + timedelta(days=3) + day_list = WEEKDAYS + test = next_departuredate(day_list) + assert test == today + timedelta(days=0) + day_list = ["sun"] + test = next_departuredate(day_list) + assert test == today + timedelta(days=0) + freezer.move_to("2022-05-16") + today = date.today() + day_list = ["wed", "thu", "fri", "sat", "sun"] + test = next_departuredate(day_list) + assert test == today + timedelta(days=2) diff --git a/tests/components/trafikverket_ferry/test_init.py b/tests/components/trafikverket_ferry/test_init.py new file mode 100644 index 00000000000..d5063ab704c --- /dev/null +++ b/tests/components/trafikverket_ferry/test_init.py @@ -0,0 +1,61 @@ +"""Test for Trafikverket Ferry component Init.""" +from __future__ import annotations + +from unittest.mock import patch + +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant import config_entries +from homeassistant.components.trafikverket_ferry.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant + +from . import ENTRY_CONFIG + +from tests.common import MockConfigEntry + + +async def test_setup_entry(hass: HomeAssistant, get_ferries: list[FerryStop]) -> None: + """Test setup entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="123", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ) as mock_tvt_ferry: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert len(mock_tvt_ferry.mock_calls) == 1 + + +async def test_unload_entry(hass: HomeAssistant, get_ferries: list[FerryStop]) -> None: + """Test unload an entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data=ENTRY_CONFIG, + entry_id="1", + unique_id="321", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is config_entries.ConfigEntryState.LOADED + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is config_entries.ConfigEntryState.NOT_LOADED diff --git a/tests/components/trafikverket_ferry/test_sensor.py b/tests/components/trafikverket_ferry/test_sensor.py new file mode 100644 index 00000000000..4353eb5c8ba --- /dev/null +++ b/tests/components/trafikverket_ferry/test_sensor.py @@ -0,0 +1,48 @@ +"""The test for the Trafikverket sensor platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from pytest import MonkeyPatch +from pytrafikverket.trafikverket_ferry import FerryStop + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util import dt + +from tests.common import async_fire_time_changed + + +async def test_sensor( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_ferries: list[FerryStop], +) -> None: + """Test the Trafikverket Ferry sensor.""" + state1 = hass.states.get("sensor.harbor1_departure_from") + state2 = hass.states.get("sensor.harbor1_departure_to") + state3 = hass.states.get("sensor.harbor1_departure_time") + assert state1.state == "Harbor 1" + assert state2.state == "Harbor 2" + assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + assert state1.attributes["icon"] == "mdi:ferry" + assert state1.attributes["other_information"] == [""] + assert state2.attributes["icon"] == "mdi:ferry" + + monkeypatch.setattr(get_ferries[0], "other_information", ["Nothing exiting"]) + + with patch( + "homeassistant.components.trafikverket_ferry.coordinator.TrafikverketFerry.async_get_next_ferry_stops", + return_value=get_ferries, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=6), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == "Harbor 1" + assert state1.attributes["other_information"] == ["Nothing exiting"] From d5df2b2ee771c8ce607c87a4b522295676f404b9 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 18 Jun 2022 22:15:44 +0200 Subject: [PATCH 486/947] Sensibo Add Pure Boost Service (#73114) * Pure Boost Service * Fix tests * Fix mypy * One service to two services * Minor fix test * Fix issues --- homeassistant/components/sensibo/climate.py | 61 +++++++ homeassistant/components/sensibo/entity.py | 7 + .../components/sensibo/services.yaml | 53 ++++++ tests/components/sensibo/test_climate.py | 156 ++++++++++++++++++ 4 files changed, 277 insertions(+) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index f0ce7b74c01..c146ed350f3 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -29,6 +29,16 @@ from .entity import SensiboDeviceBaseEntity SERVICE_ASSUME_STATE = "assume_state" SERVICE_TIMER = "timer" ATTR_MINUTES = "minutes" +SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost" +SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" + +ATTR_AC_INTEGRATION = "ac_integration" +ATTR_GEO_INTEGRATION = "geo_integration" +ATTR_INDOOR_INTEGRATION = "indoor_integration" +ATTR_OUTDOOR_INTEGRATION = "outdoor_integration" +ATTR_SENSITIVITY = "sensitivity" +BOOST_INCLUSIVE = "boost_inclusive" + PARALLEL_UPDATES = 0 FIELD_TO_FLAG = { @@ -95,6 +105,24 @@ async def async_setup_entry( }, "async_set_timer", ) + platform.async_register_entity_service( + SERVICE_ENABLE_PURE_BOOST, + { + vol.Inclusive(ATTR_AC_INTEGRATION, "settings"): bool, + vol.Inclusive(ATTR_GEO_INTEGRATION, "settings"): bool, + vol.Inclusive(ATTR_INDOOR_INTEGRATION, "settings"): bool, + vol.Inclusive(ATTR_OUTDOOR_INTEGRATION, "settings"): bool, + vol.Inclusive(ATTR_SENSITIVITY, "settings"): vol.In( + ["Normal", "Sensitive"] + ), + }, + "async_enable_pure_boost", + ) + platform.async_register_entity_service( + SERVICE_DISABLE_PURE_BOOST, + {}, + "async_disable_pure_boost", + ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @@ -308,3 +336,36 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): if result["status"] == "success": return await self.coordinator.async_request_refresh() raise HomeAssistantError(f"Could not set timer for device {self.name}") + + async def async_enable_pure_boost( + self, + ac_integration: bool | None = None, + geo_integration: bool | None = None, + indoor_integration: bool | None = None, + outdoor_integration: bool | None = None, + sensitivity: str | None = None, + ) -> None: + """Enable Pure Boost Configuration.""" + + params: dict[str, str | bool] = { + "enabled": True, + } + if sensitivity is not None: + params["sensitivity"] = sensitivity[0] + if indoor_integration is not None: + params["measurementsIntegration"] = indoor_integration + if ac_integration is not None: + params["acIntegration"] = ac_integration + if geo_integration is not None: + params["geoIntegration"] = geo_integration + if outdoor_integration is not None: + params["primeIntegration"] = outdoor_integration + + await self.async_send_command("set_pure_boost", params) + await self.coordinator.async_refresh() + + async def async_disable_pure_boost(self) -> None: + """Disable Pure Boost Configuration.""" + + await self.async_send_command("set_pure_boost", {"enabled": False}) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index 430d7ac61ac..bf70f499ec6 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -99,6 +99,13 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): result = await self._client.async_set_timer(self._device_id, params) if command == "del_timer": result = await self._client.async_del_timer(self._device_id) + if command == "set_pure_boost": + if TYPE_CHECKING: + assert params is not None + result = await self._client.async_set_pureboost( + self._device_id, + params, + ) return result diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index 1a64f8703b4..67006074f6b 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -44,3 +44,56 @@ timer: min: 0 step: 1 mode: box +enable_pure_boost: + name: Enable Pure Boost + description: Enable and configure Pure Boost settings. + target: + entity: + integration: sensibo + domain: climate + fields: + ac_integration: + name: AC Integration + description: Integrate with Air Conditioner. + required: false + example: true + selector: + boolean: + geo_integration: + name: Geo Integration + description: Integrate with Presence. + required: false + example: true + selector: + boolean: + indoor_integration: + name: Indoor Air Quality + description: Integrate with checking indoor air quality. + required: false + example: true + selector: + boolean: + outdoor_integration: + name: Outdoor Air Quality + description: Integrate with checking outdoor air quality. + required: false + example: true + selector: + boolean: + sensitivity: + name: Sensitivity + description: Set the sensitivity for Pure Boost. + required: false + example: "Normal" + selector: + select: + options: + - "Normal" + - "Sensitive" +disable_pure_boost: + name: Disable Pure Boost + description: Disable Pure Boost. + target: + entity: + integration: sensibo + domain: climate diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 16e83162600..30356f2b00d 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -21,8 +21,15 @@ from homeassistant.components.climate.const import ( SERVICE_SET_TEMPERATURE, ) from homeassistant.components.sensibo.climate import ( + ATTR_AC_INTEGRATION, + ATTR_GEO_INTEGRATION, + ATTR_INDOOR_INTEGRATION, ATTR_MINUTES, + ATTR_OUTDOOR_INTEGRATION, + ATTR_SENSITIVITY, SERVICE_ASSUME_STATE, + SERVICE_DISABLE_PURE_BOOST, + SERVICE_ENABLE_PURE_BOOST, SERVICE_TIMER, _find_valid_target_temp, ) @@ -905,3 +912,152 @@ async def test_climate_set_timer_failures( blocking=True, ) await hass.async_block_till_done() + + +async def test_climate_pure_boost( + hass: HomeAssistant, + entity_registry_enabled_by_default: AsyncMock, + load_int: ConfigEntry, + monkeypatch: pytest.MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo climate assumed state service.""" + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_climate = hass.states.get("climate.kitchen") + state2 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") + assert state2.state == "off" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_INDOOR_INTEGRATION: True, + ATTR_OUTDOOR_INTEGRATION: True, + ATTR_SENSITIVITY: "Sensitive", + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + return_value={ + "status": "success", + "result": { + "enabled": True, + "sensitivity": "S", + "measurements_integration": True, + "ac_integration": False, + "geo_integration": False, + "prime_integration": True, + }, + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_AC_INTEGRATION: False, + ATTR_GEO_INTEGRATION: False, + ATTR_INDOOR_INTEGRATION: True, + ATTR_OUTDOOR_INTEGRATION: True, + ATTR_SENSITIVITY: "Sensitive", + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", True) + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_sensitivity", "s") + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_measure_integration", True) + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_prime_integration", True) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") + state2 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" + ) + state3 = hass.states.get( + "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" + ) + state4 = hass.states.get("sensor.kitchen_pure_sensitivity") + assert state1.state == "on" + assert state2.state == "on" + assert state3.state == "on" + assert state4.state == "s" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_set_pureboost", + return_value={ + "status": "success", + "result": { + "enabled": False, + "sensitivity": "S", + "measurements_integration": True, + "ac_integration": False, + "geo_integration": False, + "prime_integration": True, + }, + }, + ) as mock_set_pureboost: + await hass.services.async_call( + DOMAIN, + SERVICE_DISABLE_PURE_BOOST, + { + ATTR_ENTITY_ID: state_climate.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + mock_set_pureboost.assert_called_once() + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", False) + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_sensitivity", "s") + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") + state4 = hass.states.get("sensor.kitchen_pure_sensitivity") + assert state1.state == "off" + assert state4.state == "s" From dcf6c2d3a41aa45112011277c9ff97c051c3d866 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 19 Jun 2022 00:23:14 +0000 Subject: [PATCH 487/947] [ci skip] Translation update --- .../eight_sleep/translations/et.json | 19 ++++++++++++ .../components/google/translations/et.json | 3 ++ .../components/nest/translations/el.json | 29 +++++++++++++++++ .../components/nest/translations/et.json | 29 +++++++++++++++++ .../components/nest/translations/hu.json | 31 ++++++++++++++++++- .../components/nest/translations/id.json | 18 ++++++++++- 6 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/eight_sleep/translations/et.json diff --git a/homeassistant/components/eight_sleep/translations/et.json b/homeassistant/components/eight_sleep/translations/et.json new file mode 100644 index 00000000000..05ba852c7e4 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/et.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "cannot_connect": "Ei saa \u00fchendust Eight Sleep pilvega: {error}" + }, + "error": { + "cannot_connect": "Ei saa \u00fchendust Eight Sleep pilvega: {error}" + }, + "step": { + "user": { + "data": { + "password": "Salas\u00f5na", + "username": "Kasutajanimi" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/et.json b/homeassistant/components/google/translations/et.json index a115378f3a2..11447b1669f 100644 --- a/homeassistant/components/google/translations/et.json +++ b/homeassistant/components/google/translations/et.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "J\u00e4rgi [OAuth n\u00f5usoleku kuva]({oauth_consent_url}) [OAuth n\u00f5usoleku kuva] ) [juhiseid]({more_info_url}), et anda koduabilisele juurdep\u00e4\u00e4s oma Google'i kalendrile. Samuti pead looma kalendriga lingitud rakenduse identimisteabe.\n1. Mine aadressile [Mandaat]({oauth_creds_url}) ja kl\u00f5psake nuppu **Loo mandaat**.\n2. Vali ripploendist **OAuth kliendi ID**.\n3. Vali rakenduse t\u00fc\u00fcbi jaoks **TV ja piiratud sisendiga seadmed**.\n\n" + }, "config": { "abort": { "already_configured": "Kasutaja on juba seadistatud", diff --git a/homeassistant/components/nest/translations/el.json b/homeassistant/components/nest/translations/el.json index 1628a721733..26e4622670e 100644 --- a/homeassistant/components/nest/translations/el.json +++ b/homeassistant/components/nest/translations/el.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "\u0391\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b9\u03c2 [\u03bf\u03b4\u03b7\u03b3\u03af\u03b5\u03c2]( {more_info_url} ) \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03c3\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u039a\u03bf\u03bd\u03c3\u03cc\u03bb\u03b1 Cloud: \n\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [\u03bf\u03b8\u03cc\u03bd\u03b7 \u03c3\u03c5\u03bd\u03b1\u03af\u03bd\u03b5\u03c3\u03b7\u03c2 OAuth]( {oauth_consent_url} ) \u03ba\u03b1\u03b9 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03c3\u03c4\u03b5\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b1 [\u0394\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1] ( {oauth_creds_url} ) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03b7\u03c1\u03af\u03c9\u03bd**.\n 1. \u0391\u03c0\u03cc \u03c4\u03b7\u03bd \u03b1\u03bd\u03b1\u03c0\u03c4\u03c5\u03c3\u03c3\u03cc\u03bc\u03b5\u03bd\u03b7 \u03bb\u03af\u03c3\u03c4\u03b1 \u03b5\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **OAuth \u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7**.\n 1. \u0395\u03c0\u03b9\u03bb\u03ad\u03be\u03c4\u03b5 **\u0395\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae \u0399\u03c3\u03c4\u03bf\u03cd** \u03b3\u03b9\u03b1 \u03c4\u03bf\u03bd \u03a4\u03cd\u03c0\u03bf \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2.\n 1. \u03a0\u03c1\u03bf\u03c3\u03b8\u03ad\u03c3\u03c4\u03b5 \u03c4\u03bf \" {redirect_url} \" \u03c3\u03c4\u03bf *Authorized redirect URI*." + }, "config": { "abort": { "authorize_url_timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7\u03c2.", @@ -29,6 +32,32 @@ "description": "\u0393\u03b9\u03b1 \u03bd\u03b1 \u03c3\u03c5\u03bd\u03b4\u03ad\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc \u03c3\u03b1\u03c2 \u03c3\u03c4\u03bf Google, [\u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03bf\u03c4\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc \u03c3\u03b1\u03c2]({url}).\n\n\u039c\u03b5\u03c4\u03ac \u03c4\u03b7\u03bd \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7, \u03b1\u03bd\u03c4\u03b9\u03b3\u03c1\u03ac\u03c8\u03c4\u03b5-\u03b5\u03c0\u03b9\u03ba\u03bf\u03bb\u03bb\u03ae\u03c3\u03c4\u03b5 \u03c4\u03bf\u03bd \u03c0\u03b1\u03c1\u03b1\u03ba\u03ac\u03c4\u03c9 \u03c0\u03b1\u03c1\u03b5\u03c7\u03cc\u03bc\u03b5\u03bd\u03bf \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc Auth Token.", "title": "\u03a3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7 \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03bf\u03cd Google" }, + "auth_upgrade": { + "description": "\u03a4\u03bf App Auth \u03ad\u03c7\u03b5\u03b9 \u03ba\u03b1\u03c4\u03b1\u03c1\u03b3\u03b7\u03b8\u03b5\u03af \u03b1\u03c0\u03cc \u03c4\u03b7\u03bd Google \u03b3\u03b9\u03b1 \u03b2\u03b5\u03bb\u03c4\u03af\u03c9\u03c3\u03b7 \u03c4\u03b7\u03c2 \u03b1\u03c3\u03c6\u03ac\u03bb\u03b5\u03b9\u03b1\u03c2 \u03ba\u03b1\u03b9 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03c0\u03c1\u03bf\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03b5 \u03b5\u03bd\u03ad\u03c1\u03b3\u03b5\u03b9\u03b5\u03c2 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ce\u03bd\u03c4\u03b1\u03c2 \u03bd\u03ad\u03b1 \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03ae\u03c1\u03b9\u03b1 \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2. \n\n \u0391\u03bd\u03bf\u03af\u03be\u03c4\u03b5 \u03c4\u03bf [documentation]( {more_info_url} ) \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03b5\u03c4\u03b5, \u03ba\u03b1\u03b8\u03ce\u03c2 \u03c4\u03b1 \u03b5\u03c0\u03cc\u03bc\u03b5\u03bd\u03b1 \u03b2\u03ae\u03bc\u03b1\u03c4\u03b1 \u03b8\u03b1 \u03c3\u03b1\u03c2 \u03ba\u03b1\u03b8\u03bf\u03b4\u03b7\u03b3\u03ae\u03c3\u03bf\u03c5\u03bd \u03c3\u03c4\u03b1 \u03b2\u03ae\u03bc\u03b1\u03c4\u03b1 \u03c0\u03bf\u03c5 \u03c0\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03b5\u03c4\u03b5 \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b5\u03c0\u03b1\u03bd\u03b1\u03c6\u03ad\u03c1\u03b5\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7 \u03c3\u03c4\u03b9\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ad\u03c2 Nest.", + "title": "Nest: \u039a\u03b1\u03c4\u03ac\u03c1\u03b3\u03b7\u03c3\u03b7 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b5\u03c6\u03b1\u03c1\u03bc\u03bf\u03b3\u03ae\u03c2" + }, + "cloud_project": { + "data": { + "cloud_project_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 Google Cloud" + }, + "description": "\u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c4\u03bf\u03c5 \u03ad\u03c1\u03b3\u03bf\u03c5 Cloud \u03c0\u03b1\u03c1\u03b1\u03ba\u03ac\u03c4\u03c9, \u03c0.\u03c7. *example-project-12345*. \u0391\u03bd\u03b1\u03c4\u03c1\u03ad\u03be\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [Google Cloud Console]({cloud_console_url}) \u03ae \u03c3\u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7 \u03b3\u03b9\u03b1 [\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]({more_info_url}).", + "title": "Nest: \u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 Cloud" + }, + "create_cloud_project": { + "description": "\u0397 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7 Nest \u03c3\u03ac\u03c2 \u03b5\u03c0\u03b9\u03c4\u03c1\u03ad\u03c0\u03b5\u03b9 \u03bd\u03b1 \u03b5\u03bd\u03c3\u03c9\u03bc\u03b1\u03c4\u03ce\u03c3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03c5\u03c2 \u03b8\u03b5\u03c1\u03bc\u03bf\u03c3\u03c4\u03ac\u03c4\u03b5\u03c2 Nest, \u03c4\u03b9\u03c2 \u03ba\u03ac\u03bc\u03b5\u03c1\u03b5\u03c2 \u03ba\u03b1\u03b9 \u03c4\u03b1 \u03ba\u03bf\u03c5\u03b4\u03bf\u03cd\u03bd\u03b9\u03b1 \u03c0\u03cc\u03c1\u03c4\u03b1\u03c2 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03ce\u03bd\u03c4\u03b1\u03c2 \u03c4\u03bf API \u03b4\u03b9\u03b1\u03c7\u03b5\u03af\u03c1\u03b9\u03c3\u03b7\u03c2 \u03ad\u03be\u03c5\u03c0\u03bd\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2. \u03a4\u03bf SDM API **\u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03bc\u03b9\u03b1 \u03b5\u03c6\u03ac\u03c0\u03b1\u03be \u03c7\u03c1\u03ad\u03c9\u03c3\u03b7 \u03b5\u03b3\u03ba\u03b1\u03c4\u03ac\u03c3\u03c4\u03b1\u03c3\u03b7\u03c2 5 $**. \u0394\u03b5\u03af\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7 \u03b3\u03b9\u03b1 [\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]( {more_info_url} ). \n\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03bf [Google Cloud Console]( {cloud_console_url} ).\n 1. \u0395\u03ac\u03bd \u03b1\u03c5\u03c4\u03cc \u03b5\u03af\u03bd\u03b1\u03b9 \u03c4\u03bf \u03c0\u03c1\u03ce\u03c4\u03bf \u03c3\u03b1\u03c2 \u03ad\u03c1\u03b3\u03bf, \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ad\u03c1\u03b3\u03bf\u03c5** \u03ba\u03b1\u03b9 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03bd\u03ad\u03c7\u03b5\u03b9\u03b1 \u03c3\u03c4\u03bf **\u039d\u03ad\u03bf \u03ad\u03c1\u03b3\u03bf**.\n 1. \u0394\u03ce\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c3\u03c4\u03bf Cloud Project \u03c3\u03b1\u03c2 \u03ba\u03b1\u03b9 \u03bc\u03b5\u03c4\u03ac \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1**.\n 1. \u0391\u03c0\u03bf\u03b8\u03b7\u03ba\u03b5\u03cd\u03c3\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 Cloud, \u03c0.\u03c7. *example-project-12345* \u03ba\u03b1\u03b8\u03ce\u03c2 \u03b8\u03b1 \u03c4\u03bf \u03c7\u03c1\u03b5\u03b9\u03b1\u03c3\u03c4\u03b5\u03af\u03c4\u03b5 \u03b1\u03c1\u03b3\u03cc\u03c4\u03b5\u03c1\u03b1\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7 \u0392\u03b9\u03b2\u03bb\u03b9\u03bf\u03b8\u03ae\u03ba\u03b7 API \u03b3\u03b9\u03b1 \u03c4\u03bf [Smart Device Management API]( {sdm_api_url} ) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7**.\n 1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7 \u0392\u03b9\u03b2\u03bb\u03b9\u03bf\u03b8\u03ae\u03ba\u03b7 API \u03b3\u03b9\u03b1 \u03c4\u03bf [Cloud Pub/Sub API]( {pubsub_api_url} ) \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7**. \n\n \u03a3\u03c5\u03bd\u03b5\u03c7\u03af\u03c3\u03c4\u03b5 \u03cc\u03c4\u03b1\u03bd \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af \u03c4\u03bf \u03ad\u03c1\u03b3\u03bf \u03c3\u03b1\u03c2 \u03c3\u03c4\u03bf cloud.", + "title": "Nest: \u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03ba\u03b1\u03b9 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf Cloud Project" + }, + "device_project": { + "data": { + "project_id": "\u0391\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "description": "\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ad\u03c1\u03b3\u03bf \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c4\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae Nest Device Access, \u03c4\u03bf \u03bf\u03c0\u03bf\u03af\u03bf **\u03b1\u03c0\u03b1\u03b9\u03c4\u03b5\u03af \u03ad\u03bd\u03b1 \u03c4\u03ad\u03bb\u03bf\u03c2 \u03cd\u03c8\u03bf\u03c5\u03c2 5 \u03b4\u03bf\u03bb\u03b1\u03c1\u03af\u03c9\u03bd \u0397\u03a0\u0391** \u03b3\u03b9\u03b1 \u03c4\u03b7 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03c4\u03bf\u03c5.\n1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [Device Access Console]({device_access_console_url}), \u03ba\u03b1\u03b9 \u03bc\u03ad\u03c3\u03c9 \u03c4\u03b7\u03c2 \u03c1\u03bf\u03ae\u03c2 \u03c0\u03bb\u03b7\u03c1\u03c9\u03bc\u03ae\u03c2.\n1. \u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ad\u03c1\u03b3\u03bf\u03c5**.\n1. \u0394\u03ce\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03cc\u03bd\u03bf\u03bc\u03b1 \u03c3\u03c4\u03bf \u03ad\u03c1\u03b3\u03bf Device Access \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u0395\u03c0\u03cc\u03bc\u03b5\u03bd\u03bf**.\n1. \u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth\n1. \u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03b9\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b1 \u03c3\u03c5\u03bc\u03b2\u03ac\u03bd\u03c4\u03b1 \u03ba\u03ac\u03bd\u03bf\u03bd\u03c4\u03b1\u03c2 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03b7\u03bd \u03b5\u03c0\u03b9\u03bb\u03bf\u03b3\u03ae **\u0395\u03bd\u03b5\u03c1\u03b3\u03bf\u03c0\u03bf\u03af\u03b7\u03c3\u03b7** \u03ba\u03b1\u03b9 **\u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1 \u03ad\u03c1\u03b3\u03bf\u03c5**.\n\n\u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03ad\u03c1\u03b3\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 \u03c3\u03b1\u03c2 \u03c0\u03b1\u03c1\u03b1\u03ba\u03ac\u03c4\u03c9 ([\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]({more_info_url})).\n", + "title": "Nest: \u0394\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03ae\u03c3\u03c4\u03b5 \u03ad\u03bd\u03b1 \u03ad\u03c1\u03b3\u03bf \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "device_project_upgrade": { + "description": "\u0395\u03bd\u03b7\u03bc\u03b5\u03c1\u03ce\u03c3\u03c4\u03b5 \u03c4\u03bf Nest Device Access Project \u03bc\u03b5 \u03c4\u03bf \u03bd\u03ad\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth ([\u03c0\u03b5\u03c1\u03b9\u03c3\u03c3\u03cc\u03c4\u03b5\u03c1\u03b5\u03c2 \u03c0\u03bb\u03b7\u03c1\u03bf\u03c6\u03bf\u03c1\u03af\u03b5\u03c2]({more_info_url}))\n1. \u039c\u03b5\u03c4\u03b1\u03b2\u03b5\u03af\u03c4\u03b5 \u03c3\u03c4\u03b7\u03bd [Device Access Console]({device_access_console_url}).\n1. \u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03b5\u03b9\u03ba\u03bf\u03bd\u03af\u03b4\u03b9\u03bf \u03c4\u03bf\u03c5 \u03ba\u03ac\u03b4\u03bf\u03c5 \u03b1\u03c0\u03bf\u03c1\u03c1\u03b9\u03bc\u03bc\u03ac\u03c4\u03c9\u03bd \u03b4\u03af\u03c0\u03bb\u03b1 \u03c3\u03c4\u03bf *OAuth Client ID*.\n1. \u039a\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf \u03bc\u03b5\u03bd\u03bf\u03cd \u03c5\u03c0\u03b5\u03c1\u03c7\u03b5\u03af\u03bb\u03b9\u03c3\u03b7\u03c2 `...` \u03ba\u03b1\u03b9 *Add Client ID*.\n1. \u0395\u03b9\u03c3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf \u03bd\u03ad\u03bf \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth \u03ba\u03b1\u03b9 \u03ba\u03ac\u03bd\u03c4\u03b5 \u03ba\u03bb\u03b9\u03ba \u03c3\u03c4\u03bf **\u03a0\u03c1\u03bf\u03c3\u03b8\u03ae\u03ba\u03b7**.\n\n\u03a4\u03bf \u03b4\u03b9\u03ba\u03cc \u03c3\u03b1\u03c2 \u03b1\u03bd\u03b1\u03b3\u03bd\u03c9\u03c1\u03b9\u03c3\u03c4\u03b9\u03ba\u03cc \u03c0\u03b5\u03bb\u03ac\u03c4\u03b7 OAuth \u03b5\u03af\u03bd\u03b1\u03b9: `{client_id}`", + "title": "Nest: \u0395\u03bd\u03b7\u03bc\u03ad\u03c1\u03c9\u03c3\u03b7 \u03ad\u03c1\u03b3\u03bf\u03c5 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, "init": { "data": { "flow_impl": "\u03a0\u03ac\u03c1\u03bf\u03c7\u03bf\u03c2" diff --git a/homeassistant/components/nest/translations/et.json b/homeassistant/components/nest/translations/et.json index 898c9e9f3f3..3d8cd25f47c 100644 --- a/homeassistant/components/nest/translations/et.json +++ b/homeassistant/components/nest/translations/et.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "J\u00e4rgi pilvekonsooli seadistamiseks [juhiseid]({more_info_url}):\n\n1. Ava [OAuth n\u00f5usoleku kuva]({oauth_consent_url}) ja seadista\n1. Mine aadressile [Mandaat]({oauth_creds_url}) ja kl\u00f5psa nuppu **Loo mandaat**.\n1. Vali ripploendist **OAuth kliendi ID**.\n1. Vali rakenduse t\u00fc\u00fcbi jaoks **Veebirakendus**.\n1. Lisa \"{redirect_url}\" jaotises *Volitatud \u00fcmbersuunamine URI*." + }, "config": { "abort": { "authorize_url_timeout": "Tuvastamise URL-i loomise ajal\u00f5pp.", @@ -29,6 +32,32 @@ "description": "Oma Google'i konto sidumiseks vali [autoriseeri oma konto]({url}).\n\nP\u00e4rast autoriseerimist kopeeri ja aseta allpool esitatud Auth Token'i kood.", "title": "Google'i konto linkimine" }, + "auth_upgrade": { + "description": "Google on app Authi turvalisuse parandamiseks tauninud ja pead tegutsema, luues uusi rakenduse mandaate.\n\nAva [dokumentatsioon]({more_info_url}), mida j\u00e4rgida, kuna j\u00e4rgmised juhised juhendavad teid nest-seadmetele juurdep\u00e4\u00e4su taastamiseks vajalike juhiste kaudu.", + "title": "Nest: App Auth Deprecation" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google'i pilveprojekti ID" + }, + "description": "Sisesta allpool pilveprojekti ID nt *n\u00e4idisprojekt-12345*. Vaata [Google'i pilvekonsooli]({cloud_console_url}) v\u00f5i dokumentatsiooni [lisateave]({more_info_url}).", + "title": "Nest: sisesta pilveprojekti ID" + }, + "create_cloud_project": { + "description": "Nesti sidumine v\u00f5imaldab integreerida pesa termostaate, kaameraid ja uksekellasid nutiseadme haldamise API abil. SDM API **n\u00f5uab \u00fchekordset h\u00e4\u00e4lestustasu 5 USA dollarit**. Vt dokumentatsiooni teemast [more information]({more_info_url}).\n\n1. Mine [Google'i pilvekonsooli]({cloud_console_url}).\n1. Kui see on esimene projekt, kl\u00f5psa nuppu **Loo projekt** ja seej\u00e4rel **Uus projekt**.\n1. Anna oma pilveprojektile nimi ja seej\u00e4rel kl\u00f5psa nuppu **Loo**.\n1. Salvesta pilveprojekti ID nt *n\u00e4ide-projekt-12345*, kuna vajad seda hiljem\n1. Ava API teek [Smart Device Management API]({sdm_api_url}) jaoks ja kl\u00f5psa nuppu **Luba**.\n1. Mine API teeki [Cloud Pub/Sub API]({pubsub_api_url}) jaoks ja kl\u00f5psa nuppu **Luba**.\n\nJ\u00e4tka kui pilveprojekt on h\u00e4\u00e4lestatud.", + "title": "Nest: Pilveprojekti loomine ja konfigureerimine" + }, + "device_project": { + "data": { + "project_id": "Seadme juurdep\u00e4\u00e4su projekti ID" + }, + "description": "Loo Nest Device Accessi projekt, mille seadistamiseks on vaja 5 USA dollari suurust tasu**.\n1. Ava [Seadme juurdep\u00e4\u00e4sukonsool]({device_access_console_url}) ja maksevoo kaudu.\n1. Vajuta **Loo projekt**\n1. Anna oma seadmele juurdep\u00e4\u00e4su projektile nimi ja kl\u00f5psa nuppu **Next**.\n1. Sisesta oma OAuth Kliendi ID\n1. Luba s\u00fcndmused, kl\u00f5psates nuppu **Luba** ja **Loo projekt**.\n\nSisesta allpool seadme accessi projekti ID ([lisateave]({more_info_url})).\n", + "title": "Nest: seadmele juurdep\u00e4\u00e4su projekti loomine" + }, + "device_project_upgrade": { + "description": "Nest Device Access Projecti v\u00e4rskendamine uue OAuth Client ID-ga ([lisateave]({more_info_url}))\n1. Ava [Seadme juurdep\u00e4\u00e4sukonsool]({device_access_console_url}).\n1. Kl\u00f5psa pr\u00fcgikastiikooni *OAuth Client ID* k\u00f5rval.\n1. Kl\u00f5psa \u00fclet\u00e4itumise men\u00fc\u00fcd \"...\", ja *Lisa kliendi ID*.\n1. Sisesta uus OAuth-kliendi ID ja kl\u00f5psa nuppu **Lisa**.\n\nOAuth-kliendi ID on:{client_id}.", + "title": "Nest: seadmele juurdep\u00e4\u00e4su projekti v\u00e4rskendamine" + }, "init": { "data": { "flow_impl": "Pakkuja" diff --git a/homeassistant/components/nest/translations/hu.json b/homeassistant/components/nest/translations/hu.json index 1f98e162a7b..a93d06c4f6d 100644 --- a/homeassistant/components/nest/translations/hu.json +++ b/homeassistant/components/nest/translations/hu.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "K\u00f6vesse az [utas\u00edt\u00e1sokat]( {more_info_url} ) a Cloud Console konfigur\u00e1l\u00e1s\u00e1hoz: \n\n 1. Nyissa meg az [OAuth hozz\u00e1j\u00e1rul\u00e1si k\u00e9perny\u0151t]({oauth_consent_url}), \u00e9s \u00e1ll\u00edtsa be\n 1. Nyissa meg a [Hiteles\u00edt\u00e9si adatok]({oauth_creds_url}), majd kattintson a **Hiteles\u00edt\u0151 adatok l\u00e9trehoz\u00e1sa** lehet\u0151s\u00e9gre.\n 1. A leg\u00f6rd\u00fcl\u0151 list\u00e1b\u00f3l v\u00e1lassza az **OAuth-\u00fcgyf\u00e9lazonos\u00edt\u00f3** lehet\u0151s\u00e9get.\n 1. V\u00e1lassza a **Webes alkalmaz\u00e1s** lehet\u0151s\u00e9get az Alkalmaz\u00e1s t\u00edpusak\u00e9nt.\n 1. Adja hozz\u00e1 a `{redirect_url}` \u00e9rt\u00e9ket az *Authorized redirect URI* r\u00e9szhez." + }, "config": { "abort": { "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", @@ -19,7 +22,7 @@ "subscriber_error": "Ismeretlen el\u0151fizet\u0151i hiba, b\u0151vebben a napl\u00f3kban", "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n.", "unknown": "V\u00e1ratlan hiba t\u00f6rt\u00e9nt", - "wrong_project_id": "K\u00e9rem, adjon meg egy \u00e9rv\u00e9nyes Cloud Project ID-t (Device Access Project ID tal\u00e1lva)" + "wrong_project_id": "K\u00e9rem, adjon meg egy \u00e9rv\u00e9nyes Cloud Project ID-t (Device Access Project ID-vrl azonos)" }, "step": { "auth": { @@ -29,6 +32,32 @@ "description": "[Enged\u00e9lyezze]({url}) Google-fi\u00f3kj\u00e1t az \u00f6sszekapcsol\u00e1hoz.\n\nAz enged\u00e9lyez\u00e9s ut\u00e1n m\u00e1solja \u00e1t a kapott token k\u00f3dot.", "title": "\u00d6sszekapcsol\u00e1s Google-al" }, + "auth_upgrade": { + "description": "A Google a biztons\u00e1g jav\u00edt\u00e1sa \u00e9rdek\u00e9ben megsz\u00fcntette az App Auth szolg\u00e1ltat\u00e1st, \u00e9s \u00d6nnek \u00faj alkalmaz\u00e1s hiteles\u00edt\u00e9si adatainak l\u00e9trehoz\u00e1s\u00e1val kell tennie valamit. \n\n Nyissa meg a [dokument\u00e1ci\u00f3t]({more_info_url}), hogy k\u00f6vesse, mivel a k\u00f6vetkez\u0151 l\u00e9p\u00e9sek v\u00e9gigvezetik a Nest-eszk\u00f6zeihez val\u00f3 hozz\u00e1f\u00e9r\u00e9s vissza\u00e1ll\u00edt\u00e1s\u00e1hoz sz\u00fcks\u00e9ges l\u00e9p\u00e9seken.", + "title": "Nest: Az alkalmaz\u00e1shiteles\u00edt\u00e9s megsz\u00fcntet\u00e9se" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Adja meg a Cloud projekt azonos\u00edt\u00f3j\u00e1t, pl. *example-project-12345*. B\u0151vebben: [Google Cloud Console]({cloud_console_url}) vagy [dokument\u00e1ci\u00f3]({more_info_url}).", + "title": "Nest: Adja meg a Cloud Project ID-t" + }, + "create_cloud_project": { + "description": "A Nest-integr\u00e1ci\u00f3 lehet\u0151v\u00e9 teszi Nest termoszt\u00e1tjainak, kamer\u00e1inak \u00e9s ajt\u00f3cseng\u0151inek integr\u00e1l\u00e1s\u00e1t a Smart Device Management API seg\u00edts\u00e9g\u00e9vel. Az SDM API **5 USD** egyszeri be\u00e1ll\u00edt\u00e1si d\u00edjat ig\u00e9nyel. [Tov\u00e1bbi inform\u00e1ci\u00f3]({more_info_url}). \n\n 1. Nyissa meg a [Google Cloud Console]({cloud_console_url}) oldalt.\n 1. Ha ez az els\u0151 projektje, kattintson a **Projekt l\u00e9trehoz\u00e1sa**, majd az **\u00daj projekt** lehet\u0151s\u00e9gre.\n 1. Adjon nevet a Cloud Projectnek, majd kattintson a **L\u00e9trehoz\u00e1s** gombra.\n 1. Mentse el a felh\u0151projekt azonos\u00edt\u00f3j\u00e1t, p\u00e9ld\u00e1ul *example-projekt-12345*, mert k\u00e9s\u0151bb sz\u00fcks\u00e9ge lesz r\u00e1\n 1. Nyissa meg a [Smart Device Management API-t]({sdm_api_url}), \u00e9s kattintson az **Enged\u00e9lyez\u00e9s** lehet\u0151s\u00e9gre.\n 1. Nyissa meg a [Cloud Pub/Sub API-t]({pubsub_api_url}), \u00e9s kattintson az **Enged\u00e9lyez\u00e9s** lehet\u0151s\u00e9gre. \n\nFolytassa a be\u00e1ll\u00edt\u00e1s ut\u00e1n.", + "title": "Nest: Cloud Project l\u00e9trehoz\u00e1sa \u00e9s konfigur\u00e1l\u00e1sa" + }, + "device_project": { + "data": { + "project_id": "Eszk\u00f6z-hozz\u00e1f\u00e9r\u00e9s Projekt azonos\u00edt\u00f3" + }, + "description": "Hozzon l\u00e9tre egy Nest Device Access projektet, amelynek **be\u00e1ll\u00edt\u00e1sa 5 USD d\u00edjat** ig\u00e9nyel.\n1. Menjen a [Device Access Console]({device_access_console_url}) oldalra, \u00e9s a fizet\u00e9si folyamaton kereszt\u00fcl.\n1. Kattintson a **Projekt l\u00e9trehoz\u00e1sa** gombra.\n1. Adjon nevet a Device Access projektnek, \u00e9s kattintson a **K\u00f6vetkez\u0151** gombra.\n1. Adja meg az OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3j\u00e1t\n1. Enged\u00e9lyezze az esem\u00e9nyeket a **Enable** \u00e9s a **Create project** gombra kattintva.\n\nAdja meg a Device Access projekt azonos\u00edt\u00f3j\u00e1t az al\u00e1bbiakban ([more info]({more_info_url})).\n", + "title": "Nest: Hozzon l\u00e9tre egy eszk\u00f6z-hozz\u00e1f\u00e9r\u00e9si projektet" + }, + "device_project_upgrade": { + "description": "Friss\u00edtse a Nest Device Access projektet az \u00faj OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3j\u00e1val ([more info]({more_info_url}))\n1. L\u00e9pjen az [Device Access Console]({device_access_console_url}).\n1. Kattintson a *OAuth Client ID* melletti szemetes ikonra.\n1. Kattintson a `...` t\u00falfoly\u00f3 men\u00fcre \u00e9s a *Add Client ID* men\u00fcpontra.\n1. Adja meg az \u00faj OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3j\u00e1t, \u00e9s kattintson a **Add** gombra.\n\nAz \u00d6n OAuth \u00fcgyf\u00e9l azonos\u00edt\u00f3ja a k\u00f6vetkez\u0151: `{client_id}`", + "title": "Nest: Friss\u00edtse az eszk\u00f6zhozz\u00e1f\u00e9r\u00e9si projektet" + }, "init": { "data": { "flow_impl": "Szolg\u00e1ltat\u00f3" diff --git a/homeassistant/components/nest/translations/id.json b/homeassistant/components/nest/translations/id.json index ab7aaa2d459..e58f891f6c4 100644 --- a/homeassistant/components/nest/translations/id.json +++ b/homeassistant/components/nest/translations/id.json @@ -22,7 +22,7 @@ "subscriber_error": "Kesalahan pelanggan tidak diketahui, lihat log", "timeout": "Tenggang waktu memvalidasi kode telah habis.", "unknown": "Kesalahan yang tidak diharapkan", - "wrong_project_id": "Masukkan Cloud Project ID yang valid (Device Access Project ID yang ditemukan)" + "wrong_project_id": "Masukkan ID Proyek Cloud yang valid (sebelumnya sama dengan ID Proyek Akses Perangkat)" }, "step": { "auth": { @@ -32,9 +32,25 @@ "description": "Untuk menautkan akun Google Anda, [otorisasi akun Anda]({url}).\n\nSetelah otorisasi, salin dan tempel Token Auth yang disediakan di bawah ini.", "title": "Tautkan Akun Google" }, + "auth_upgrade": { + "title": "Nest: Penghentian Autentikasi Aplikasi" + }, "cloud_project": { + "data": { + "cloud_project_id": "ID Proyek Google Cloud" + }, + "description": "Masukkan ID Proyek Cloud di bawah ini, misalnya *contoh-proyek-12345*. Lihat [Konsol Google Cloud]({cloud_console_url}) atau dokumentasi untuk [info selengkapnya]({more_info_url}).", "title": "Nest: Masukkan ID Proyek Cloud" }, + "create_cloud_project": { + "title": "Nest: Buat dan konfigurasikan Proyek Cloud" + }, + "device_project": { + "data": { + "project_id": "ID Proyek Akses Perangkat" + }, + "title": "Nest: Buat Proyek Akses Perangkat" + }, "device_project_upgrade": { "title": "Nest: Perbarui Proyek Akses Perangkat" }, From 45142558ef28ed1ef0224bdfb4207ecb1de6d5e7 Mon Sep 17 00:00:00 2001 From: Rechner Fox <659028+rechner@users.noreply.github.com> Date: Sat, 18 Jun 2022 23:54:10 -0700 Subject: [PATCH 488/947] Bump pyenvisalink to 4.5 (#73663) * Bump pyenvisalink to latest version 4.5 * Minor bugfixes: * Prevent reconnects from being scheduled simultaneously * Fix parsing keypad messages containing extra commas * Add pyenvisalink updated dependency --- homeassistant/components/envisalink/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json index 2154cd68772..44a40991a37 100644 --- a/homeassistant/components/envisalink/manifest.json +++ b/homeassistant/components/envisalink/manifest.json @@ -2,7 +2,7 @@ "domain": "envisalink", "name": "Envisalink", "documentation": "https://www.home-assistant.io/integrations/envisalink", - "requirements": ["pyenvisalink==4.4"], + "requirements": ["pyenvisalink==4.5"], "codeowners": ["@ufodone"], "iot_class": "local_push", "loggers": ["pyenvisalink"] diff --git a/requirements_all.txt b/requirements_all.txt index 79ba4689352..ad12a87c152 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1474,7 +1474,7 @@ pyeight==0.3.0 pyemby==1.8 # homeassistant.components.envisalink -pyenvisalink==4.4 +pyenvisalink==4.5 # homeassistant.components.ephember pyephember==0.3.1 From 7714183118780faf8b6d41ceaeb6d34c596164dd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 19 Jun 2022 10:09:26 -0400 Subject: [PATCH 489/947] Add `zwave_js/subscribe_node_status` WS API cmd (#73249) * Add zwave_js/subscribe_node_status WS API cmd * add ready to event --- homeassistant/components/zwave_js/api.py | 18 +++++++++++------ tests/components/zwave_js/test_api.py | 25 +++++++++++++++++++++--- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 6a4aaf0264e..b41c6bf2f92 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -378,6 +378,7 @@ def node_status(node: Node) -> dict[str, Any]: def async_register_api(hass: HomeAssistant) -> None: """Register all of our api endpoints.""" websocket_api.async_register_command(hass, websocket_network_status) + websocket_api.async_register_command(hass, websocket_subscribe_node_status) websocket_api.async_register_command(hass, websocket_node_status) websocket_api.async_register_command(hass, websocket_node_metadata) websocket_api.async_register_command(hass, websocket_node_comments) @@ -422,7 +423,6 @@ def async_register_api(hass: HomeAssistant) -> None: hass, websocket_subscribe_controller_statistics ) websocket_api.async_register_command(hass, websocket_subscribe_node_statistics) - websocket_api.async_register_command(hass, websocket_node_ready) hass.http.register_view(FirmwareUploadView()) @@ -497,25 +497,28 @@ async def websocket_network_status( @websocket_api.websocket_command( { - vol.Required(TYPE): "zwave_js/node_ready", + vol.Required(TYPE): "zwave_js/subscribe_node_status", vol.Required(DEVICE_ID): str, } ) @websocket_api.async_response @async_get_node -async def websocket_node_ready( +async def websocket_subscribe_node_status( hass: HomeAssistant, connection: ActiveConnection, msg: dict, node: Node, ) -> None: - """Subscribe to the node ready event of a Z-Wave JS node.""" + """Subscribe to node status update events of a Z-Wave JS node.""" @callback def forward_event(event: dict) -> None: """Forward the event.""" connection.send_message( - websocket_api.event_message(msg[ID], {"event": event["event"]}) + websocket_api.event_message( + msg[ID], + {"event": event["event"], "status": node.status, "ready": node.ready}, + ) ) @callback @@ -525,7 +528,10 @@ async def websocket_node_ready( unsub() connection.subscriptions[msg["id"]] = async_cleanup - msg[DATA_UNSUBSCRIBE] = unsubs = [node.on("ready", forward_event)] + msg[DATA_UNSUBSCRIBE] = unsubs = [ + node.on(evt, forward_event) + for evt in ("alive", "dead", "sleep", "wake up", "ready") + ] connection.send_result(msg[ID]) diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 6c3dd796a7d..88fea684233 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -198,14 +198,14 @@ async def test_network_status(hass, multisensor_6, integration, hass_ws_client): assert msg["error"]["code"] == ERR_INVALID_FORMAT -async def test_node_ready( +async def test_subscribe_node_status( hass, multisensor_6_state, client, integration, hass_ws_client, ): - """Test the node ready websocket command.""" + """Test the subscribe node status websocket command.""" entry = integration ws_client = await hass_ws_client(hass) node_data = deepcopy(multisensor_6_state) # Copy to allow modification in tests. @@ -222,7 +222,7 @@ async def test_node_ready( await ws_client.send_json( { ID: 3, - TYPE: "zwave_js/node_ready", + TYPE: "zwave_js/subscribe_node_status", DEVICE_ID: device.id, } ) @@ -246,6 +246,25 @@ async def test_node_ready( msg = await ws_client.receive_json() assert msg["event"]["event"] == "ready" + assert msg["event"]["status"] == 1 + assert msg["event"]["ready"] + + event = Event( + "wake up", + { + "source": "node", + "event": "wake up", + "nodeId": node.node_id, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + + msg = await ws_client.receive_json() + + assert msg["event"]["event"] == "wake up" + assert msg["event"]["status"] == 2 + assert msg["event"]["ready"] async def test_node_status(hass, multisensor_6, integration, hass_ws_client): From b19b6ec6eae7c1e05c38159cfc93f006d244eadb Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sun, 19 Jun 2022 10:22:33 -0400 Subject: [PATCH 490/947] Update UniFi Protect to use MAC address for unique ID (#73508) --- .../components/unifiprotect/__init__.py | 2 +- .../components/unifiprotect/camera.py | 4 +- homeassistant/components/unifiprotect/data.py | 8 +- .../components/unifiprotect/entity.py | 13 +-- .../components/unifiprotect/migrate.py | 96 +++++++++++++++++-- .../components/unifiprotect/services.py | 20 ++-- .../components/unifiprotect/utils.py | 51 ++++++---- tests/components/unifiprotect/conftest.py | 35 ++++++- .../unifiprotect/test_binary_sensor.py | 1 - tests/components/unifiprotect/test_button.py | 4 +- tests/components/unifiprotect/test_camera.py | 17 ++-- .../unifiprotect/test_diagnostics.py | 4 +- tests/components/unifiprotect/test_init.py | 5 +- tests/components/unifiprotect/test_light.py | 2 +- tests/components/unifiprotect/test_lock.py | 2 +- .../unifiprotect/test_media_player.py | 2 +- tests/components/unifiprotect/test_migrate.py | 77 ++++++++++++--- .../components/unifiprotect/test_services.py | 8 +- tests/components/unifiprotect/test_switch.py | 6 +- 19 files changed, 266 insertions(+), 91 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index d05f544ada1..a24777f9ecd 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -128,5 +128,5 @@ async def async_remove_config_entry_device( assert api is not None return api.bootstrap.nvr.mac not in unifi_macs and not any( device.mac in unifi_macs - for device in async_get_devices(api, DEVICES_THAT_ADOPT) + for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT) ) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 8020d5e8aab..d59ee59b760 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -110,10 +110,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera): super().__init__(data, camera) if self._secure: - self._attr_unique_id = f"{self.device.id}_{self.channel.id}" + self._attr_unique_id = f"{self.device.mac}_{self.channel.id}" self._attr_name = f"{self.device.name} {self.channel.name}" else: - self._attr_unique_id = f"{self.device.id}_{self.channel.id}_insecure" + self._attr_unique_id = f"{self.device.mac}_{self.channel.id}_insecure" self._attr_name = f"{self.device.name} {self.channel.name} Insecure" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index bcc1e561e99..1e9729f7930 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -21,7 +21,7 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_time_interval from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN -from .utils import async_get_adoptable_devices_by_type, async_get_devices +from .utils import async_get_devices, async_get_devices_by_type _LOGGER = logging.getLogger(__name__) @@ -70,8 +70,8 @@ class ProtectData: ) -> Generator[ProtectAdoptableDeviceModel, None, None]: """Get all devices matching types.""" for device_type in device_types: - yield from async_get_adoptable_devices_by_type( - self.api, device_type + yield from async_get_devices_by_type( + self.api.bootstrap, device_type ).values() async def async_setup(self) -> None: @@ -153,7 +153,7 @@ class ProtectData: return self.async_signal_device_id_update(self.api.bootstrap.nvr.id) - for device in async_get_devices(self.api, DEVICES_THAT_ADOPT): + for device in async_get_devices(self.api.bootstrap, DEVICES_THAT_ADOPT): self.async_signal_device_id_update(device.id) @callback diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 2911a861535..6de0a4c57cb 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData from .models import ProtectRequiredKeysMixin -from .utils import async_get_adoptable_devices_by_type, get_nested_attr +from .utils import async_device_by_id, get_nested_attr _LOGGER = logging.getLogger(__name__) @@ -117,11 +117,11 @@ class ProtectDeviceEntity(Entity): self.device = device if description is None: - self._attr_unique_id = f"{self.device.id}" + self._attr_unique_id = f"{self.device.mac}" self._attr_name = f"{self.device.name}" else: self.entity_description = description - self._attr_unique_id = f"{self.device.id}_{description.key}" + self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" self._attr_name = f"{self.device.name} {name.title()}" @@ -153,10 +153,11 @@ class ProtectDeviceEntity(Entity): """Update Entity object from Protect device.""" if self.data.last_update_success: assert self.device.model - devices = async_get_adoptable_devices_by_type( - self.data.api, self.device.model + device = async_device_by_id( + self.data.api.bootstrap, self.device.id, device_type=self.device.model ) - self.device = devices[self.device.id] + assert device is not None + self.device = device is_connected = ( self.data.last_update_success and self.device.state == StateType.CONNECTED diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index dcba0b504c9..307020caa5c 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -3,14 +3,18 @@ from __future__ import annotations import logging +from aiohttp.client_exceptions import ServerDisconnectedError from pyunifiprotect import ProtectApiClient +from pyunifiprotect.data import NVR, Bootstrap, ProtectAdoptableDeviceModel +from pyunifiprotect.exceptions import ClientError from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .const import DEVICES_THAT_ADOPT +from .utils import async_device_by_id _LOGGER = logging.getLogger(__name__) @@ -24,6 +28,21 @@ async def async_migrate_data( await async_migrate_buttons(hass, entry, protect) _LOGGER.debug("Completed Migrate: async_migrate_buttons") + _LOGGER.debug("Start Migrate: async_migrate_device_ids") + await async_migrate_device_ids(hass, entry, protect) + _LOGGER.debug("Completed Migrate: async_migrate_device_ids") + + +async def async_get_bootstrap(protect: ProtectApiClient) -> Bootstrap: + """Get UniFi Protect bootstrap or raise appropriate HA error.""" + + try: + bootstrap = await protect.get_bootstrap() + except (TimeoutError, ClientError, ServerDisconnectedError) as err: + raise ConfigEntryNotReady from err + + return bootstrap + async def async_migrate_buttons( hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient @@ -47,16 +66,10 @@ async def async_migrate_buttons( _LOGGER.debug("No button entities need migration") return - bootstrap = await protect.get_bootstrap() + bootstrap = await async_get_bootstrap(protect) count = 0 for button in to_migrate: - device = None - for model in DEVICES_THAT_ADOPT: - attr = f"{model.value}s" - device = getattr(bootstrap, attr).get(button.unique_id) - if device is not None: - break - + device = async_device_by_id(bootstrap, button.unique_id) if device is None: continue @@ -81,3 +94,68 @@ async def async_migrate_buttons( if count < len(to_migrate): _LOGGER.warning("Failed to migate %s reboot buttons", len(to_migrate) - count) + + +async def async_migrate_device_ids( + hass: HomeAssistant, entry: ConfigEntry, protect: ProtectApiClient +) -> None: + """ + Migrate unique IDs from {device_id}_{name} format to {mac}_{name} format. + + This makes devices persist better with in HA. Anything a device is unadopted/readopted or + the Protect instance has to rebuild the disk array, the device IDs of Protect devices + can change. This causes a ton of orphaned entities and loss of historical data. MAC + addresses are the one persistent identifier a device has that does not change. + + Added in 2022.7.0. + """ + + registry = er.async_get(hass) + to_migrate = [] + for entity in er.async_entries_for_config_entry(registry, entry.entry_id): + parts = entity.unique_id.split("_") + # device ID = 24 characters, MAC = 12 + if len(parts[0]) == 24: + _LOGGER.debug("Entity %s needs migration", entity.entity_id) + to_migrate.append(entity) + + if len(to_migrate) == 0: + _LOGGER.debug("No entities need migration to MAC address ID") + return + + bootstrap = await async_get_bootstrap(protect) + count = 0 + for entity in to_migrate: + parts = entity.unique_id.split("_") + if parts[0] == bootstrap.nvr.id: + device: NVR | ProtectAdoptableDeviceModel | None = bootstrap.nvr + else: + device = async_device_by_id(bootstrap, parts[0]) + + if device is None: + continue + + new_unique_id = device.mac + if len(parts) > 1: + new_unique_id = f"{device.mac}_{'_'.join(parts[1:])}" + _LOGGER.debug( + "Migrating entity %s (old unique_id: %s, new unique_id: %s)", + entity.entity_id, + entity.unique_id, + new_unique_id, + ) + try: + registry.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) + except ValueError as err: + print(err) + _LOGGER.warning( + "Could not migrate entity %s (old unique_id: %s, new unique_id: %s)", + entity.entity_id, + entity.unique_id, + new_unique_id, + ) + else: + count += 1 + + if count < len(to_migrate): + _LOGGER.warning("Failed to migrate %s entities", len(to_migrate) - count) diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 828aa9ecfd7..3b7b3db026f 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -3,10 +3,11 @@ from __future__ import annotations import asyncio import functools -from typing import Any +from typing import Any, cast from pydantic import ValidationError from pyunifiprotect.api import ProtectApiClient +from pyunifiprotect.data import Chime from pyunifiprotect.exceptions import BadRequest import voluptuous as vol @@ -122,8 +123,8 @@ async def set_default_doorbell_text(hass: HomeAssistant, call: ServiceCall) -> N @callback -def _async_unique_id_to_ufp_device_id(unique_id: str) -> str: - """Extract the UFP device id from the registry entry unique id.""" +def _async_unique_id_to_mac(unique_id: str) -> str: + """Extract the MAC address from the registry entry unique id.""" return unique_id.split("_")[0] @@ -136,10 +137,12 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> chime_button = entity_registry.async_get(entity_id) assert chime_button is not None assert chime_button.device_id is not None - chime_ufp_device_id = _async_unique_id_to_ufp_device_id(chime_button.unique_id) + chime_mac = _async_unique_id_to_mac(chime_button.unique_id) instance = _async_get_ufp_instance(hass, chime_button.device_id) - chime = instance.bootstrap.chimes[chime_ufp_device_id] + chime = instance.bootstrap.get_device_from_mac(chime_mac) + chime = cast(Chime, chime) + assert chime is not None call.data = ReadOnlyDict(call.data.get("doorbells") or {}) doorbell_refs = async_extract_referenced_entity_ids(hass, call) @@ -154,10 +157,9 @@ async def set_chime_paired_doorbells(hass: HomeAssistant, call: ServiceCall) -> != BinarySensorDeviceClass.OCCUPANCY ): continue - doorbell_ufp_device_id = _async_unique_id_to_ufp_device_id( - doorbell_sensor.unique_id - ) - camera = instance.bootstrap.cameras[doorbell_ufp_device_id] + doorbell_mac = _async_unique_id_to_mac(doorbell_sensor.unique_id) + camera = instance.bootstrap.get_device_from_mac(doorbell_mac) + assert camera is not None doorbell_ids.add(camera.id) chime.camera_ids = sorted(doorbell_ids) await chime.save_device() diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index fffe987db0f..b57753e15d4 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -7,12 +7,15 @@ from enum import Enum import socket from typing import Any -from pyunifiprotect import ProtectApiClient -from pyunifiprotect.data.base import ProtectAdoptableDeviceModel, ProtectDeviceModel +from pyunifiprotect.data import ( + Bootstrap, + ProtectAdoptableDeviceModel, + ProtectDeviceModel, +) from homeassistant.core import HomeAssistant, callback -from .const import ModelType +from .const import DEVICES_THAT_ADOPT, ModelType def get_nested_attr(obj: Any, attr: str) -> Any: @@ -59,33 +62,45 @@ async def _async_resolve(hass: HomeAssistant, host: str) -> str | None: return None +@callback def async_get_devices_by_type( - api: ProtectApiClient, device_type: ModelType -) -> dict[str, ProtectDeviceModel]: - """Get devices by type.""" - devices: dict[str, ProtectDeviceModel] = getattr( - api.bootstrap, f"{device_type.value}s" - ) - return devices - - -def async_get_adoptable_devices_by_type( - api: ProtectApiClient, device_type: ModelType + bootstrap: Bootstrap, device_type: ModelType ) -> dict[str, ProtectAdoptableDeviceModel]: - """Get adoptable devices by type.""" + """Get devices by type.""" + devices: dict[str, ProtectAdoptableDeviceModel] = getattr( - api.bootstrap, f"{device_type.value}s" + bootstrap, f"{device_type.value}s" ) return devices +@callback +def async_device_by_id( + bootstrap: Bootstrap, + device_id: str, + device_type: ModelType | None = None, +) -> ProtectAdoptableDeviceModel | None: + """Get devices by type.""" + + device_types = DEVICES_THAT_ADOPT + if device_type is not None: + device_types = {device_type} + + device = None + for model in device_types: + device = async_get_devices_by_type(bootstrap, model).get(device_id) + if device is not None: + break + return device + + @callback def async_get_devices( - api: ProtectApiClient, model_type: Iterable[ModelType] + bootstrap: Bootstrap, model_type: Iterable[ModelType] ) -> Generator[ProtectDeviceModel, None, None]: """Return all device by type.""" return ( device for device_type in model_type - for device in async_get_devices_by_type(api, device_type).values() + for device in async_get_devices_by_type(bootstrap, device_type).values() ) diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 3986e4cd5a3..9892bcc3ec6 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -23,6 +23,7 @@ from pyunifiprotect.data import ( Viewer, WSSubscriptionMessage, ) +from pyunifiprotect.test_util.anonymize import random_hex from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.const import Platform @@ -80,6 +81,26 @@ class MockBootstrap: "chimes": [c.unifi_dict() for c in self.chimes.values()], } + def get_device_from_mac(self, mac: str) -> ProtectAdoptableDeviceModel | None: + """Return device for MAC address.""" + + mac = mac.lower().replace(":", "").replace("-", "").replace("_", "") + + all_devices = ( + self.cameras.values(), + self.lights.values(), + self.sensors.values(), + self.viewers.values(), + self.liveviews.values(), + self.doorlocks.values(), + self.chimes.values(), + ) + for devices in all_devices: + for device in devices: + if device.mac.lower() == mac: + return device + return None + @dataclass class MockEntityFixture: @@ -301,7 +322,19 @@ def ids_from_device_description( description.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") ) - unique_id = f"{device.id}_{description.key}" + unique_id = f"{device.mac}_{description.key}" entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" return unique_id, entity_id + + +def generate_random_ids() -> tuple[str, str]: + """Generate random IDs for device.""" + + return random_hex(24).upper(), random_hex(12).upper() + + +def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None: + """Regenerate the IDs on UFP device.""" + + device.id, device.mac = generate_random_ids() diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 834f8634ee1..8fbaf61aca1 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -366,7 +366,6 @@ async def test_binary_sensor_setup_sensor_none( state = hass.states.get(entity_id) assert state - print(entity_id) assert state.state == expected[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index f3b76cb7abb..5b7122f6227 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -46,7 +46,7 @@ async def test_reboot_button( mock_entry.api.reboot_device = AsyncMock() - unique_id = f"{chime.id}_reboot" + unique_id = f"{chime.mac}_reboot" entity_id = "button.test_chime_reboot_device" entity_registry = er.async_get(hass) @@ -75,7 +75,7 @@ async def test_chime_button( mock_entry.api.play_speaker = AsyncMock() - unique_id = f"{chime.id}_play" + unique_id = f"{chime.mac}_play" entity_id = "button.test_chime_play_chime" entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 538b3bf7652..2f8d2607da0 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -38,6 +38,7 @@ from .conftest import ( MockEntityFixture, assert_entity_counts, enable_entity, + regenerate_device_ids, time_changed, ) @@ -124,7 +125,7 @@ def validate_default_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name}" - unique_id = f"{camera_obj.id}_{channel.id}" + unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -146,7 +147,7 @@ def validate_rtsps_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name}" - unique_id = f"{camera_obj.id}_{channel.id}" + unique_id = f"{camera_obj.mac}_{channel.id}" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -168,7 +169,7 @@ def validate_rtsp_camera_entity( channel = camera_obj.channels[channel_id] entity_name = f"{camera_obj.name} {channel.name} Insecure" - unique_id = f"{camera_obj.id}_{channel.id}_insecure" + unique_id = f"{camera_obj.mac}_{channel.id}_insecure" entity_id = f"camera.{entity_name.replace(' ', '_').lower()}" entity_registry = er.async_get(hass) @@ -251,12 +252,12 @@ async def test_basic_setup( camera_high_only.channels[1]._api = mock_entry.api camera_high_only.channels[2]._api = mock_entry.api camera_high_only.name = "Test Camera 1" - camera_high_only.id = "test_high" camera_high_only.channels[0].is_rtsp_enabled = True camera_high_only.channels[0].name = "High" camera_high_only.channels[0].rtsp_alias = "test_high_alias" camera_high_only.channels[1].is_rtsp_enabled = False camera_high_only.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_high_only) camera_medium_only = mock_camera.copy(deep=True) camera_medium_only._api = mock_entry.api @@ -264,12 +265,12 @@ async def test_basic_setup( camera_medium_only.channels[1]._api = mock_entry.api camera_medium_only.channels[2]._api = mock_entry.api camera_medium_only.name = "Test Camera 2" - camera_medium_only.id = "test_medium" camera_medium_only.channels[0].is_rtsp_enabled = False camera_medium_only.channels[1].is_rtsp_enabled = True camera_medium_only.channels[1].name = "Medium" camera_medium_only.channels[1].rtsp_alias = "test_medium_alias" camera_medium_only.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_medium_only) camera_all_channels = mock_camera.copy(deep=True) camera_all_channels._api = mock_entry.api @@ -277,7 +278,6 @@ async def test_basic_setup( camera_all_channels.channels[1]._api = mock_entry.api camera_all_channels.channels[2]._api = mock_entry.api camera_all_channels.name = "Test Camera 3" - camera_all_channels.id = "test_all" camera_all_channels.channels[0].is_rtsp_enabled = True camera_all_channels.channels[0].name = "High" camera_all_channels.channels[0].rtsp_alias = "test_high_alias" @@ -287,6 +287,7 @@ async def test_basic_setup( camera_all_channels.channels[2].is_rtsp_enabled = True camera_all_channels.channels[2].name = "Low" camera_all_channels.channels[2].rtsp_alias = "test_low_alias" + regenerate_device_ids(camera_all_channels) camera_no_channels = mock_camera.copy(deep=True) camera_no_channels._api = mock_entry.api @@ -294,11 +295,11 @@ async def test_basic_setup( camera_no_channels.channels[1]._api = mock_entry.api camera_no_channels.channels[2]._api = mock_entry.api camera_no_channels.name = "Test Camera 4" - camera_no_channels.id = "test_none" camera_no_channels.channels[0].is_rtsp_enabled = False camera_no_channels.channels[0].name = "High" camera_no_channels.channels[1].is_rtsp_enabled = False camera_no_channels.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_no_channels) camera_package = mock_camera.copy(deep=True) camera_package._api = mock_entry.api @@ -306,12 +307,12 @@ async def test_basic_setup( camera_package.channels[1]._api = mock_entry.api camera_package.channels[2]._api = mock_entry.api camera_package.name = "Test Camera 5" - camera_package.id = "test_package" camera_package.channels[0].is_rtsp_enabled = True camera_package.channels[0].name = "High" camera_package.channels[0].rtsp_alias = "test_high_alias" camera_package.channels[1].is_rtsp_enabled = False camera_package.channels[2].is_rtsp_enabled = False + regenerate_device_ids(camera_package) package_channel = camera_package.channels[0].copy(deep=True) package_channel.is_rtsp_enabled = False package_channel.name = "Package Camera" diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index b58e164e913..2e7f8c0e4b4 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -4,7 +4,7 @@ from pyunifiprotect.data import NVR, Light from homeassistant.core import HomeAssistant -from .conftest import MockEntityFixture +from .conftest import MockEntityFixture, regenerate_device_ids from tests.components.diagnostics import get_diagnostics_for_config_entry @@ -17,7 +17,7 @@ async def test_diagnostics( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) mock_entry.api.bootstrap.lights = { light1.id: light1, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 68f171b52bf..23dfa12fc97 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from . import _patch_discovery -from .conftest import MockBootstrap, MockEntityFixture +from .conftest import MockBootstrap, MockEntityFixture, regenerate_device_ids from tests.common import MockConfigEntry @@ -212,8 +212,7 @@ async def test_device_remove_devices( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" - light1.mac = "AABBCCDDEEFF" + regenerate_device_ids(light1) mock_entry.api.bootstrap.lights = { light1.id: light1, diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 8f4dc4f8fcf..a3686fdfbd9 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -57,7 +57,7 @@ async def test_light_setup( ): """Test light entity setup.""" - unique_id = light[0].id + unique_id = light[0].mac entity_id = light[1] entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 0a02fcb22a4..abcea4ec04e 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -60,7 +60,7 @@ async def test_lock_setup( ): """Test lock entity setup.""" - unique_id = f"{doorlock[0].id}_lock" + unique_id = f"{doorlock[0].mac}_lock" entity_id = doorlock[1] entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index c4586eb7880..d6404ee3fe5 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -66,7 +66,7 @@ async def test_media_player_setup( ): """Test media_player entity setup.""" - unique_id = f"{camera[0].id}_speaker" + unique_id = f"{camera[0].mac}_speaker" entity_id = camera[1] entity_registry = er.async_get(hass) diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 756672bcbca..b62aa9d7757 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -12,7 +12,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture +from .conftest import MockEntityFixture, generate_random_ids, regenerate_device_ids async def test_migrate_reboot_button( @@ -23,12 +23,13 @@ async def test_migrate_reboot_button( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) light2 = mock_light.copy() light2._api = mock_entry.api light2.name = "Test Light 2" - light2.id = "lightid2" + regenerate_device_ids(light2) + mock_entry.api.bootstrap.lights = { light1.id: light1, light2.id: light2, @@ -42,7 +43,7 @@ async def test_migrate_reboot_button( registry.async_get_or_create( Platform.BUTTON, DOMAIN, - f"{light2.id}_reboot", + f"{light2.mac}_reboot", config_entry=mock_entry.entry, ) @@ -59,20 +60,21 @@ async def test_migrate_reboot_button( ): if entity.domain == Platform.BUTTON.value: buttons.append(entity) - print(entity.entity_id) assert len(buttons) == 2 assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device") is None assert registry.async_get(f"{Platform.BUTTON}.test_light_1_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid1") + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light1.id.lower()}") assert light is not None - assert light.unique_id == f"{light1.id}_reboot" + assert light.unique_id == f"{light1.mac}_reboot" assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device") is None assert registry.async_get(f"{Platform.BUTTON}.test_light_2_reboot_device_2") is None - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2_reboot") + light = registry.async_get( + f"{Platform.BUTTON}.unifiprotect_{light2.mac.lower()}_reboot" + ) assert light is not None - assert light.unique_id == f"{light2.id}_reboot" + assert light.unique_id == f"{light2.mac}_reboot" async def test_migrate_reboot_button_no_device( @@ -83,7 +85,9 @@ async def test_migrate_reboot_button_no_device( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) + + light2_id, _ = generate_random_ids() mock_entry.api.bootstrap.lights = { light1.id: light1, @@ -92,7 +96,7 @@ async def test_migrate_reboot_button_no_device( registry = er.async_get(hass) registry.async_get_or_create( - Platform.BUTTON, DOMAIN, "lightid2", config_entry=mock_entry.entry + Platform.BUTTON, DOMAIN, light2_id, config_entry=mock_entry.entry ) await hass.config_entries.async_setup(mock_entry.entry.entry_id) @@ -110,9 +114,9 @@ async def test_migrate_reboot_button_no_device( buttons.append(entity) assert len(buttons) == 2 - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_lightid2") + light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light2_id.lower()}") assert light is not None - assert light.unique_id == "lightid2" + assert light.unique_id == light2_id async def test_migrate_reboot_button_fail( @@ -123,7 +127,7 @@ async def test_migrate_reboot_button_fail( light1 = mock_light.copy() light1._api = mock_entry.api light1.name = "Test Light 1" - light1.id = "lightid1" + regenerate_device_ids(light1) mock_entry.api.bootstrap.lights = { light1.id: light1, @@ -155,4 +159,47 @@ async def test_migrate_reboot_button_fail( light = registry.async_get(f"{Platform.BUTTON}.test_light_1") assert light is not None - assert light.unique_id == f"{light1.id}" + assert light.unique_id == f"{light1.mac}" + + +async def test_migrate_device_mac_button_fail( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Test migrating unique ID to MAC format.""" + + light1 = mock_light.copy() + light1._api = mock_entry.api + light1.name = "Test Light 1" + regenerate_device_ids(light1) + + mock_entry.api.bootstrap.lights = { + light1.id: light1, + } + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light1.id}_reboot", + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light1.mac}_reboot", + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + light = registry.async_get(f"{Platform.BUTTON}.test_light_1") + assert light is not None + assert light.unique_id == f"{light1.id}_reboot" diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 22f7fdd1a6c..2ad3821cc40 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import MockEntityFixture +from .conftest import MockEntityFixture, regenerate_device_ids @pytest.fixture(name="device") @@ -165,22 +165,22 @@ async def test_set_chime_paired_doorbells( } camera1 = mock_camera.copy() - camera1.id = "cameraid1" camera1.name = "Test Camera 1" camera1._api = mock_entry.api camera1.channels[0]._api = mock_entry.api camera1.channels[1]._api = mock_entry.api camera1.channels[2]._api = mock_entry.api camera1.feature_flags.has_chime = True + regenerate_device_ids(camera1) camera2 = mock_camera.copy() - camera2.id = "cameraid2" camera2.name = "Test Camera 2" camera2._api = mock_entry.api camera2.channels[0]._api = mock_entry.api camera2.channels[1]._api = mock_entry.api camera2.channels[2]._api = mock_entry.api camera2.feature_flags.has_chime = True + regenerate_device_ids(camera2) mock_entry.api.bootstrap.cameras = { camera1.id: camera1, @@ -210,5 +210,5 @@ async def test_set_chime_paired_doorbells( ) mock_entry.api.update_device.assert_called_once_with( - ModelType.CHIME, mock_chime.id, {"cameraIds": [camera1.id, camera2.id]} + ModelType.CHIME, mock_chime.id, {"cameraIds": sorted([camera1.id, camera2.id])} ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index bc0c8387c29..8ca1ef9b533 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -238,7 +238,7 @@ async def test_switch_setup_light( description = LIGHT_SWITCHES[0] - unique_id = f"{light.id}_{description.key}" + unique_id = f"{light.mac}_{description.key}" entity_id = f"switch.test_light_{description.name.lower().replace(' ', '_')}" entity = entity_registry.async_get(entity_id) @@ -282,7 +282,7 @@ async def test_switch_setup_camera_all( description_entity_name = ( description.name.lower().replace(":", "").replace(" ", "_") ) - unique_id = f"{camera.id}_{description.key}" + unique_id = f"{camera.mac}_{description.key}" entity_id = f"switch.test_camera_{description_entity_name}" entity = entity_registry.async_get(entity_id) @@ -329,7 +329,7 @@ async def test_switch_setup_camera_none( description_entity_name = ( description.name.lower().replace(":", "").replace(" ", "_") ) - unique_id = f"{camera_none.id}_{description.key}" + unique_id = f"{camera_none.mac}_{description.key}" entity_id = f"switch.test_camera_{description_entity_name}" entity = entity_registry.async_get(entity_id) From 68135e57af05af38fd9a55992cc9435230999ef0 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 19 Jun 2022 16:28:33 +0200 Subject: [PATCH 491/947] Split timer service for Sensibo (#73684) --- .../components/sensibo/binary_sensor.py | 35 +-- homeassistant/components/sensibo/climate.py | 36 ++- homeassistant/components/sensibo/const.py | 1 + .../components/sensibo/services.yaml | 16 +- homeassistant/components/sensibo/switch.py | 146 ++++++++++++ tests/components/sensibo/test_climate.py | 207 ++++-------------- tests/components/sensibo/test_switch.py | 165 ++++++++++++++ 7 files changed, 371 insertions(+), 235 deletions(-) create mode 100644 homeassistant/components/sensibo/switch.py create mode 100644 tests/components/sensibo/test_switch.py diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 72003e0a418..503717c61e4 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -1,9 +1,9 @@ """Binary Sensor platform for Sensibo integration.""" from __future__ import annotations -from collections.abc import Callable, Mapping +from collections.abc import Callable from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from pysensibo.model import MotionSensor, SensiboDevice @@ -36,7 +36,6 @@ class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[SensiboDevice], bool | None] - extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None] | None] | None @dataclass @@ -85,18 +84,6 @@ MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, .. name="Room Occupied", icon="mdi:motion-sensor", value_fn=lambda data: data.room_occupied, - extra_fn=None, - ), -) - -DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( - SensiboDeviceBinarySensorEntityDescription( - key="timer_on", - device_class=BinarySensorDeviceClass.RUNNING, - name="Timer Running", - icon="mdi:timer", - value_fn=lambda data: data.timer_on, - extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, ), ) @@ -107,7 +94,6 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost Enabled", icon="mdi:wind-power-outline", value_fn=lambda data: data.pure_boost_enabled, - extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_ac_integration", @@ -116,7 +102,6 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with AC", icon="mdi:connection", value_fn=lambda data: data.pure_ac_integration, - extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_geo_integration", @@ -125,7 +110,6 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Presence", icon="mdi:connection", value_fn=lambda data: data.pure_geo_integration, - extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_measure_integration", @@ -134,7 +118,6 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Indoor Air Quality", icon="mdi:connection", value_fn=lambda data: data.pure_measure_integration, - extra_fn=None, ), SensiboDeviceBinarySensorEntityDescription( key="pure_prime_integration", @@ -143,7 +126,6 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( name="Pure Boost linked with Outdoor Air Quality", icon="mdi:connection", value_fn=lambda data: data.pure_prime_integration, - extra_fn=None, ), ) @@ -172,12 +154,6 @@ async def async_setup_entry( for device_id, device_data in coordinator.data.parsed.items() if device_data.motion_sensors is not None ) - entities.extend( - SensiboDeviceSensor(coordinator, device_id, description) - for description in DEVICE_SENSOR_TYPES - for device_id, device_data in coordinator.data.parsed.items() - if device_data.model != "pure" - ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) for description in PURE_SENSOR_TYPES @@ -247,10 +223,3 @@ class SensiboDeviceSensor(SensiboDeviceBaseEntity, BinarySensorEntity): def is_on(self) -> bool | None: """Return true if the binary sensor is on.""" return self.entity_description.value_fn(self.device_data) - - @property - def extra_state_attributes(self) -> Mapping[str, Any] | None: - """Return additional attributes.""" - if self.entity_description.extra_fn is not None: - return self.entity_description.extra_fn(self.device_data) - return None diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index c146ed350f3..6a1084f733c 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -27,7 +27,7 @@ from .coordinator import SensiboDataUpdateCoordinator from .entity import SensiboDeviceBaseEntity SERVICE_ASSUME_STATE = "assume_state" -SERVICE_TIMER = "timer" +SERVICE_ENABLE_TIMER = "enable_timer" ATTR_MINUTES = "minutes" SERVICE_ENABLE_PURE_BOOST = "enable_pure_boost" SERVICE_DISABLE_PURE_BOOST = "disable_pure_boost" @@ -98,12 +98,11 @@ async def async_setup_entry( "async_assume_state", ) platform.async_register_entity_service( - SERVICE_TIMER, + SERVICE_ENABLE_TIMER, { - vol.Required(ATTR_STATE): vol.In(["on", "off"]), - vol.Optional(ATTR_MINUTES): cv.positive_int, + vol.Required(ATTR_MINUTES): cv.positive_int, }, - "async_set_timer", + "async_enable_timer", ) platform.async_register_entity_service( SERVICE_ENABLE_PURE_BOOST, @@ -315,27 +314,18 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): await self._async_set_ac_state_property("on", state != HVACMode.OFF, True) await self.coordinator.async_refresh() - async def async_set_timer(self, state: str, minutes: int | None = None) -> None: - """Set or delete timer.""" - if state == "off" and self.device_data.timer_id is None: - raise HomeAssistantError("No timer to delete") - - if state == "on" and minutes is None: - raise ValueError("No value provided for timer") - - if state == "off": - result = await self.async_send_command("del_timer") - else: - new_state = bool(self.device_data.ac_states["on"] is False) - params = { - "minutesFromNow": minutes, - "acState": {**self.device_data.ac_states, "on": new_state}, - } - result = await self.async_send_command("set_timer", params) + async def async_enable_timer(self, minutes: int) -> None: + """Enable the timer.""" + new_state = bool(self.device_data.ac_states["on"] is False) + params = { + "minutesFromNow": minutes, + "acState": {**self.device_data.ac_states, "on": new_state}, + } + result = await self.async_send_command("set_timer", params) if result["status"] == "success": return await self.coordinator.async_request_refresh() - raise HomeAssistantError(f"Could not set timer for device {self.name}") + raise HomeAssistantError(f"Could not enable timer for device {self.name}") async def async_enable_pure_boost( self, diff --git a/homeassistant/components/sensibo/const.py b/homeassistant/components/sensibo/const.py index 5fce3822bb2..736d663b144 100644 --- a/homeassistant/components/sensibo/const.py +++ b/homeassistant/components/sensibo/const.py @@ -18,6 +18,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, Platform.UPDATE, ] DEFAULT_NAME = "Sensibo" diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index 67006074f6b..6eb5c065789 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -16,24 +16,14 @@ assume_state: options: - "on" - "off" -timer: - name: Timer - description: Set or delete timer for device. +enable_timer: + name: Enable Timer + description: Enable the timer with custom time. target: entity: integration: sensibo domain: climate fields: - state: - name: State - description: Timer on or off. - required: true - example: "on" - selector: - select: - options: - - "on" - - "off" minutes: name: Minutes description: Countdown for timer (for timer state on) diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py new file mode 100644 index 00000000000..3b9915d0a89 --- /dev/null +++ b/homeassistant/components/sensibo/switch.py @@ -0,0 +1,146 @@ +"""Switch platform for Sensibo integration.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from typing import Any + +from pysensibo.model import SensiboDevice + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SensiboDataUpdateCoordinator +from .entity import SensiboDeviceBaseEntity + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Sensibo base description keys.""" + + value_fn: Callable[[SensiboDevice], bool | None] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None]] + command_on: str + command_off: str + remote_key: str + + +@dataclass +class SensiboDeviceSwitchEntityDescription( + SwitchEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Sensibo Switch entity.""" + + +DEVICE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( + SensiboDeviceSwitchEntityDescription( + key="timer_on_switch", + device_class=SwitchDeviceClass.SWITCH, + name="Timer", + icon="mdi:timer", + value_fn=lambda data: data.timer_on, + extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, + command_on="set_timer", + command_off="del_timer", + remote_key="timer_on", + ), +) + + +def build_params(command: str, device_data: SensiboDevice) -> dict[str, Any] | None: + """Build params for turning on switch.""" + if command == "set_timer": + new_state = bool(device_data.ac_states["on"] is False) + params = { + "minutesFromNow": 60, + "acState": {**device_data.ac_states, "on": new_state}, + } + return params + return None + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sensibo binary sensor platform.""" + + coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[SensiboDeviceSwitch] = [] + + entities.extend( + SensiboDeviceSwitch(coordinator, device_id, description) + for description in DEVICE_SWITCH_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.model != "pure" + ) + + async_add_entities(entities) + + +class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): + """Representation of a Sensibo Device Switch.""" + + entity_description: SensiboDeviceSwitchEntityDescription + + def __init__( + self, + coordinator: SensiboDataUpdateCoordinator, + device_id: str, + entity_description: SensiboDeviceSwitchEntityDescription, + ) -> None: + """Initiate Sensibo Device Switch.""" + super().__init__( + coordinator, + device_id, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}-{entity_description.key}" + self._attr_name = f"{self.device_data.name} {entity_description.name}" + + @property + def is_on(self) -> bool | None: + """Return True if entity is on.""" + return self.entity_description.value_fn(self.device_data) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the entity on.""" + params = build_params(self.entity_description.command_on, self.device_data) + result = await self.async_send_command( + self.entity_description.command_on, params + ) + + if result["status"] == "success": + setattr(self.device_data, self.entity_description.remote_key, True) + self.async_write_ha_state() + return await self.coordinator.async_request_refresh() + raise HomeAssistantError( + f"Could not execute {self.entity_description.command_on} for device {self.name}" + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the entity off.""" + result = await self.async_send_command(self.entity_description.command_off) + + if result["status"] == "success": + setattr(self.device_data, self.entity_description.remote_key, False) + self.async_write_ha_state() + return await self.coordinator.async_request_refresh() + raise HomeAssistantError( + f"Could not execute {self.entity_description.command_off} for device {self.name}" + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any]: + """Return additional attributes.""" + return self.entity_description.extra_fn(self.device_data) diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index 30356f2b00d..ea7f6eb16fe 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -30,7 +30,7 @@ from homeassistant.components.sensibo.climate import ( SERVICE_ASSUME_STATE, SERVICE_DISABLE_PURE_BOOST, SERVICE_ENABLE_PURE_BOOST, - SERVICE_TIMER, + SERVICE_ENABLE_TIMER, _find_valid_target_temp, ) from homeassistant.components.sensibo.const import DOMAIN @@ -706,9 +706,45 @@ async def test_climate_set_timer( ) await hass.async_block_till_done() - state1 = hass.states.get("climate.hallway") + state_climate = hass.states.get("climate.hallway") assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN - assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(MultipleInvalid): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_TIMER, + { + ATTR_ENTITY_ID: state_climate.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_ENABLE_TIMER, + { + ATTR_ENTITY_ID: state_climate.entity_id, + ATTR_MINUTES: 30, + }, + blocking=True, + ) + await hass.async_block_till_done() with patch( "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", @@ -719,10 +755,9 @@ async def test_climate_set_timer( ): await hass.services.async_call( DOMAIN, - SERVICE_TIMER, + SERVICE_ENABLE_TIMER, { - ATTR_ENTITY_ID: state1.entity_id, - ATTR_STATE: "on", + ATTR_ENTITY_ID: state_climate.entity_id, ATTR_MINUTES: 30, }, blocking=True, @@ -752,166 +787,6 @@ async def test_climate_set_timer( hass.states.get("sensor.hallway_timer_end_time").state == "2022-06-06T12:00:00+00:00" ) - assert hass.states.get("binary_sensor.hallway_timer_running").state == "on" - assert hass.states.get("binary_sensor.hallway_timer_running").attributes == { - "device_class": "running", - "friendly_name": "Hallway Timer Running", - "icon": "mdi:timer", - "id": "SzTGE4oZ4D", - "turn_on": False, - } - - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), patch( - "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", - return_value={"status": "success"}, - ): - await hass.services.async_call( - DOMAIN, - SERVICE_TIMER, - { - ATTR_ENTITY_ID: state1.entity_id, - ATTR_STATE: "off", - }, - blocking=True, - ) - await hass.async_block_till_done() - - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", False) - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", None) - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", None) - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_time", None) - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN - assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" - - -async def test_climate_set_timer_failures( - hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, - load_int: ConfigEntry, - monkeypatch: pytest.MonkeyPatch, - get_data: SensiboData, -) -> None: - """Test the Sensibo climate Set Timer service failures.""" - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - state1 = hass.states.get("climate.hallway") - assert hass.states.get("sensor.hallway_timer_end_time").state == STATE_UNKNOWN - assert hass.states.get("binary_sensor.hallway_timer_running").state == "off" - - with pytest.raises(ValueError): - await hass.services.async_call( - DOMAIN, - SERVICE_TIMER, - { - ATTR_ENTITY_ID: state1.entity_id, - ATTR_STATE: "on", - }, - blocking=True, - ) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", - return_value={"status": "success", "result": {"id": ""}}, - ): - await hass.services.async_call( - DOMAIN, - SERVICE_TIMER, - { - ATTR_ENTITY_ID: state1.entity_id, - ATTR_STATE: "on", - ATTR_MINUTES: 30, - }, - blocking=True, - ) - await hass.async_block_till_done() - - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", None) - monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) - monkeypatch.setattr( - get_data.parsed["ABC999111"], - "timer_time", - datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), - ) - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_TIMER, - { - ATTR_ENTITY_ID: state1.entity_id, - ATTR_STATE: "off", - }, - blocking=True, - ) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), patch( - "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", - return_value={"status": "failure"}, - ): - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - DOMAIN, - SERVICE_TIMER, - { - ATTR_ENTITY_ID: state1.entity_id, - ATTR_STATE: "on", - ATTR_MINUTES: 30, - }, - blocking=True, - ) - await hass.async_block_till_done() async def test_climate_pure_boost( diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py new file mode 100644 index 00000000000..49efca4103e --- /dev/null +++ b/tests/components/sensibo/test_switch.py @@ -0,0 +1,165 @@ +"""The test for the sensibo switch platform.""" +from __future__ import annotations + +from datetime import timedelta +from unittest.mock import patch + +from pysensibo.model import SensiboData +import pytest +from pytest import MonkeyPatch + +from homeassistant.components.sensibo.switch import build_params +from homeassistant.components.switch.const import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt + +from tests.common import async_fire_time_changed + + +async def test_switch( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch.""" + + state1 = hass.states.get("switch.hallway_timer") + assert state1.state == STATE_OFF + assert state1.attributes["id"] is None + assert state1.attributes["turn_on"] is None + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", True) + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_id", "SzTGE4oZ4D") + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_state_on", False) + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + state1 = hass.states.get("switch.hallway_timer") + assert state1.state == STATE_ON + assert state1.attributes["id"] == "SzTGE4oZ4D" + assert state1.attributes["turn_on"] is False + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", + return_value={"status": "success", "result": {"id": "SzTGE4oZ4D"}}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "timer_on", False) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.hallway_timer") + assert state1.state == STATE_OFF + + +async def test_switch_command_failure( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch fails commands.""" + + state1 = hass.states.get("switch.hallway_timer") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_del_timer", + return_value={"status": "failure"}, + ): + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + + +async def test_build_params( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the build params method.""" + + assert build_params("set_timer", get_data.parsed["ABC999111"]) == { + "minutesFromNow": 60, + "acState": {**get_data.parsed["ABC999111"].ac_states, "on": False}, + } + assert build_params("incorrect_command", get_data.parsed["ABC999111"]) is None From 24bf42cfbe674f5cffc3778b0de8ec617c50aa78 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 19 Jun 2022 16:29:57 +0200 Subject: [PATCH 492/947] Update pylint to 2.14.3 (#73703) --- homeassistant/components/recorder/pool.py | 4 +--- requirements_test.txt | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/recorder/pool.py b/homeassistant/components/recorder/pool.py index 52b6b74dfa1..a8579df834c 100644 --- a/homeassistant/components/recorder/pool.py +++ b/homeassistant/components/recorder/pool.py @@ -87,9 +87,7 @@ class RecorderPool(SingletonThreadPool, NullPool): # type: ignore[misc] exclude_integrations={"recorder"}, error_if_core=False, ) - return super( # pylint: disable=bad-super-call - NullPool, self - )._create_connection() + return super(NullPool, self)._create_connection() class MutexPool(StaticPool): # type: ignore[misc] diff --git a/requirements_test.txt b/requirements_test.txt index 2ccbd6ab440..046d8bfb400 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -13,7 +13,7 @@ freezegun==1.2.1 mock-open==1.4.0 mypy==0.961 pre-commit==2.19.0 -pylint==2.14.1 +pylint==2.14.3 pipdeptree==2.2.1 pylint-strict-informational==0.1 pytest-aiohttp==0.3.0 From be6c2554dd73232ae4e0f0d9e3d92bcf9e4fba12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Sun, 19 Jun 2022 16:43:29 +0200 Subject: [PATCH 493/947] Add QNAP QSW DHCP discovery (#73130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * qnap_qsw: add DHCP discovery Signed-off-by: Álvaro Fernández Rojas * qnap_qsw: config_flow: add async_step_dhcp Signed-off-by: Álvaro Fernández Rojas * qnap_qsw: config_flow: lower DHCP logging Signed-off-by: Álvaro Fernández Rojas * tests: qnap_qsw: fix copy & paste Signed-off-by: Álvaro Fernández Rojas * qnap_qsw: dhcp: introduce changes suggested by @bdraco Signed-off-by: Álvaro Fernández Rojas * Update homeassistant/components/qnap_qsw/config_flow.py Co-authored-by: J. Nick Koston * qnap_qsw: async_step_user: disable raising on progress Allows async_step_user to win over a discovery. Signed-off-by: Álvaro Fernández Rojas Co-authored-by: J. Nick Koston --- .../components/qnap_qsw/config_flow.py | 67 ++++++++- .../components/qnap_qsw/manifest.json | 7 +- homeassistant/generated/dhcp.py | 1 + tests/components/qnap_qsw/test_config_flow.py | 137 +++++++++++++++++- 4 files changed, 209 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index 891c72c9911..e9d11433021 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -1,6 +1,7 @@ """Config flow for QNAP QSW.""" from __future__ import annotations +import logging from typing import Any from aioqsw.exceptions import LoginError, QswError @@ -8,6 +9,7 @@ from aioqsw.localapi import ConnectionOptions, QnapQswApi import voluptuous as vol from homeassistant import config_entries +from homeassistant.components import dhcp from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import aiohttp_client @@ -15,10 +17,15 @@ from homeassistant.helpers.device_registry import format_mac from .const import DOMAIN +_LOGGER = logging.getLogger(__name__) + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle config flow for a QNAP QSW device.""" + _discovered_mac: str | None = None + _discovered_url: str | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -46,7 +53,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if mac is None: raise AbortFlow("invalid_id") - await self.async_set_unique_id(format_mac(mac)) + await self.async_set_unique_id(format_mac(mac), raise_on_progress=False) self._abort_if_unique_id_configured() title = f"QNAP {system_board.get_product()} {mac}" @@ -63,3 +70,61 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), errors=errors, ) + + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle DHCP discovery.""" + self._discovered_url = f"http://{discovery_info.ip}" + self._discovered_mac = discovery_info.macaddress + + _LOGGER.debug("DHCP discovery detected QSW: %s", self._discovered_mac) + + mac = format_mac(self._discovered_mac) + options = ConnectionOptions(self._discovered_url, "", "") + qsw = QnapQswApi(aiohttp_client.async_get_clientsession(self.hass), options) + + try: + await qsw.get_live() + except QswError as err: + raise AbortFlow("cannot_connect") from err + + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured() + + return await self.async_step_discovered_connection() + + async def async_step_discovered_connection( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm discovery.""" + errors = {} + assert self._discovered_url is not None + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + qsw = QnapQswApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions(self._discovered_url, username, password), + ) + + try: + system_board = await qsw.validate() + except LoginError: + errors[CONF_PASSWORD] = "invalid_auth" + except QswError: + errors[CONF_URL] = "cannot_connect" + else: + title = f"QNAP {system_board.get_product()} {self._discovered_mac}" + return self.async_create_entry(title=title, data=user_input) + + return self.async_show_form( + step_id="discovered_connection", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/qnap_qsw/manifest.json b/homeassistant/components/qnap_qsw/manifest.json index 0dfd0e4793e..be565f2a07e 100644 --- a/homeassistant/components/qnap_qsw/manifest.json +++ b/homeassistant/components/qnap_qsw/manifest.json @@ -6,5 +6,10 @@ "requirements": ["aioqsw==0.1.0"], "codeowners": ["@Noltari"], "iot_class": "local_polling", - "loggers": ["aioqsw"] + "loggers": ["aioqsw"], + "dhcp": [ + { + "macaddress": "245EBE*" + } + ] } diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 43bf7ca2715..9f2438aafa1 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -74,6 +74,7 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'oncue', 'hostname': 'kohlergen*', 'macaddress': '00146F*'}, {'domain': 'overkiz', 'hostname': 'gateway*', 'macaddress': 'F8811A*'}, {'domain': 'powerwall', 'hostname': '1118431-*'}, + {'domain': 'qnap_qsw', 'macaddress': '245EBE*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '009D6B*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': 'F0038C*'}, {'domain': 'rachio', 'hostname': 'rachio-*', 'macaddress': '74C63B*'}, diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index e8cc9c56c0a..0b7072dd602 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -5,7 +5,8 @@ from unittest.mock import MagicMock, patch from aioqsw.const import API_MAC_ADDR, API_PRODUCT, API_RESULT from aioqsw.exceptions import LoginError, QswError -from homeassistant import data_entry_flow +from homeassistant import config_entries, data_entry_flow +from homeassistant.components import dhcp from homeassistant.components.qnap_qsw.const import DOMAIN from homeassistant.config_entries import SOURCE_USER, ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME @@ -16,6 +17,16 @@ from .util import CONFIG, LIVE_MOCK, SYSTEM_BOARD_MOCK, USERS_LOGIN_MOCK from tests.common import MockConfigEntry +DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( + hostname="qsw-m408-4c", + ip="192.168.1.200", + macaddress="245EBE000000", +) + +TEST_PASSWORD = "test-password" +TEST_URL = "test-url" +TEST_USERNAME = "test-username" + async def test_form(hass: HomeAssistant) -> None: """Test that the form is served with valid input.""" @@ -134,3 +145,127 @@ async def test_login_error(hass: HomeAssistant): ) assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} + + +async def test_dhcp_flow(hass: HomeAssistant) -> None: + """Test that DHCP discovery works.""" + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.qnap_qsw.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_system_board", + return_value=SYSTEM_BOARD_MOCK, + ), patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_users_login", + return_value=USERS_LOGIN_MOCK, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result2["type"] == "create_entry" + assert result2["data"] == { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + } + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_dhcp_flow_error(hass: HomeAssistant) -> None: + """Test that DHCP discovery fails.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + side_effect=QswError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "cannot_connect" + + +async def test_dhcp_connection_error(hass: HomeAssistant): + """Test DHCP connection to host error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + side_effect=QswError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["errors"] == {CONF_URL: "cannot_connect"} + + +async def test_dhcp_login_error(hass: HomeAssistant): + """Test DHCP login error.""" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_live", + return_value=LIVE_MOCK, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + data=DHCP_SERVICE_INFO, + context={"source": config_entries.SOURCE_DHCP}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "discovered_connection" + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.validate", + side_effect=LoginError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: TEST_USERNAME, + CONF_PASSWORD: TEST_PASSWORD, + }, + ) + + assert result["errors"] == {CONF_PASSWORD: "invalid_auth"} From 26641fc90df780e9e2e3b00f0efe18e6a4acda1c Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sun, 19 Jun 2022 19:59:37 +0200 Subject: [PATCH 494/947] Bump async-upnp-client to 0.31.2 (#73712) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/dlna_dms/manifest.json | 2 +- homeassistant/components/samsungtv/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/yeelight/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index cc72f5d4778..7e03d34e900 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Renderer", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.31.1"], + "requirements": ["async-upnp-client==0.31.2"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/dlna_dms/manifest.json b/homeassistant/components/dlna_dms/manifest.json index 590d1b8370a..a07f33d09dd 100644 --- a/homeassistant/components/dlna_dms/manifest.json +++ b/homeassistant/components/dlna_dms/manifest.json @@ -3,7 +3,7 @@ "name": "DLNA Digital Media Server", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dlna_dms", - "requirements": ["async-upnp-client==0.31.1"], + "requirements": ["async-upnp-client==0.31.2"], "dependencies": ["ssdp"], "after_dependencies": ["media_source"], "ssdp": [ diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index ce65af7d8bb..9cd068ef409 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -7,7 +7,7 @@ "samsungctl[websocket]==0.7.1", "samsungtvws[async,encrypted]==2.5.0", "wakeonlan==2.0.1", - "async-upnp-client==0.31.1" + "async-upnp-client==0.31.2" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index f0db05d9015..88e3d0f4286 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -2,7 +2,7 @@ "domain": "ssdp", "name": "Simple Service Discovery Protocol (SSDP)", "documentation": "https://www.home-assistant.io/integrations/ssdp", - "requirements": ["async-upnp-client==0.31.1"], + "requirements": ["async-upnp-client==0.31.2"], "dependencies": ["network"], "after_dependencies": ["zeroconf"], "codeowners": [], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index dc87e73fdee..a4b913ec4c8 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.31.1", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.31.2", "getmac==0.8.2"], "dependencies": ["network", "ssdp"], "codeowners": ["@StevenLooman", "@ehendrix23"], "ssdp": [ diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json index 57d32f315ba..1032ef0d2e5 100644 --- a/homeassistant/components/yeelight/manifest.json +++ b/homeassistant/components/yeelight/manifest.json @@ -2,7 +2,7 @@ "domain": "yeelight", "name": "Yeelight", "documentation": "https://www.home-assistant.io/integrations/yeelight", - "requirements": ["yeelight==0.7.10", "async-upnp-client==0.31.1"], + "requirements": ["yeelight==0.7.10", "async-upnp-client==0.31.2"], "codeowners": ["@zewelor", "@shenxn", "@starkillerOG", "@alexyao2015"], "config_flow": true, "dependencies": ["network"], diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f423c96dbac..8452b4e62fb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.11 aiohttp==3.8.1 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.31.1 +async-upnp-client==0.31.2 async_timeout==4.0.2 atomicwrites==1.4.0 attrs==21.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index ad12a87c152..9c37ee79679 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -339,7 +339,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.1 +async-upnp-client==0.31.2 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 886c0921e27..c793c9a564a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -281,7 +281,7 @@ arcam-fmj==0.12.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.31.1 +async-upnp-client==0.31.2 # homeassistant.components.sleepiq asyncsleepiq==1.2.3 From 9b930717205fd273b94cb69e71d9bcac102cd4cb Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 19 Jun 2022 14:12:01 -0400 Subject: [PATCH 495/947] Bump zwave-js-server-python to 0.38.0 (#73707) * Bump zwave-js-server-python to 0.38.0 * Fix test --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 40b74096f0c..f2670be7b80 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.37.2"], + "requirements": ["zwave-js-server-python==0.38.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 9c37ee79679..687abb03abd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ zigpy==0.46.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.2 +zwave-js-server-python==0.38.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c793c9a564a..1f07b1a4a1d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1671,7 +1671,7 @@ zigpy-znp==0.7.0 zigpy==0.46.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.37.2 +zwave-js-server-python==0.38.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 88fea684233..f303db6af75 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3372,7 +3372,7 @@ async def test_abort_firmware_update( ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) - client.async_send_command_no_wait.return_value = {} + client.async_send_command.return_value = {} await ws_client.send_json( { ID: 1, @@ -3383,8 +3383,8 @@ async def test_abort_firmware_update( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command_no_wait.call_args_list) == 1 - args = client.async_send_command_no_wait.call_args[0][0] + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] assert args["command"] == "node.abort_firmware_update" assert args["nodeId"] == multisensor_6.node_id From ab95299150ea489d42b6903454b9e064344f98fb Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 19 Jun 2022 11:16:07 -0700 Subject: [PATCH 496/947] Bump gcal_sync to 0.10.0 and fix `google` typing (#73710) Bump gcal_sync to 0.10.0 --- homeassistant/components/google/api.py | 4 ++-- homeassistant/components/google/calendar.py | 2 +- homeassistant/components/google/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index a4cda1ff41a..f4a4912a1b9 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -173,7 +173,7 @@ async def async_create_device_flow( return DeviceFlow(hass, oauth_flow, device_flow_info) -class ApiAuthImpl(AbstractAuth): # type: ignore[misc] +class ApiAuthImpl(AbstractAuth): """Authentication implementation for google calendar api library.""" def __init__( @@ -191,7 +191,7 @@ class ApiAuthImpl(AbstractAuth): # type: ignore[misc] return cast(str, self._session.token["access_token"]) -class AccessTokenAuthImpl(AbstractAuth): # type: ignore[misc] +class AccessTokenAuthImpl(AbstractAuth): """Authentication implementation used during config flow, without refresh. This exists to allow the config flow to use the API before it has fully diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index 39e3d69e6b9..e27eb0b1336 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -224,7 +224,7 @@ class GoogleCalendarEntity(CalendarEntity): """Return True if the event is visible.""" if self._ignore_availability: return True - return event.transparency == OPAQUE # type: ignore[no-any-return] + return event.transparency == OPAQUE async def async_get_events( self, hass: HomeAssistant, start_date: datetime, end_date: datetime diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json index 081eae34a95..d39f2093cf0 100644 --- a/homeassistant/components/google/manifest.json +++ b/homeassistant/components/google/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/calendar.google/", - "requirements": ["gcal-sync==0.9.0", "oauth2client==4.1.3"], + "requirements": ["gcal-sync==0.10.0", "oauth2client==4.1.3"], "codeowners": ["@allenporter"], "iot_class": "cloud_polling", "loggers": ["googleapiclient"] diff --git a/requirements_all.txt b/requirements_all.txt index 687abb03abd..494ad4f27ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -692,7 +692,7 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.9.0 +gcal-sync==0.10.0 # homeassistant.components.geniushub geniushub-client==0.6.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1f07b1a4a1d..af74cc1329c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -495,7 +495,7 @@ gTTS==2.2.4 garages-amsterdam==3.0.0 # homeassistant.components.google -gcal-sync==0.9.0 +gcal-sync==0.10.0 # homeassistant.components.geocaching geocachingapi==0.2.1 From 801ba6ff8e2d6161d5b6f3fd6622afe9570b9be9 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 19 Jun 2022 14:50:05 -0400 Subject: [PATCH 497/947] Add target option to zwave_js firmware upload view (#73690) --- homeassistant/components/zwave_js/api.py | 7 ++++++- tests/components/zwave_js/test_api.py | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index b41c6bf2f92..75180cfa84f 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import Callable import dataclasses from functools import partial, wraps -from typing import Any, Literal +from typing import Any, Literal, cast from aiohttp import web, web_exceptions, web_request import voluptuous as vol @@ -1976,6 +1976,10 @@ class FirmwareUploadView(HomeAssistantView): if "file" not in data or not isinstance(data["file"], web_request.FileField): raise web_exceptions.HTTPBadRequest + target = None + if "target" in data: + target = int(cast(str, data["target"])) + uploaded_file: web_request.FileField = data["file"] try: @@ -1985,6 +1989,7 @@ class FirmwareUploadView(HomeAssistantView): uploaded_file.filename, await hass.async_add_executor_job(uploaded_file.file.read), async_get_clientsession(hass), + target=target, ) except BaseZwaveJSServerError as err: raise web_exceptions.HTTPBadRequest(reason=str(err)) from err diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index f303db6af75..149fe394a6d 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -2847,9 +2847,10 @@ async def test_firmware_upload_view( ) as mock_cmd: resp = await client.post( f"/api/zwave_js/firmware/upload/{device.id}", - data={"file": firmware_file}, + data={"file": firmware_file, "target": "15"}, ) assert mock_cmd.call_args[0][1:4] == (multisensor_6, "file", bytes(10)) + assert mock_cmd.call_args[1] == {"target": 15} assert json.loads(await resp.text()) is None From bb5a6a71046830e5c784ef5f9b792cb84579218d Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 19 Jun 2022 14:50:47 -0400 Subject: [PATCH 498/947] Add `zwave_js/get_firmware_update_capabilties` WS command (#73691) * Add zwave_js/get_firmware_update_capabilties WS command * Fix test --- homeassistant/components/zwave_js/api.py | 24 +++++ tests/components/zwave_js/test_api.py | 112 +++++++++++++++++------ 2 files changed, 110 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 75180cfa84f..6b6286b78f4 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -417,6 +417,9 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command( hass, websocket_subscribe_firmware_update_status ) + websocket_api.async_register_command( + hass, websocket_get_firmware_update_capabilities + ) websocket_api.async_register_command(hass, websocket_check_for_config_updates) websocket_api.async_register_command(hass, websocket_install_config_update) websocket_api.async_register_command( @@ -1944,6 +1947,27 @@ async def websocket_subscribe_firmware_update_status( ) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_firmware_update_capabilities", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_get_firmware_update_capabilities( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Abort a firmware update.""" + capabilities = await node.async_get_firmware_update_capabilities() + connection.send_result(msg[ID], capabilities.to_dict()) + + class FirmwareUploadView(HomeAssistantView): """View to upload firmware.""" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 149fe394a6d..337c74e955b 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3423,19 +3423,10 @@ async def test_abort_firmware_update( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_LOADED - -async def test_abort_firmware_update_failures( - hass, multisensor_6, client, integration, hass_ws_client -): - """Test failures for the abort_firmware_update websocket command.""" - entry = integration - ws_client = await hass_ws_client(hass) - device = get_device(hass, multisensor_6) - # Test sending command with improper device ID fails await ws_client.send_json( { - ID: 2, + ID: 4, TYPE: "zwave_js/abort_firmware_update", DEVICE_ID: "fake_device", } @@ -3445,22 +3436,6 @@ async def test_abort_firmware_update_failures( assert not msg["success"] assert msg["error"]["code"] == ERR_NOT_FOUND - # Test sending command with not loaded entry fails - await hass.config_entries.async_unload(entry.entry_id) - await hass.async_block_till_done() - - await ws_client.send_json( - { - ID: 3, - TYPE: "zwave_js/abort_firmware_update", - DEVICE_ID: device.id, - } - ) - msg = await ws_client.receive_json() - - assert not msg["success"] - assert msg["error"]["code"] == ERR_NOT_LOADED - async def test_subscribe_firmware_update_status( hass, multisensor_6, integration, client, hass_ws_client @@ -3603,6 +3578,91 @@ async def test_subscribe_firmware_update_status_failures( assert msg["error"]["code"] == ERR_NOT_LOADED +async def test_get_firmware_update_capabilities( + hass, client, multisensor_6, integration, hass_ws_client +): + """Test that the get_firmware_update_capabilities WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + client.async_send_command.return_value = { + "capabilities": { + "firmwareUpgradable": True, + "firmwareTargets": [0], + "continuesToFunction": True, + "supportsActivation": True, + } + } + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] == { + "firmware_upgradable": True, + "firmware_targets": [0], + "continues_to_function": True, + "supports_activation": True, + } + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.get_firmware_update_capabilities" + assert args["nodeId"] == multisensor_6.node_id + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_get_firmware_update_capabilities", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + # Test sending command with improper device ID fails + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/get_firmware_update_capabilities", + DEVICE_ID: "fake_device", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + async def test_check_for_config_updates(hass, client, integration, hass_ws_client): """Test that the check_for_config_updates WS API call works.""" entry = integration From e53372f5590d8259336488c73b860b679e7bc738 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 19 Jun 2022 15:33:58 -0400 Subject: [PATCH 499/947] Add `zwave_js/get_firmware_update_progress` WS command (#73304) Add zwave_js/get_firmware_update_progress WS command --- homeassistant/components/zwave_js/api.py | 21 +++++++++ tests/components/zwave_js/test_api.py | 60 ++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 6b6286b78f4..ca25088a642 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -414,6 +414,7 @@ def async_register_api(hass: HomeAssistant) -> None: ) websocket_api.async_register_command(hass, websocket_data_collection_status) websocket_api.async_register_command(hass, websocket_abort_firmware_update) + websocket_api.async_register_command(hass, websocket_get_firmware_update_progress) websocket_api.async_register_command( hass, websocket_subscribe_firmware_update_status ) @@ -1867,6 +1868,26 @@ async def websocket_abort_firmware_update( connection.send_result(msg[ID]) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_firmware_update_progress", + vol.Required(DEVICE_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_node +async def websocket_get_firmware_update_progress( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + node: Node, +) -> None: + """Get whether firmware update is in progress.""" + connection.send_result(msg[ID], await node.async_get_firmware_update_progress()) + + def _get_firmware_update_progress_dict( progress: FirmwareUpdateProgress, ) -> dict[str, int]: diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 337c74e955b..1ed125cc43a 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3437,6 +3437,66 @@ async def test_abort_firmware_update( assert msg["error"]["code"] == ERR_NOT_FOUND +async def test_get_firmware_update_progress( + hass, client, multisensor_6, integration, hass_ws_client +): + """Test that the get_firmware_update_progress WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + device = get_device(hass, multisensor_6) + + client.async_send_command.return_value = {"progress": True} + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_firmware_update_progress", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.get_firmware_update_progress" + assert args["nodeId"] == multisensor_6.node_id + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.node.Node.async_get_firmware_update_progress", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/get_firmware_update_progress", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/get_firmware_update_progress", + DEVICE_ID: device.id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + async def test_subscribe_firmware_update_status( hass, multisensor_6, integration, client, hass_ws_client ): From e7e9c65e44f5c9a0bceb0f6737952acb8494dccd Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 19 Jun 2022 21:38:01 +0200 Subject: [PATCH 500/947] Adjust zha routine to get name and original_name (#73646) --- homeassistant/components/zha/api.py | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index faf8ccc5053..f99255f55a9 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -60,6 +60,7 @@ from .core.const import ( ZHA_CHANNEL_MSG, ZHA_CONFIG_SCHEMAS, ) +from .core.gateway import EntityReference from .core.group import GroupMember from .core.helpers import ( async_cluster_exists, @@ -316,6 +317,22 @@ async def websocket_get_devices( connection.send_result(msg[ID], devices) +@callback +def _get_entity_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.name if entry else None + + +@callback +def _get_entity_original_name( + zha_gateway: ZHAGateway, entity_ref: EntityReference +) -> str | None: + entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + return entry.original_name if entry else None + + @websocket_api.require_admin @websocket_api.websocket_command({vol.Required(TYPE): "zha/devices/groupable"}) @websocket_api.async_response @@ -336,12 +353,10 @@ async def websocket_get_groupable_devices( "endpoint_id": ep_id, "entities": [ { - "name": zha_gateway.ha_entity_registry.async_get( - entity_ref.reference_id - ).name, - "original_name": zha_gateway.ha_entity_registry.async_get( - entity_ref.reference_id - ).original_name, + "name": _get_entity_name(zha_gateway, entity_ref), + "original_name": _get_entity_original_name( + zha_gateway, entity_ref + ), } for entity_ref in entity_refs if list(entity_ref.cluster_channels.values())[ From a92105171cc69a5ada661a67a3abfaf8fa55b213 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sun, 19 Jun 2022 21:39:24 +0200 Subject: [PATCH 501/947] Remove vizio from mypy ignore list (#73585) * Remove vizio config_flow from mypy ignore list * Fix mypy errors * Adjust media_player * Add space --- homeassistant/components/vizio/config_flow.py | 10 ++++++---- homeassistant/components/vizio/media_player.py | 15 +++++++++------ homeassistant/helpers/config_validation.py | 2 +- mypy.ini | 6 ------ script/hassfest/mypy_config.py | 2 -- 5 files changed, 16 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 8acc602dd36..a80105579fe 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -187,11 +187,11 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize config flow.""" self._user_schema = None - self._must_show_form = None + self._must_show_form: bool | None = None self._ch_type = None self._pairing_token = None - self._data = None - self._apps = {} + self._data: dict[str, Any] | None = None + self._apps: dict[str, list] = {} async def _create_entry(self, input_dict: dict[str, Any]) -> FlowResult: """Create vizio config entry.""" @@ -387,10 +387,11 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Ask user for PIN to complete pairing process. """ - errors = {} + errors: dict[str, str] = {} # Start pairing process if it hasn't already started if not self._ch_type and not self._pairing_token: + assert self._data dev = VizioAsync( DEVICE_ID, self._data[CONF_HOST], @@ -448,6 +449,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _pairing_complete(self, step_id: str) -> FlowResult: """Handle config flow completion.""" + assert self._data if not self._must_show_form: return await self._create_entry(self._data) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index b2d33551020..ab48f1405a9 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import timedelta import logging -from typing import Any from pyvizio import VizioAsync from pyvizio.api.apps import find_app_name @@ -103,7 +102,10 @@ async def async_setup_entry( params["data"] = new_data if params: - hass.config_entries.async_update_entry(config_entry, **params) + hass.config_entries.async_update_entry( + config_entry, + **params, # type: ignore[arg-type] + ) device = VizioAsync( DEVICE_ID, @@ -134,7 +136,7 @@ class VizioDevice(MediaPlayerEntity): config_entry: ConfigEntry, device: VizioAsync, name: str, - device_class: str, + device_class: MediaPlayerDeviceClass, apps_coordinator: DataUpdateCoordinator, ) -> None: """Initialize Vizio device.""" @@ -145,8 +147,8 @@ class VizioDevice(MediaPlayerEntity): self._current_input = None self._current_app_config = None self._attr_app_name = None - self._available_inputs = [] - self._available_apps = [] + self._available_inputs: list[str] = [] + self._available_apps: list[str] = [] self._all_apps = apps_coordinator.data if apps_coordinator else None self._conf_apps = config_entry.options.get(CONF_APPS, {}) self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get( @@ -195,6 +197,7 @@ class VizioDevice(MediaPlayerEntity): self._attr_available = True if not self._attr_device_info: + assert self._attr_unique_id self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="VIZIO", @@ -276,7 +279,7 @@ class VizioDevice(MediaPlayerEntity): if self._attr_app_name == NO_APP_RUNNING: self._attr_app_name = None - def _get_additional_app_names(self) -> list[dict[str, Any]]: + def _get_additional_app_names(self) -> list[str]: """Return list of additional apps that were included in configuration.yaml.""" return [ additional_app["name"] for additional_app in self._additional_app_configs diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index f459d96040b..2ed4bc7abab 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -758,7 +758,7 @@ def ensure_list_csv(value: Any) -> list: class multi_select: """Multi select validator returning list of selected values.""" - def __init__(self, options: dict) -> None: + def __init__(self, options: dict | list) -> None: """Initialize multi select.""" self.options = options diff --git a/mypy.ini b/mypy.ini index 8a9c5b0478a..f19f5f5dc68 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2940,12 +2940,6 @@ ignore_errors = true [mypy-homeassistant.components.unifi.unifi_entity_base] ignore_errors = true -[mypy-homeassistant.components.vizio.config_flow] -ignore_errors = true - -[mypy-homeassistant.components.vizio.media_player] -ignore_errors = true - [mypy-homeassistant.components.withings] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b7fb778cfee..09a9820e7b4 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -129,8 +129,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.unifi.device_tracker", "homeassistant.components.unifi.diagnostics", "homeassistant.components.unifi.unifi_entity_base", - "homeassistant.components.vizio.config_flow", - "homeassistant.components.vizio.media_player", "homeassistant.components.withings", "homeassistant.components.withings.binary_sensor", "homeassistant.components.withings.common", From 6f8e0419f01899ddade4b5982f4d333261e25697 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 20 Jun 2022 00:22:34 +0000 Subject: [PATCH 502/947] [ci skip] Translation update --- homeassistant/components/nest/translations/ca.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index 7b1a9c91bb4..0767d9c1cf1 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -1,4 +1,7 @@ { + "application_credentials": { + "description": "Segueix les [instruccions]({more_info_url}) per configurar la Cloud Console: \n\n1. V\u00e9s a la [pantalla de consentiment OAuth]({oauth_consent_url}) i configura\n2. V\u00e9s a [Credencials]({oauth_creds_url}) i fes clic a **Crear credencials**.\n3. A la llista desplegable, selecciona **ID de client OAuth**.\n4. Selecciona **Aplicaci\u00f3 web** al tipus d'aplicaci\u00f3.\n5. Afegeix `{redirect_url}` a *URI de redirecci\u00f3 autoritzat*." + }, "config": { "abort": { "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", @@ -29,22 +32,30 @@ "description": "Per enlla\u00e7ar un compte de Google, [autoritza el compte]({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copia i enganxa a continuaci\u00f3 el codi 'token' d'autenticaci\u00f3 proporcionat.", "title": "Vinculaci\u00f3 amb compte de Google" }, + "auth_upgrade": { + "description": "Google ha deixat d'utilitzar l'autenticaci\u00f3 d'aplicacions per millorar la seguretat i has de crear noves credencials d'aplicaci\u00f3. \n\nConsulta la [documentaci\u00f3]({more_info_url}) i segueix els passos que et guiaran per tornar a tenir acc\u00e9s als teus dispositius Nest.", + "title": "Nest: l'autenticaci\u00f3 d'aplicaci\u00f3 s'acaba" + }, "cloud_project": { "data": { "cloud_project_id": "ID de projecte Google Cloud" }, + "description": "Introdueix l'identificador de projecte Cloud a continuaci\u00f3, per exemple, *exemple-projecte-12345*. Consulta [Google Cloud Console]({cloud_console_url}) o la [documentaci\u00f3]({more_info_url}) per a m\u00e9s informaci\u00f3.", "title": "Nest: introdueix l'identificador del projecte Cloud" }, "create_cloud_project": { + "description": "La integraci\u00f3 Nest et permet integrar els termostats, c\u00e0meres i timbres de Nest mitjan\u00e7ant l'API de gesti\u00f3 de dispositius intel\u00b7ligents. L'API SDM **estableix una tarifa de configuraci\u00f3 de 5\u202f$** nom\u00e9s la primera vegada. Consulta la documentaci\u00f3 per a [m\u00e9s informaci\u00f3]({more_info_url}). \n\n1. V\u00e9s a [Google Cloud Console]({cloud_console_url}).\n2. Si aquest \u00e9s el teu primer projecte, fes clic a **Crea projecte** i despr\u00e9s a **Projecte nou**.\n3. D\u00f3na-li un nom al teu projecte Cloud i fes clic a **Crea**.\n4. Desa l'identificador del projecte Cloud, per exemple, *exemple-projecte-12345*, ja que el necessitar\u00e0s m\u00e9s endavant\n5. V\u00e9s a la Biblioteca API de [API de gesti\u00f3 de dispositius intel\u00b7ligents]({sdm_api_url}) i fes clic a **Activar** ('Enable').\n6. V\u00e9s a la Biblioteca API de [API Cloud Pub/Sub]({pubsub_api_url}) i fes clic a **Activar** ('Enable'). \n\nContinua quan el teu projecte Cloud estigui configurat.", "title": "Nest: crea i configura el projecte Cloud" }, "device_project": { "data": { "project_id": "ID de projecte Device Access" }, + "description": "Crea un projecte d'acc\u00e9s a dispositius Nest que **requereix una tarifa de 5 $** per configurar-lo.\n1. V\u00e9s a [Consola d'acc\u00e9s al dispositiu]({device_access_console_url}) i a trav\u00e9s del flux de pagament.\n2. Fes clic a **Crea projecte**.\n3. D\u00f3na-li un nom al projecte d'acc\u00e9s a dispositius i feu clic a **Seg\u00fcent**.\n4. Introdueix el teu ID de client OAuth.\n5. Activa els esdeveniments fent clic a **Activa** i **Crea projecte**. \n\nIntrodueix el teu ID de projecte d'acc\u00e9s a dispositiu a continuaci\u00f3 ([m\u00e9s informaci\u00f3]({more_info_url})).\n", "title": "Nest: crea un projecte Device Access" }, "device_project_upgrade": { + "description": "Actualitza el projecte d'acc\u00e9s al dispositiu Nest amb el teu nou ID de client OAuth ([m\u00e9s informaci\u00f3]({more_info_url}))\n1. V\u00e9s a la [Consola d'acc\u00e9s al dispositiu]({device_access_console_url}).\n2. Fes clic a la icona de la paperera que hi ha al costat de *OAuth Client ID*.\n3. Feu clic al men\u00fa desplegable `...` i *Afegeix un ID de client*.\n4. Introdueix el teu nou ID de client OAuth i feu clic a **Afegeix**.\n\nEl teu ID de client OAuth \u00e9s: `{client_id}`", "title": "Nest: actualitza projecte Device Access" }, "init": { From fcd885954250e9e3589290fafbc429859d177fa5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 19 Jun 2022 21:24:42 -0500 Subject: [PATCH 503/947] Remove self from logbook codeowners (#73724) --- CODEOWNERS | 4 ++-- homeassistant/components/logbook/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d94e8866633..183430eb646 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -582,8 +582,8 @@ build.json @home-assistant/supervisor /tests/components/local_ip/ @issacg /homeassistant/components/lock/ @home-assistant/core /tests/components/lock/ @home-assistant/core -/homeassistant/components/logbook/ @home-assistant/core @bdraco -/tests/components/logbook/ @home-assistant/core @bdraco +/homeassistant/components/logbook/ @home-assistant/core +/tests/components/logbook/ @home-assistant/core /homeassistant/components/logger/ @home-assistant/core /tests/components/logger/ @home-assistant/core /homeassistant/components/logi_circle/ @evanjd diff --git a/homeassistant/components/logbook/manifest.json b/homeassistant/components/logbook/manifest.json index 26a45e74439..66c0348a2ac 100644 --- a/homeassistant/components/logbook/manifest.json +++ b/homeassistant/components/logbook/manifest.json @@ -3,6 +3,6 @@ "name": "Logbook", "documentation": "https://www.home-assistant.io/integrations/logbook", "dependencies": ["frontend", "http", "recorder"], - "codeowners": ["@home-assistant/core", "@bdraco"], + "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } From 57daeaa17471323806671763e012b7d5a8f53af1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 20 Jun 2022 08:51:12 +0200 Subject: [PATCH 504/947] Fix MQTT config schema to ensure correct validation (#73619) * Ensure config schema validation * Use correct schema for device_tracker * Remove schema validation from the platform setup * Remove loop to build schema --- homeassistant/components/mqtt/__init__.py | 6 +- .../components/mqtt/alarm_control_panel.py | 2 +- .../components/mqtt/binary_sensor.py | 4 +- homeassistant/components/mqtt/button.py | 4 +- homeassistant/components/mqtt/camera.py | 4 +- homeassistant/components/mqtt/climate.py | 4 +- homeassistant/components/mqtt/config.py | 107 +--------- .../components/mqtt/config_integration.py | 193 ++++++++++++++++++ homeassistant/components/mqtt/cover.py | 2 +- .../mqtt/device_tracker/__init__.py | 1 + .../mqtt/device_tracker/schema_discovery.py | 2 +- homeassistant/components/mqtt/fan.py | 4 +- homeassistant/components/mqtt/humidifier.py | 4 +- .../components/mqtt/light/__init__.py | 2 +- homeassistant/components/mqtt/lock.py | 2 +- homeassistant/components/mqtt/mixins.py | 22 +- homeassistant/components/mqtt/number.py | 4 +- homeassistant/components/mqtt/scene.py | 2 +- homeassistant/components/mqtt/select.py | 4 +- homeassistant/components/mqtt/sensor.py | 4 +- homeassistant/components/mqtt/siren.py | 2 +- homeassistant/components/mqtt/switch.py | 4 +- .../components/mqtt/vacuum/__init__.py | 4 +- tests/components/mqtt/test_humidifier.py | 13 ++ tests/components/mqtt/test_init.py | 34 ++- 25 files changed, 258 insertions(+), 176 deletions(-) create mode 100644 homeassistant/components/mqtt/config_integration.py diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 417d1400758..82b66ddc89e 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -47,7 +47,11 @@ from .client import ( # noqa: F401 publish, subscribe, ) -from .config import CONFIG_SCHEMA_BASE, DEFAULT_VALUES, DEPRECATED_CONFIG_KEYS +from .config_integration import ( + CONFIG_SCHEMA_BASE, + DEFAULT_VALUES, + DEPRECATED_CONFIG_KEYS, +) from .const import ( # noqa: F401 ATTR_PAYLOAD, ATTR_QOS, diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index c0c6f9732d7..6bb7d9cd0d1 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -148,7 +148,7 @@ async def async_setup_entry( """Set up MQTT alarm control panel through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery(hass, alarm.DOMAIN, PLATFORM_SCHEMA_MODERN) + await async_setup_platform_discovery(hass, alarm.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index cec065e20f2..39fd87c8b02 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -103,9 +103,7 @@ async def async_setup_entry( """Set up MQTT binary sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, binary_sensor.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, binary_sensor.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index afa9900db35..370243c3579 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -83,9 +83,7 @@ async def async_setup_entry( """Set up MQTT button through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, button.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, button.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 86db828b111..5c8d3bc48b2 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -81,9 +81,7 @@ async def async_setup_entry( """Set up MQTT camera through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, camera.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, camera.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index bdcc82f2c39..a26e9cba8df 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -392,9 +392,7 @@ async def async_setup_entry( """Set up MQTT climate device through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, climate.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, climate.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/config.py b/homeassistant/components/mqtt/config.py index 4f84d911418..8cfc3490f0c 100644 --- a/homeassistant/components/mqtt/config.py +++ b/homeassistant/components/mqtt/config.py @@ -3,126 +3,21 @@ from __future__ import annotations import voluptuous as vol -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_DISCOVERY, - CONF_PASSWORD, - CONF_PORT, - CONF_PROTOCOL, - CONF_USERNAME, - CONF_VALUE_TEMPLATE, -) +from homeassistant.const import CONF_VALUE_TEMPLATE from homeassistant.helpers import config_validation as cv from .const import ( - ATTR_PAYLOAD, - ATTR_QOS, - ATTR_RETAIN, - ATTR_TOPIC, - CONF_BIRTH_MESSAGE, - CONF_BROKER, - CONF_CERTIFICATE, - CONF_CLIENT_CERT, - CONF_CLIENT_KEY, CONF_COMMAND_TOPIC, - CONF_DISCOVERY_PREFIX, CONF_ENCODING, - CONF_KEEPALIVE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, - CONF_TLS_INSECURE, - CONF_TLS_VERSION, - CONF_WILL_MESSAGE, - DEFAULT_BIRTH, - DEFAULT_DISCOVERY, DEFAULT_ENCODING, - DEFAULT_PREFIX, DEFAULT_QOS, DEFAULT_RETAIN, - DEFAULT_WILL, - PLATFORMS, - PROTOCOL_31, - PROTOCOL_311, ) from .util import _VALID_QOS_SCHEMA, valid_publish_topic, valid_subscribe_topic -DEFAULT_PORT = 1883 -DEFAULT_KEEPALIVE = 60 -DEFAULT_PROTOCOL = PROTOCOL_311 -DEFAULT_TLS_PROTOCOL = "auto" - -DEFAULT_VALUES = { - CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, - CONF_DISCOVERY: DEFAULT_DISCOVERY, - CONF_PORT: DEFAULT_PORT, - CONF_TLS_VERSION: DEFAULT_TLS_PROTOCOL, - CONF_WILL_MESSAGE: DEFAULT_WILL, -} - -CLIENT_KEY_AUTH_MSG = ( - "client_key and client_cert must both be present in " - "the MQTT broker configuration" -) - -MQTT_WILL_BIRTH_SCHEMA = vol.Schema( - { - vol.Inclusive(ATTR_TOPIC, "topic_payload"): valid_publish_topic, - vol.Inclusive(ATTR_PAYLOAD, "topic_payload"): cv.string, - vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, - vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, - }, - required=True, -) - -PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( - {vol.Optional(platform.value): cv.ensure_list for platform in PLATFORMS} -) - -CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( - { - vol.Optional(CONF_CLIENT_ID): cv.string, - vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All( - vol.Coerce(int), vol.Range(min=15) - ), - vol.Optional(CONF_BROKER): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile), - vol.Inclusive( - CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Inclusive( - CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG - ): cv.isfile, - vol.Optional(CONF_TLS_INSECURE): cv.boolean, - vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), - vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All( - cv.string, vol.In([PROTOCOL_31, PROTOCOL_311]) - ), - vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, - vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, - vol.Optional(CONF_DISCOVERY): cv.boolean, - # discovery_prefix must be a valid publish topic because if no - # state topic is specified, it will be created with the given prefix. - vol.Optional( - CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX - ): valid_publish_topic, - } -) - -DEPRECATED_CONFIG_KEYS = [ - CONF_BIRTH_MESSAGE, - CONF_BROKER, - CONF_DISCOVERY, - CONF_PASSWORD, - CONF_PORT, - CONF_TLS_VERSION, - CONF_USERNAME, - CONF_WILL_MESSAGE, -] - SCHEMA_BASE = { vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, diff --git a/homeassistant/components/mqtt/config_integration.py b/homeassistant/components/mqtt/config_integration.py new file mode 100644 index 00000000000..ab685a63802 --- /dev/null +++ b/homeassistant/components/mqtt/config_integration.py @@ -0,0 +1,193 @@ +"""Support for MQTT platform config setup.""" +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_DISCOVERY, + CONF_PASSWORD, + CONF_PORT, + CONF_PROTOCOL, + CONF_USERNAME, + Platform, +) +from homeassistant.helpers import config_validation as cv + +from . import ( + alarm_control_panel as alarm_control_panel_platform, + binary_sensor as binary_sensor_platform, + button as button_platform, + camera as camera_platform, + climate as climate_platform, + cover as cover_platform, + device_tracker as device_tracker_platform, + fan as fan_platform, + humidifier as humidifier_platform, + light as light_platform, + lock as lock_platform, + number as number_platform, + scene as scene_platform, + select as select_platform, + sensor as sensor_platform, + siren as siren_platform, + switch as switch_platform, + vacuum as vacuum_platform, +) +from .const import ( + ATTR_PAYLOAD, + ATTR_QOS, + ATTR_RETAIN, + ATTR_TOPIC, + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_CERTIFICATE, + CONF_CLIENT_CERT, + CONF_CLIENT_KEY, + CONF_DISCOVERY_PREFIX, + CONF_KEEPALIVE, + CONF_TLS_INSECURE, + CONF_TLS_VERSION, + CONF_WILL_MESSAGE, + DEFAULT_BIRTH, + DEFAULT_DISCOVERY, + DEFAULT_PREFIX, + DEFAULT_QOS, + DEFAULT_RETAIN, + DEFAULT_WILL, + PROTOCOL_31, + PROTOCOL_311, +) +from .util import _VALID_QOS_SCHEMA, valid_publish_topic + +DEFAULT_PORT = 1883 +DEFAULT_KEEPALIVE = 60 +DEFAULT_PROTOCOL = PROTOCOL_311 +DEFAULT_TLS_PROTOCOL = "auto" + +DEFAULT_VALUES = { + CONF_BIRTH_MESSAGE: DEFAULT_BIRTH, + CONF_DISCOVERY: DEFAULT_DISCOVERY, + CONF_PORT: DEFAULT_PORT, + CONF_TLS_VERSION: DEFAULT_TLS_PROTOCOL, + CONF_WILL_MESSAGE: DEFAULT_WILL, +} + +PLATFORM_CONFIG_SCHEMA_BASE = vol.Schema( + { + Platform.ALARM_CONTROL_PANEL.value: vol.All( + cv.ensure_list, [alarm_control_panel_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.BINARY_SENSOR.value: vol.All( + cv.ensure_list, [binary_sensor_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.BUTTON.value: vol.All( + cv.ensure_list, [button_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.CAMERA.value: vol.All( + cv.ensure_list, [camera_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.CLIMATE.value: vol.All( + cv.ensure_list, [climate_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.COVER.value: vol.All( + cv.ensure_list, [cover_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.DEVICE_TRACKER.value: vol.All( + cv.ensure_list, [device_tracker_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.FAN.value: vol.All( + cv.ensure_list, [fan_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.HUMIDIFIER.value: vol.All( + cv.ensure_list, [humidifier_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.LOCK.value: vol.All( + cv.ensure_list, [lock_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.LIGHT.value: vol.All( + cv.ensure_list, [light_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.NUMBER.value: vol.All( + cv.ensure_list, [number_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.SCENE.value: vol.All( + cv.ensure_list, [scene_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.SELECT.value: vol.All( + cv.ensure_list, [select_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.SENSOR.value: vol.All( + cv.ensure_list, [sensor_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.SIREN.value: vol.All( + cv.ensure_list, [siren_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.SWITCH.value: vol.All( + cv.ensure_list, [switch_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + Platform.VACUUM.value: vol.All( + cv.ensure_list, [vacuum_platform.PLATFORM_SCHEMA_MODERN] # type: ignore[has-type] + ), + } +) + + +CLIENT_KEY_AUTH_MSG = ( + "client_key and client_cert must both be present in " + "the MQTT broker configuration" +) + +MQTT_WILL_BIRTH_SCHEMA = vol.Schema( + { + vol.Inclusive(ATTR_TOPIC, "topic_payload"): valid_publish_topic, + vol.Inclusive(ATTR_PAYLOAD, "topic_payload"): cv.string, + vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA, + vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean, + }, + required=True, +) + +CONFIG_SCHEMA_BASE = PLATFORM_CONFIG_SCHEMA_BASE.extend( + { + vol.Optional(CONF_CLIENT_ID): cv.string, + vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE): vol.All( + vol.Coerce(int), vol.Range(min=15) + ), + vol.Optional(CONF_BROKER): cv.string, + vol.Optional(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CERTIFICATE): vol.Any("auto", cv.isfile), + vol.Inclusive( + CONF_CLIENT_KEY, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG + ): cv.isfile, + vol.Inclusive( + CONF_CLIENT_CERT, "client_key_auth", msg=CLIENT_KEY_AUTH_MSG + ): cv.isfile, + vol.Optional(CONF_TLS_INSECURE): cv.boolean, + vol.Optional(CONF_TLS_VERSION): vol.Any("auto", "1.0", "1.1", "1.2"), + vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.All( + cv.string, vol.In([PROTOCOL_31, PROTOCOL_311]) + ), + vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, + vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA, + vol.Optional(CONF_DISCOVERY): cv.boolean, + # discovery_prefix must be a valid publish topic because if no + # state topic is specified, it will be created with the given prefix. + vol.Optional( + CONF_DISCOVERY_PREFIX, default=DEFAULT_PREFIX + ): valid_publish_topic, + } +) + +DEPRECATED_CONFIG_KEYS = [ + CONF_BIRTH_MESSAGE, + CONF_BROKER, + CONF_DISCOVERY, + CONF_PASSWORD, + CONF_PORT, + CONF_TLS_VERSION, + CONF_USERNAME, + CONF_WILL_MESSAGE, +] diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 8d4df0c301d..0901a4f63a6 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -241,7 +241,7 @@ async def async_setup_entry( """Set up MQTT cover through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery(hass, cover.DOMAIN, PLATFORM_SCHEMA_MODERN) + await async_setup_platform_discovery(hass, cover.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/device_tracker/__init__.py b/homeassistant/components/mqtt/device_tracker/__init__.py index bcd5bbd4ee1..1b6c2b25ff3 100644 --- a/homeassistant/components/mqtt/device_tracker/__init__.py +++ b/homeassistant/components/mqtt/device_tracker/__init__.py @@ -4,6 +4,7 @@ import voluptuous as vol from homeassistant.components import device_tracker from ..mixins import warn_for_legacy_schema +from .schema_discovery import PLATFORM_SCHEMA_MODERN # noqa: F401 from .schema_discovery import async_setup_entry_from_discovery from .schema_yaml import PLATFORM_SCHEMA_YAML, async_setup_scanner_from_yaml diff --git a/homeassistant/components/mqtt/device_tracker/schema_discovery.py b/homeassistant/components/mqtt/device_tracker/schema_discovery.py index 1b48e15b80e..1ba540c8243 100644 --- a/homeassistant/components/mqtt/device_tracker/schema_discovery.py +++ b/homeassistant/components/mqtt/device_tracker/schema_discovery.py @@ -54,7 +54,7 @@ async def async_setup_entry_from_discovery(hass, config_entry, async_add_entitie *( _async_setup_entity(hass, async_add_entities, config, config_entry) for config in await async_get_platform_config_from_yaml( - hass, device_tracker.DOMAIN, PLATFORM_SCHEMA_MODERN + hass, device_tracker.DOMAIN ) ) ) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index d0b4ff10692..4e1d1465ac2 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -231,9 +231,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT fan through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, fan.DOMAIN, PLATFORM_SCHEMA_MODERN) - ) + config_entry.async_on_unload(await async_setup_platform_discovery(hass, fan.DOMAIN)) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 1c9ec5dc201..d2856767cf0 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -188,9 +188,7 @@ async def async_setup_entry( """Set up MQTT humidifier through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, humidifier.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, humidifier.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 158ea6ffa0d..d4914cb9506 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -112,7 +112,7 @@ async def async_setup_entry( """Set up MQTT lights configured under the light platform key (deprecated).""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery(hass, light.DOMAIN, PLATFORM_SCHEMA_MODERN) + await async_setup_platform_discovery(hass, light.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 0bdb66ab48b..8cf65485a09 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -104,7 +104,7 @@ async def async_setup_entry( """Set up MQTT lock through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery(hass, lock.DOMAIN, PLATFORM_SCHEMA_MODERN) + await async_setup_platform_discovery(hass, lock.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index e768c2ff409..dcf387eb360 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -10,7 +10,6 @@ from typing import Any, Protocol, cast, final import voluptuous as vol -from homeassistant.config import async_log_exception from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_CONFIGURATION_URL, @@ -263,7 +262,7 @@ class SetupEntity(Protocol): async def async_setup_platform_discovery( - hass: HomeAssistant, platform_domain: str, schema: vol.Schema + hass: HomeAssistant, platform_domain: str ) -> CALLBACK_TYPE: """Set up platform discovery for manual config.""" @@ -282,7 +281,7 @@ async def async_setup_platform_discovery( *( discovery.async_load_platform(hass, platform_domain, DOMAIN, config, {}) for config in await async_get_platform_config_from_yaml( - hass, platform_domain, schema, config_yaml + hass, platform_domain, config_yaml ) ) ) @@ -295,32 +294,17 @@ async def async_setup_platform_discovery( async def async_get_platform_config_from_yaml( hass: HomeAssistant, platform_domain: str, - schema: vol.Schema, config_yaml: ConfigType = None, ) -> list[ConfigType]: """Return a list of validated configurations for the domain.""" - def async_validate_config( - hass: HomeAssistant, - config: list[ConfigType], - ) -> list[ConfigType]: - """Validate config.""" - validated_config = [] - for config_item in config: - try: - validated_config.append(schema(config_item)) - except vol.MultipleInvalid as err: - async_log_exception(err, platform_domain, config_item, hass) - - return validated_config - if config_yaml is None: config_yaml = hass.data.get(DATA_MQTT_CONFIG) if not config_yaml: return [] if not (platform_configs := config_yaml.get(platform_domain)): return [] - return async_validate_config(hass, platform_configs) + return platform_configs async def async_setup_entry_helper(hass, domain, async_setup, schema): diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index bbc78ae07db..660ffe987f0 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -136,9 +136,7 @@ async def async_setup_entry( """Set up MQTT number through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, number.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, number.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 9c4a212bd8e..cc911cc3431 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -80,7 +80,7 @@ async def async_setup_entry( """Set up MQTT scene through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery(hass, scene.DOMAIN, PLATFORM_SCHEMA_MODERN) + await async_setup_platform_discovery(hass, scene.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 994c11653b7..0d9f1411fd1 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -95,9 +95,7 @@ async def async_setup_entry( """Set up MQTT select through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, select.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, select.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index f9e0b5151bb..672e22f632f 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -148,9 +148,7 @@ async def async_setup_entry( """Set up MQTT sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, sensor.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, sensor.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index fef2a4fb3dd..e7b91274f4f 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -144,7 +144,7 @@ async def async_setup_entry( """Set up MQTT siren through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery(hass, siren.DOMAIN, PLATFORM_SCHEMA_MODERN) + await async_setup_platform_discovery(hass, siren.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index be7fc655e1e..dadd5f86f20 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -98,9 +98,7 @@ async def async_setup_entry( """Set up MQTT switch through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, switch.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, switch.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 206a15a024a..694e9530939 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -92,9 +92,7 @@ async def async_setup_entry( """Set up MQTT vacuum through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml config_entry.async_on_unload( - await async_setup_platform_discovery( - hass, vacuum.DOMAIN, PLATFORM_SCHEMA_MODERN - ) + await async_setup_platform_discovery(hass, vacuum.DOMAIN) ) # setup for discovery setup = functools.partial( diff --git a/tests/components/mqtt/test_humidifier.py b/tests/components/mqtt/test_humidifier.py index e1e757762df..0301e9e0481 100644 --- a/tests/components/mqtt/test_humidifier.py +++ b/tests/components/mqtt/test_humidifier.py @@ -13,6 +13,7 @@ from homeassistant.components.humidifier import ( SERVICE_SET_HUMIDITY, SERVICE_SET_MODE, ) +from homeassistant.components.mqtt import CONFIG_SCHEMA from homeassistant.components.mqtt.humidifier import ( CONF_MODE_COMMAND_TOPIC, CONF_MODE_STATE_TOPIC, @@ -1283,3 +1284,15 @@ async def test_setup_manual_entity_from_yaml(hass): del config["platform"] await help_test_setup_manual_entity_from_yaml(hass, platform, config) assert hass.states.get(f"{platform}.test") is not None + + +async def test_config_schema_validation(hass): + """Test invalid platform options in the config schema do pass the config validation.""" + platform = humidifier.DOMAIN + config = copy.deepcopy(DEFAULT_CONFIG[platform]) + config["name"] = "test" + del config["platform"] + CONFIG_SCHEMA({DOMAIN: {platform: config}}) + CONFIG_SCHEMA({DOMAIN: {platform: [config]}}) + with pytest.raises(MultipleInvalid): + CONFIG_SCHEMA({"mqtt": {"humidifier": [{"bla": "bla"}]}}) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index b75def64834..a29f1fd88ef 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -14,7 +14,7 @@ import yaml from homeassistant import config as hass_config from homeassistant.components import mqtt -from homeassistant.components.mqtt import debug_info +from homeassistant.components.mqtt import CONFIG_SCHEMA, debug_info from homeassistant.components.mqtt.mixins import MQTT_ENTITY_DEVICE_INFO_SCHEMA from homeassistant.components.mqtt.models import ReceiveMessage from homeassistant.const import ( @@ -1373,40 +1373,47 @@ async def test_setup_override_configuration(hass, caplog, tmp_path): assert calls_username_password_set[0][1] == "somepassword" -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_with_platform_key(hass, caplog): """Test set up a manual MQTT item with a platform key.""" config = {"platform": "mqtt", "name": "test", "command_topic": "test-topic"} - await help_test_setup_manual_entity_from_yaml(hass, "light", config) + with pytest.raises(AssertionError): + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert ( - "Invalid config for [light]: [platform] is an invalid option for [light]. " - "Check: light->platform. (See ?, line ?)" in caplog.text + "Invalid config for [mqtt]: [platform] is an invalid option for [mqtt]" + in caplog.text ) -@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_with_invalid_config(hass, caplog): """Test set up a manual MQTT item with an invalid config.""" config = {"name": "test"} - await help_test_setup_manual_entity_from_yaml(hass, "light", config) + with pytest.raises(AssertionError): + await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert ( - "Invalid config for [light]: required key not provided @ data['command_topic']." + "Invalid config for [mqtt]: required key not provided @ data['mqtt']['light'][0]['command_topic']." " Got None. (See ?, line ?)" in caplog.text ) +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_manual_mqtt_empty_platform(hass, caplog): """Test set up a manual MQTT platform without items.""" - config = None + config = [] await help_test_setup_manual_entity_from_yaml(hass, "light", config) assert "voluptuous.error.MultipleInvalid" not in caplog.text +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_setup_mqtt_client_protocol(hass): """Test MQTT client protocol setup.""" entry = MockConfigEntry( domain=mqtt.DOMAIN, - data={mqtt.CONF_BROKER: "test-broker", mqtt.config.CONF_PROTOCOL: "3.1"}, + data={ + mqtt.CONF_BROKER: "test-broker", + mqtt.config_integration.CONF_PROTOCOL: "3.1", + }, ) with patch("paho.mqtt.client.Client") as mock_client: mock_client.on_connect(return_value=0) @@ -2612,3 +2619,10 @@ async def test_one_deprecation_warning_per_platform( ): count += 1 assert count == 1 + + +async def test_config_schema_validation(hass): + """Test invalid platform options in the config schema do not pass the config validation.""" + config = {"mqtt": {"sensor": [{"some_illegal_topic": "mystate/topic/path"}]}} + with pytest.raises(vol.MultipleInvalid): + CONFIG_SCHEMA(config) From 2c936addd2470e7e6c67e6d78500fcb3babe0ec8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jun 2022 08:52:37 +0200 Subject: [PATCH 505/947] Fix handling of illegal dates in onvif sensor (#73600) * Fix handling of illegal dates in onvif sensor * Address review comment * Address review comment --- homeassistant/components/onvif/parsers.py | 38 ++++++++++++++--------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 5141f25cbef..87b901d2c52 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -1,4 +1,6 @@ """ONVIF event parsers.""" +from __future__ import annotations + from collections.abc import Callable, Coroutine import datetime from typing import Any @@ -12,16 +14,16 @@ from .models import Event PARSERS: Registry[str, Callable[[str, Any], Coroutine[Any, Any, Event]]] = Registry() -def datetime_or_zero(value: str) -> datetime: - """Convert strings to datetimes, if invalid, return datetime.min.""" +def local_datetime_or_none(value: str) -> datetime.datetime | None: + """Convert strings to datetimes, if invalid, return None.""" # To handle cameras that return times like '0000-00-00T00:00:00Z' (e.g. hikvision) try: ret = dt_util.parse_datetime(value) except ValueError: - return datetime.datetime.min - if ret is None: - return datetime.datetime.min - return ret + return None + if ret is not None: + return dt_util.as_local(ret) + return None @PARSERS.register("tns1:VideoSource/MotionAlarm") @@ -394,14 +396,16 @@ async def async_parse_last_reboot(uid: str, msg) -> Event: Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - date_time = datetime_or_zero(msg.Message._value_1.Data.SimpleItem[0].Value) + date_time = local_datetime_or_none( + msg.Message._value_1.Data.SimpleItem[0].Value + ) return Event( f"{uid}_{msg.Topic._value_1}", "Last Reboot", "sensor", "timestamp", None, - dt_util.as_local(date_time), + date_time, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -416,14 +420,16 @@ async def async_parse_last_reset(uid: str, msg) -> Event: Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - date_time = datetime_or_zero(msg.Message._value_1.Data.SimpleItem[0].Value) + date_time = local_datetime_or_none( + msg.Message._value_1.Data.SimpleItem[0].Value + ) return Event( f"{uid}_{msg.Topic._value_1}", "Last Reset", "sensor", "timestamp", None, - dt_util.as_local(date_time), + date_time, EntityCategory.DIAGNOSTIC, entity_enabled=False, ) @@ -440,14 +446,16 @@ async def async_parse_backup_last(uid: str, msg) -> Event: """ try: - date_time = datetime_or_zero(msg.Message._value_1.Data.SimpleItem[0].Value) + date_time = local_datetime_or_none( + msg.Message._value_1.Data.SimpleItem[0].Value + ) return Event( f"{uid}_{msg.Topic._value_1}", "Last Backup", "sensor", "timestamp", None, - dt_util.as_local(date_time), + date_time, EntityCategory.DIAGNOSTIC, entity_enabled=False, ) @@ -463,14 +471,16 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event: Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - date_time = datetime_or_zero(msg.Message._value_1.Data.SimpleItem[0].Value) + date_time = local_datetime_or_none( + msg.Message._value_1.Data.SimpleItem[0].Value + ) return Event( f"{uid}_{msg.Topic._value_1}", "Last Clock Synchronization", "sensor", "timestamp", None, - dt_util.as_local(date_time), + date_time, EntityCategory.DIAGNOSTIC, entity_enabled=False, ) From cf000fae1be1ab3b0bf48793106f44c6dba5c84d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jun 2022 02:04:54 -0500 Subject: [PATCH 506/947] Remove self from tplink codeowners (#73723) --- CODEOWNERS | 4 ++-- homeassistant/components/tplink/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 183430eb646..0a1a33794fc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1068,8 +1068,8 @@ build.json @home-assistant/supervisor /tests/components/tomorrowio/ @raman325 @lymanepp /homeassistant/components/totalconnect/ @austinmroczek /tests/components/totalconnect/ @austinmroczek -/homeassistant/components/tplink/ @rytilahti @thegardenmonkey @bdraco -/tests/components/tplink/ @rytilahti @thegardenmonkey @bdraco +/homeassistant/components/tplink/ @rytilahti @thegardenmonkey +/tests/components/tplink/ @rytilahti @thegardenmonkey /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus /homeassistant/components/trace/ @home-assistant/core diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json index 9a419302a18..e9cc687cc02 100644 --- a/homeassistant/components/tplink/manifest.json +++ b/homeassistant/components/tplink/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tplink", "requirements": ["python-kasa==0.5.0"], - "codeowners": ["@rytilahti", "@thegardenmonkey", "@bdraco"], + "codeowners": ["@rytilahti", "@thegardenmonkey"], "dependencies": ["network"], "quality_scale": "platinum", "iot_class": "local_polling", From 1f4add0119d12d2ada35d7917de5a8ac3fab9c46 Mon Sep 17 00:00:00 2001 From: Max Gashkov Date: Mon, 20 Jun 2022 16:05:28 +0900 Subject: [PATCH 507/947] Fix AmbiClimate services definition (#73668) --- homeassistant/components/ambiclimate/services.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ambiclimate/services.yaml b/homeassistant/components/ambiclimate/services.yaml index f75857e4d2e..e5532ae82f9 100644 --- a/homeassistant/components/ambiclimate/services.yaml +++ b/homeassistant/components/ambiclimate/services.yaml @@ -5,7 +5,7 @@ set_comfort_mode: description: > Enable comfort mode on your AC. fields: - Name: + name: description: > String with device name. required: true @@ -18,14 +18,14 @@ send_comfort_feedback: description: > Send feedback for comfort mode. fields: - Name: + name: description: > String with device name. required: true example: Bedroom selector: text: - Value: + value: description: > Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing required: true @@ -38,14 +38,14 @@ set_temperature_mode: description: > Enable temperature mode on your AC. fields: - Name: + name: description: > String with device name. required: true example: Bedroom selector: text: - Value: + value: description: > Target value in celsius required: true From bd29b91867a24e7ee9fa210bf081050aec1cef50 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 09:08:11 +0200 Subject: [PATCH 508/947] Use Mapping for async_step_reauth (a-e) (#72763) * Adjust abode * Adjust airvisual * Adjust aladdin_connect * Adjust ambee * Adjust aussie-broadband * Adjust brunt * Adjust cloudflare * Adjust deconz * Adjust deluge * Adjust devolo_home_control * Adjust efergy * Adjust esphome --- homeassistant/components/abode/config_flow.py | 3 ++- homeassistant/components/airvisual/config_flow.py | 6 ++++-- homeassistant/components/aladdin_connect/config_flow.py | 5 ++--- homeassistant/components/ambee/config_flow.py | 3 ++- homeassistant/components/aussie_broadband/config_flow.py | 5 +++-- homeassistant/components/brunt/config_flow.py | 5 ++--- homeassistant/components/cloudflare/config_flow.py | 3 ++- homeassistant/components/deconz/config_flow.py | 3 ++- homeassistant/components/deluge/config_flow.py | 3 ++- .../components/devolo_home_control/config_flow.py | 7 ++++--- homeassistant/components/efergy/config_flow.py | 3 ++- homeassistant/components/esphome/config_flow.py | 3 ++- 12 files changed, 29 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index 715bc53e2b2..1cbab2bdbe4 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Abode Security System component.""" from __future__ import annotations +from collections.abc import Mapping from http import HTTPStatus from typing import Any, cast @@ -149,7 +150,7 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_abode_mfa_login() - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle reauthorization request from Abode.""" self._username = config[CONF_USERNAME] diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 85ee4bf6ae5..516b8906092 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -2,6 +2,8 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping +from typing import Any from pyairvisual import CloudAPI, NodeSamba from pyairvisual.errors import ( @@ -70,7 +72,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the config flow.""" - self._entry_data_for_reauth: dict[str, str] = {} + self._entry_data_for_reauth: Mapping[str, Any] = {} self._geo_id: str | None = None @property @@ -219,7 +221,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO}, ) - async def async_step_reauth(self, data: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._entry_data_for_reauth = data self._geo_id = async_get_geography_id(data) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index 153a63ffb06..f0f622b3ab7 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Aladdin Connect cover integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -44,9 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 entry: config_entries.ConfigEntry | None - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Aladdin Connect.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/ambee/config_flow.py b/homeassistant/components/ambee/config_flow.py index 0550c541ed0..6c11f01b759 100644 --- a/homeassistant/components/ambee/config_flow.py +++ b/homeassistant/components/ambee/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Ambee integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from ambee import Ambee, AmbeeAuthenticationError, AmbeeError @@ -71,7 +72,7 @@ class AmbeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Ambee.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/aussie_broadband/config_flow.py b/homeassistant/components/aussie_broadband/config_flow.py index 6e101250386..37de59d4767 100644 --- a/homeassistant/components/aussie_broadband/config_flow.py +++ b/homeassistant/components/aussie_broadband/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Aussie Broadband integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from aiohttp import ClientError @@ -76,9 +77,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input: dict[str, str]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle reauth on credential failure.""" - self._reauth_username = user_input[CONF_USERNAME] + self._reauth_username = data[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py index c81eb2de6ca..bba58deea45 100644 --- a/homeassistant/components/brunt/config_flow.py +++ b/homeassistant/components/brunt/config_flow.py @@ -1,6 +1,7 @@ """Config flow for brunt integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -77,9 +78,7 @@ class BruntConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index 121e0fc9974..af67cbe8ffc 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Cloudflare integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -97,7 +98,7 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): self.zones: list[str] | None = None self.records: list[str] | None = None - async def async_step_reauth(self, data: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Cloudflare.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 28205a7382d..6e2a286c168 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from pprint import pformat from typing import Any, cast from urllib.parse import urlparse @@ -204,7 +205,7 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" self.context["title_placeholders"] = {CONF_HOST: config[CONF_HOST]} diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index 2f38d4d447d..a4bb1893b7e 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -1,6 +1,7 @@ """Config flow for the Deluge integration.""" from __future__ import annotations +from collections.abc import Mapping import socket from ssl import SSLError from typing import Any @@ -75,7 +76,7 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_user() diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index e0e49197f45..fc1689e0742 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the devolo home control integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any import voluptuous as vol @@ -67,14 +68,14 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="zeroconf_confirm", errors={"base": "invalid_auth"} ) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle reauthentication.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - self._url = user_input[CONF_MYDEVOLO] + self._url = data[CONF_MYDEVOLO] self.data_schema = { - vol.Required(CONF_USERNAME, default=user_input[CONF_USERNAME]): str, + vol.Required(CONF_USERNAME, default=data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, } return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/efergy/config_flow.py b/homeassistant/components/efergy/config_flow.py index 5ff6e9ba9f2..0abf99c2504 100644 --- a/homeassistant/components/efergy/config_flow.py +++ b/homeassistant/components/efergy/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Efergy integration.""" from __future__ import annotations +from collections.abc import Mapping from typing import Any from pyefergy import Efergy, exceptions @@ -52,7 +53,7 @@ class EfergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_user() diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index b73743ee950..552d7ed420e 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections import OrderedDict +from collections.abc import Mapping from typing import Any from aioesphomeapi import ( @@ -63,7 +64,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" return await self._async_step_user_base(user_input=user_input) - async def async_step_reauth(self, data: dict[str, Any] | None = None) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle a flow initialized by a reauth event.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry is not None From 4e6d753d2fd9de4e5628012c4fcef4b67dd93180 Mon Sep 17 00:00:00 2001 From: Erwin Oldenkamp Date: Mon, 20 Jun 2022 10:10:10 +0200 Subject: [PATCH 509/947] Add support for the locked status but car is connected (#73551) --- homeassistant/components/wallbox/__init__.py | 2 +- homeassistant/components/wallbox/const.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/wallbox/__init__.py b/homeassistant/components/wallbox/__init__.py index 332a1ee6741..ae003d84a9c 100644 --- a/homeassistant/components/wallbox/__init__.py +++ b/homeassistant/components/wallbox/__init__.py @@ -70,7 +70,7 @@ CHARGER_STATUS: dict[int, ChargerStatus] = { 195: ChargerStatus.CHARGING, 196: ChargerStatus.DISCHARGING, 209: ChargerStatus.LOCKED, - 210: ChargerStatus.LOCKED, + 210: ChargerStatus.LOCKED_CAR_CONNECTED, } diff --git a/homeassistant/components/wallbox/const.py b/homeassistant/components/wallbox/const.py index 6152207427b..0e4e1477911 100644 --- a/homeassistant/components/wallbox/const.py +++ b/homeassistant/components/wallbox/const.py @@ -41,6 +41,7 @@ class ChargerStatus(StrEnum): ERROR = "Error" READY = "Ready" LOCKED = "Locked" + LOCKED_CAR_CONNECTED = "Locked, car connected" UPDATING = "Updating" WAITING_IN_QUEUE_POWER_SHARING = "Waiting in queue by Power Sharing" WAITING_IN_QUEUE_POWER_BOOST = "Waiting in queue by Power Boost" From 9680a367c828a7d9b0483214f507b161305ba317 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jun 2022 10:26:24 +0200 Subject: [PATCH 510/947] Prevent using deprecated number features (#73578) --- homeassistant/components/number/__init__.py | 38 +++++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index f0095e2aecb..fe438ea6aea 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -120,13 +120,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class NumberEntityDescription(EntityDescription): """A class that describes number entities.""" - max_value: float | None = None - min_value: float | None = None + max_value: None = None + min_value: None = None native_max_value: float | None = None native_min_value: float | None = None native_unit_of_measurement: str | None = None native_step: float | None = None - step: float | None = None + step: None = None + unit_of_measurement: None = None # Type override, use native_unit_of_measurement def __post_init__(self) -> None: """Post initialisation processing.""" @@ -136,7 +137,7 @@ class NumberEntityDescription(EntityDescription): or self.step is not None or self.unit_of_measurement is not None ): - if self.__class__.__name__ == "NumberEntityDescription": + if self.__class__.__name__ == "NumberEntityDescription": # type: ignore[unreachable] caller = inspect.stack()[2] module = inspect.getmodule(caller[0]) else: @@ -180,12 +181,12 @@ class NumberEntity(Entity): """Representation of a Number entity.""" entity_description: NumberEntityDescription - _attr_max_value: float - _attr_min_value: float + _attr_max_value: None + _attr_min_value: None _attr_state: None = None - _attr_step: float + _attr_step: None _attr_mode: NumberMode = NumberMode.AUTO - _attr_value: float + _attr_value: None _attr_native_max_value: float _attr_native_min_value: float _attr_native_step: float @@ -248,16 +249,17 @@ class NumberEntity(Entity): return DEFAULT_MIN_VALUE @property + @final def min_value(self) -> float: """Return the minimum value.""" if hasattr(self, "_attr_min_value"): self._report_deprecated_number_entity() - return self._attr_min_value + return self._attr_min_value # type: ignore[return-value] if ( hasattr(self, "entity_description") and self.entity_description.min_value is not None ): - self._report_deprecated_number_entity() + self._report_deprecated_number_entity() # type: ignore[unreachable] return self.entity_description.min_value return self._convert_to_state_value(self.native_min_value, floor_decimal) @@ -274,16 +276,17 @@ class NumberEntity(Entity): return DEFAULT_MAX_VALUE @property + @final def max_value(self) -> float: """Return the maximum value.""" if hasattr(self, "_attr_max_value"): self._report_deprecated_number_entity() - return self._attr_max_value + return self._attr_max_value # type: ignore[return-value] if ( hasattr(self, "entity_description") and self.entity_description.max_value is not None ): - self._report_deprecated_number_entity() + self._report_deprecated_number_entity() # type: ignore[unreachable] return self.entity_description.max_value return self._convert_to_state_value(self.native_max_value, ceil_decimal) @@ -298,16 +301,17 @@ class NumberEntity(Entity): return None @property + @final def step(self) -> float: """Return the increment/decrement step.""" if hasattr(self, "_attr_step"): self._report_deprecated_number_entity() - return self._attr_step + return self._attr_step # type: ignore[return-value] if ( hasattr(self, "entity_description") and self.entity_description.step is not None ): - self._report_deprecated_number_entity() + self._report_deprecated_number_entity() # type: ignore[unreachable] return self.entity_description.step if hasattr(self, "_attr_native_step"): return self._attr_native_step @@ -341,6 +345,7 @@ class NumberEntity(Entity): return None @property + @final def unit_of_measurement(self) -> str | None: """Return the unit of measurement of the entity, after unit conversion.""" if hasattr(self, "_attr_unit_of_measurement"): @@ -349,7 +354,7 @@ class NumberEntity(Entity): hasattr(self, "entity_description") and self.entity_description.unit_of_measurement is not None ): - return self.entity_description.unit_of_measurement + return self.entity_description.unit_of_measurement # type: ignore[unreachable] native_unit_of_measurement = self.native_unit_of_measurement @@ -367,6 +372,7 @@ class NumberEntity(Entity): return self._attr_native_value @property + @final def value(self) -> float | None: """Return the entity value to represent the entity state.""" if hasattr(self, "_attr_value"): @@ -385,10 +391,12 @@ class NumberEntity(Entity): """Set new value.""" await self.hass.async_add_executor_job(self.set_native_value, value) + @final def set_value(self, value: float) -> None: """Set new value.""" raise NotImplementedError() + @final async def async_set_value(self, value: float) -> None: """Set new value.""" await self.hass.async_add_executor_job(self.set_value, value) From cd08f1d0c0b79ebe9ed364ed62dcc0d934036403 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jun 2022 10:26:50 +0200 Subject: [PATCH 511/947] Don't attempt to reload MQTT device tracker (#73577) --- homeassistant/components/mqtt/__init__.py | 3 ++- homeassistant/components/mqtt/const.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 82b66ddc89e..6fd288a86cf 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -80,6 +80,7 @@ from .const import ( # noqa: F401 MQTT_DISCONNECTED, MQTT_RELOADED, PLATFORMS, + RELOADABLE_PLATFORMS, ) from .models import ( # noqa: F401 MqttCommandTemplate, @@ -380,7 +381,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Setup reload service. Once support for legacy config is removed in 2022.9, we # should no longer call async_setup_reload_service but instead implement a custom # service - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + await async_setup_reload_service(hass, DOMAIN, RELOADABLE_PLATFORMS) async def _async_reload_platforms(_: Event | None) -> None: """Discover entities for a platform.""" diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index b05fd867eeb..67a9208faba 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -92,3 +92,23 @@ PLATFORMS = [ Platform.SWITCH, Platform.VACUUM, ] + +RELOADABLE_PLATFORMS = [ + Platform.ALARM_CONTROL_PANEL, + Platform.BINARY_SENSOR, + Platform.BUTTON, + Platform.CAMERA, + Platform.CLIMATE, + Platform.COVER, + Platform.FAN, + Platform.HUMIDIFIER, + Platform.LIGHT, + Platform.LOCK, + Platform.NUMBER, + Platform.SELECT, + Platform.SCENE, + Platform.SENSOR, + Platform.SIREN, + Platform.SWITCH, + Platform.VACUUM, +] From 006ea441ade1311bbb4962e5d5d11285e5aae6ef Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jun 2022 03:27:38 -0500 Subject: [PATCH 512/947] Pickup emulated_hue codeowner (#73725) - I made some changes to this during this cycle so I want to get notifications for the next release. --- CODEOWNERS | 2 ++ homeassistant/components/emulated_hue/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index 0a1a33794fc..b8e6a4ab7a7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -285,6 +285,8 @@ build.json @home-assistant/supervisor /homeassistant/components/emoncms/ @borpin /homeassistant/components/emonitor/ @bdraco /tests/components/emonitor/ @bdraco +/homeassistant/components/emulated_hue/ @bdraco +/tests/components/emulated_hue/ @bdraco /homeassistant/components/emulated_kasa/ @kbickar /tests/components/emulated_kasa/ @kbickar /homeassistant/components/energy/ @home-assistant/core diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index e5a9072e51d..be5271b78e3 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -5,7 +5,7 @@ "requirements": ["aiohttp_cors==0.7.0"], "dependencies": ["network"], "after_dependencies": ["http"], - "codeowners": [], + "codeowners": ["@bdraco"], "quality_scale": "internal", "iot_class": "local_push" } From db5e94c93ba9fb97722aeb835468696471bfb0b6 Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 20 Jun 2022 10:30:57 +0200 Subject: [PATCH 513/947] Fix HomeWizard is not catching RequestError (#73719) * Fix RequestError was not catched * Add test for RequestError --- .../components/homewizard/config_flow.py | 6 +++- .../components/homewizard/coordinator.py | 5 +++- .../components/homewizard/test_config_flow.py | 30 ++++++++++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homewizard/config_flow.py b/homeassistant/components/homewizard/config_flow.py index 7d06f08ce74..6f164637a7c 100644 --- a/homeassistant/components/homewizard/config_flow.py +++ b/homeassistant/components/homewizard/config_flow.py @@ -5,7 +5,7 @@ import logging from typing import Any, cast from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError, UnsupportedError +from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError from voluptuous import Required, Schema from homeassistant import config_entries @@ -187,6 +187,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.error("API version unsuppored") raise AbortFlow("unsupported_api_version") from ex + except RequestError as ex: + _LOGGER.error("Unexpected or no response") + raise AbortFlow("unknown_error") from ex + except Exception as ex: _LOGGER.exception( "Error connecting with Energy Device at %s", diff --git a/homeassistant/components/homewizard/coordinator.py b/homeassistant/components/homewizard/coordinator.py index e12edda63ae..bab7b5d3ba3 100644 --- a/homeassistant/components/homewizard/coordinator.py +++ b/homeassistant/components/homewizard/coordinator.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from homewizard_energy import HomeWizardEnergy -from homewizard_energy.errors import DisabledError +from homewizard_energy.errors import DisabledError, RequestError from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -41,6 +41,9 @@ class HWEnergyDeviceUpdateCoordinator(DataUpdateCoordinator[DeviceResponseEntry] "state": await self.api.state(), } + except RequestError as ex: + raise UpdateFailed("Device did not respond as expected") from ex + except DisabledError as ex: raise UpdateFailed("API disabled, API must be enabled in the app") from ex diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index d2e7d4c58ae..fca00b71892 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -2,7 +2,7 @@ import logging from unittest.mock import patch -from homewizard_energy.errors import DisabledError, UnsupportedError +from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError from homeassistant import config_entries from homeassistant.components import zeroconf @@ -333,3 +333,31 @@ async def test_check_detects_invalid_api(hass, aioclient_mock): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "unsupported_api_version" + + +async def test_check_requesterror(hass, aioclient_mock): + """Test check detecting device endpoint failed fetching data due to a requesterror.""" + + def mock_initialize(): + raise RequestError + + device = get_mock_device() + device.device.side_effect = mock_initialize + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.homewizard.config_flow.HomeWizardEnergy", + return_value=device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_IP_ADDRESS: "2.2.2.2"} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown_error" From e0dbf10808a4f6a65a215f588996ea9d0d7d8db7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 20 Jun 2022 10:31:19 +0200 Subject: [PATCH 514/947] Fix CSRF token for UniFi (#73716) Bump aiounifi to v32 --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index e779dca22f0..d481f0d0fc4 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Network", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==31"], + "requirements": ["aiounifi==32"], "codeowners": ["@Kane610"], "quality_scale": "platinum", "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 494ad4f27ab..778023377ba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -259,7 +259,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==31 +aiounifi==32 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af74cc1329c..a6b7ae5023b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==31 +aiounifi==32 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From d733a0547aec55ffc4c8fdafcc1dc270cbea57d7 Mon Sep 17 00:00:00 2001 From: micha91 Date: Mon, 20 Jun 2022 10:36:04 +0200 Subject: [PATCH 515/947] Update aiomusiccast (#73694) --- homeassistant/components/yamaha_musiccast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json index 86115e77988..8c0b55def69 100644 --- a/homeassistant/components/yamaha_musiccast/manifest.json +++ b/homeassistant/components/yamaha_musiccast/manifest.json @@ -3,7 +3,7 @@ "name": "MusicCast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/yamaha_musiccast", - "requirements": ["aiomusiccast==0.14.3"], + "requirements": ["aiomusiccast==0.14.4"], "ssdp": [ { "manufacturer": "Yamaha Corporation" diff --git a/requirements_all.txt b/requirements_all.txt index 778023377ba..e15f3a34c59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -196,7 +196,7 @@ aiolyric==1.0.8 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.3 +aiomusiccast==0.14.4 # homeassistant.components.nanoleaf aionanoleaf==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a6b7ae5023b..5298e306f3c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -168,7 +168,7 @@ aiolyric==1.0.8 aiomodernforms==0.1.8 # homeassistant.components.yamaha_musiccast -aiomusiccast==0.14.3 +aiomusiccast==0.14.4 # homeassistant.components.nanoleaf aionanoleaf==0.2.0 From 8e3d9d7435315ec7f3f01e83274d3e24a7325f82 Mon Sep 17 00:00:00 2001 From: Peter Galantha Date: Mon, 20 Jun 2022 01:45:35 -0700 Subject: [PATCH 516/947] Specify device_class and state_class on OpenEVSE sensors (#73672) * Specify device_class and state_class * import SensorStateClass --- homeassistant/components/openevse/sensor.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index 9f953832674..3dcea4d0126 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.const import ( CONF_HOST, @@ -36,34 +37,43 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( key="charge_time", name="Charge Time Elapsed", native_unit_of_measurement=TIME_MINUTES, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ambient_temp", name="Ambient Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ir_temp", name="IR Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="rtc_temp", name="RTC Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="usage_session", name="Usage this Session", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), SensorEntityDescription( key="usage_total", name="Total Usage", native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, ), ) From 120479acef9a8e9e52fa356f036e55465e441d31 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jun 2022 04:10:01 -0500 Subject: [PATCH 517/947] Enable polling for hardwired powerview devices (#73659) * Enable polling for hardwired powerview devices * Update homeassistant/components/hunterdouglas_powerview/cover.py * Update homeassistant/components/hunterdouglas_powerview/cover.py * docs were wrong * Update homeassistant/components/hunterdouglas_powerview/cover.py * Update homeassistant/components/hunterdouglas_powerview/sensor.py --- .../hunterdouglas_powerview/const.py | 6 +++ .../hunterdouglas_powerview/cover.py | 41 +++++++++++++++++-- .../hunterdouglas_powerview/entity.py | 10 ++--- .../hunterdouglas_powerview/sensor.py | 4 ++ 4 files changed, 53 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index 7146d40c737..65c461b6f2f 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -87,3 +87,9 @@ POS_KIND_PRIMARY = 1 POS_KIND_SECONDARY = 2 POS_KIND_VANE = 3 POS_KIND_ERROR = 4 + + +ATTR_BATTERY_KIND = "batteryKind" +BATTERY_KIND_HARDWIRED = 1 +BATTERY_KIND_BATTERY = 2 +BATTERY_KIND_RECHARGABLE = 3 diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index c3061c75301..e0a01f9c381 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Iterable from contextlib import suppress +from datetime import timedelta import logging from typing import Any @@ -74,6 +75,8 @@ RESYNC_DELAY = 60 # implemented for top/down shades, but also works fine with normal shades CLOSED_POSITION = (0.75 / 100) * (MAX_POSITION - MIN_POSITION) +SCAN_INTERVAL = timedelta(minutes=10) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -152,8 +155,6 @@ def hass_position_to_hd(hass_position: int, max_val: int = MAX_POSITION) -> int: class PowerViewShadeBase(ShadeEntity, CoverEntity): """Representation of a powerview shade.""" - # The hub frequently reports stale states - _attr_assumed_state = True _attr_device_class = CoverDeviceClass.SHADE _attr_supported_features = 0 @@ -174,6 +175,26 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync = None + @property + def assumed_state(self) -> bool: + """If the device is hard wired we are polling state. + + The hub will frequently provide the wrong state + for battery power devices so we set assumed + state in this case. + """ + return not self._is_hard_wired + + @property + def should_poll(self) -> bool: + """Only poll if the device is hard wired. + + We cannot poll battery powered devices + as it would drain their batteries in a matter + of days. + """ + return self._is_hard_wired + @property def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" @@ -336,15 +357,29 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): """Cancel any pending refreshes.""" self._async_cancel_scheduled_transition_update() + @property + def _update_in_progress(self) -> bool: + """Check if an update is already in progress.""" + return bool(self._scheduled_transition_update or self._forced_resync) + @callback def _async_update_shade_from_group(self) -> None: """Update with new data from the coordinator.""" - if self._scheduled_transition_update or self._forced_resync: + if self._update_in_progress: # If a transition is in progress the data will be wrong return self.data.update_from_group_data(self._shade.id) self.async_write_ha_state() + async def async_update(self) -> None: + """Refresh shade position.""" + if self._update_in_progress: + # The update will likely timeout and + # error if are already have one in flight + return + await self._shade.refresh() + self._async_update_shade_data(self._shade.raw_data) + class PowerViewShade(PowerViewShadeBase): """Represent a standard shade.""" diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 7814ba9cb12..222324eb55a 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -10,6 +10,8 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( + ATTR_BATTERY_KIND, + BATTERY_KIND_HARDWIRED, DEVICE_FIRMWARE, DEVICE_MAC_ADDRESS, DEVICE_MODEL, @@ -83,6 +85,9 @@ class ShadeEntity(HDEntity): super().__init__(coordinator, device_info, room_name, shade.id) self._shade_name = shade_name self._shade = shade + self._is_hard_wired = bool( + shade.raw_data.get(ATTR_BATTERY_KIND) == BATTERY_KIND_HARDWIRED + ) @property def positions(self) -> PowerviewShadePositions: @@ -117,8 +122,3 @@ class ShadeEntity(HDEntity): device_info[ATTR_SW_VERSION] = sw_version return device_info - - async def async_update(self) -> None: - """Refresh shade position.""" - await self._shade.refresh() - self.data.update_shade_positions(self._shade.raw_data) diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 8fd492ddb1d..3fc8942eb78 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -91,3 +91,7 @@ class PowerViewShadeBatterySensor(ShadeEntity, SensorEntity): """Update with new data from the coordinator.""" self._shade.raw_data = self.data.get_raw_data(self._shade.id) self.async_write_ha_state() + + async def async_update(self) -> None: + """Refresh shade battery.""" + await self._shade.refreshBattery() From 06e45893aab613fbd221028af86d9d01a97c0bcf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 11:28:53 +0200 Subject: [PATCH 518/947] Remove invalid type definitions in zha (#73596) --- .../components/zha/alarm_control_panel.py | 9 ++++-- homeassistant/components/zha/api.py | 6 ++-- homeassistant/components/zha/button.py | 24 ++++++++------- .../components/zha/core/channels/base.py | 7 +++-- .../components/zha/core/channels/general.py | 7 +++-- .../zha/core/channels/manufacturerspecific.py | 8 ++++- .../components/zha/core/channels/security.py | 6 +++- .../zha/core/channels/smartenergy.py | 6 +++- homeassistant/components/zha/core/device.py | 9 +++--- homeassistant/components/zha/core/helpers.py | 4 +-- .../components/zha/core/registries.py | 22 +++++++------- homeassistant/components/zha/core/store.py | 15 +++++----- homeassistant/components/zha/core/typing.py | 27 +---------------- homeassistant/components/zha/cover.py | 10 +++++-- homeassistant/components/zha/entity.py | 12 +++++--- homeassistant/components/zha/light.py | 8 +++-- homeassistant/components/zha/select.py | 23 +++++++++------ homeassistant/components/zha/sensor.py | 29 ++++++++++--------- homeassistant/components/zha/siren.py | 11 ++++--- mypy.ini | 18 ------------ script/hassfest/mypy_config.py | 6 ---- 21 files changed, 135 insertions(+), 132 deletions(-) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index ef616a8f894..15d27f95c5c 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -1,5 +1,8 @@ """Alarm control panels on Zigbee Home Automation networks.""" +from __future__ import annotations + import functools +from typing import TYPE_CHECKING from zigpy.zcl.clusters.security import IasAce @@ -38,9 +41,11 @@ from .core.const import ( ) from .core.helpers import async_get_zha_config_value from .core.registries import ZHA_ENTITIES -from .core.typing import ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.device import ZHADevice + STRICT_MATCH = functools.partial( ZHA_ENTITIES.strict_match, Platform.ALARM_CONTROL_PANEL ) @@ -83,7 +88,7 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.TRIGGER ) - def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): + def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs): """Initialize the ZHA alarm control device.""" super().__init__(unique_id, zha_device, channels, **kwargs) cfg_entry = zha_device.gateway.config_entry diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index f99255f55a9..737bef5ddff 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -69,11 +69,11 @@ from .core.helpers import ( get_matched_clusters, qr_to_install_code, ) -from .core.typing import ZhaDeviceType if TYPE_CHECKING: from homeassistant.components.websocket_api.connection import ActiveConnection + from .core.device import ZHADevice from .core.gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) @@ -559,7 +559,7 @@ async def websocket_reconfigure_node( """Reconfigure a ZHA nodes entities by its ieee address.""" zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = msg[ATTR_IEEE] - device: ZhaDeviceType = zha_gateway.get_device(ieee) + device: ZHADevice = zha_gateway.get_device(ieee) async def forward_messages(data): """Forward events to websocket.""" @@ -1084,7 +1084,7 @@ def async_load_api(hass: HomeAssistant) -> None: """Remove a node from the network.""" zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZhaDeviceType = zha_gateway.get_device(ieee) + zha_device: ZHADevice = zha_gateway.get_device(ieee) if zha_device is not None and ( zha_device.is_coordinator and zha_device.ieee == zha_gateway.application_controller.ieee diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 9f241795267..ed0836042d2 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -4,7 +4,7 @@ from __future__ import annotations import abc import functools import logging -from typing import Any +from typing import TYPE_CHECKING, Any import zigpy.exceptions from zigpy.zcl.foundation import Status @@ -20,9 +20,13 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery from .core.const import CHANNEL_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + + MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.BUTTON) CONFIG_DIAGNOSTIC_MATCH = functools.partial( ZHA_ENTITIES.config_diagnostic_match, Platform.BUTTON @@ -60,13 +64,13 @@ class ZHAButton(ZhaEntity, ButtonEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this button.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] @abc.abstractmethod def get_args(self) -> list[Any]: @@ -87,8 +91,8 @@ class ZHAIdentifyButton(ZHAButton): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -120,13 +124,13 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this button.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] async def async_press(self) -> None: """Write attribute with defined value.""" diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 7ba28a52116..c5df31f0602 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -5,7 +5,7 @@ import asyncio from enum import Enum from functools import partialmethod, wraps import logging -from typing import Any +from typing import TYPE_CHECKING, Any import zigpy.exceptions from zigpy.zcl.foundation import ( @@ -40,6 +40,9 @@ from ..const import ( ) from ..helpers import LogMixin, retryable_req, safe_read +if TYPE_CHECKING: + from . import ChannelPool + _LOGGER = logging.getLogger(__name__) @@ -105,7 +108,7 @@ class ZigbeeChannel(LogMixin): ZCL_INIT_ATTRS: dict[int | str, bool] = {} def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType + self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool ) -> None: """Initialize ZigbeeChannel.""" self._generic_id = f"channel_0x{cluster.cluster_id:04x}" diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 8b67c81db44..99c5a81688b 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine -from typing import Any +from typing import TYPE_CHECKING, Any import zigpy.exceptions import zigpy.types as t @@ -28,6 +28,9 @@ from ..const import ( ) from .base import ClientChannel, ZigbeeChannel, parse_and_log_command +if TYPE_CHECKING: + from . import ChannelPool + @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Alarms.cluster_id) class Alarms(ZigbeeChannel): @@ -305,7 +308,7 @@ class OnOffChannel(ZigbeeChannel): } def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType + self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool ) -> None: """Initialize OnOffChannel.""" super().__init__(cluster, ch_pool) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 0ec9c7c2f4e..a89c74c54e8 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -1,5 +1,8 @@ """Manufacturer specific channels module for Zigbee Home Automation.""" +from __future__ import annotations + import logging +from typing import TYPE_CHECKING from homeassistant.core import callback @@ -16,6 +19,9 @@ from ..const import ( ) from .base import ClientChannel, ZigbeeChannel +if TYPE_CHECKING: + from . import ChannelPool + _LOGGER = logging.getLogger(__name__) @@ -55,7 +61,7 @@ class OppleRemote(ZigbeeChannel): REPORT_CONFIG = [] def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType + self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool ) -> None: """Initialize Opple channel.""" super().__init__(cluster, ch_pool) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 0e1e0f8e8a3..510237eee60 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -7,6 +7,7 @@ https://home-assistant.io/integrations/zha/ from __future__ import annotations import asyncio +from typing import TYPE_CHECKING from zigpy.exceptions import ZigbeeException from zigpy.zcl.clusters import security @@ -26,6 +27,9 @@ from ..const import ( from ..typing import CALLABLE_T from .base import ChannelStatus, ZigbeeChannel +if TYPE_CHECKING: + from . import ChannelPool + IAS_ACE_ARM = 0x0000 # ("arm", (t.enum8, t.CharacterString, t.uint8_t), False), IAS_ACE_BYPASS = 0x0001 # ("bypass", (t.LVList(t.uint8_t), t.CharacterString), False), IAS_ACE_EMERGENCY = 0x0002 # ("emergency", (), False), @@ -48,7 +52,7 @@ class IasAce(ZigbeeChannel): """IAS Ancillary Control Equipment channel.""" def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType + self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool ) -> None: """Initialize IAS Ancillary Control Equipment channel.""" super().__init__(cluster, ch_pool) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 927ceb248c5..d8f148c3dc5 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -3,6 +3,7 @@ from __future__ import annotations import enum from functools import partialmethod +from typing import TYPE_CHECKING from zigpy.zcl.clusters import smartenergy @@ -15,6 +16,9 @@ from ..const import ( ) from .base import ZigbeeChannel +if TYPE_CHECKING: + from . import ChannelPool + @registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Calendar.cluster_id) class Calendar(ZigbeeChannel): @@ -115,7 +119,7 @@ class Metering(ZigbeeChannel): SUMMATION = 1 def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType + self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool ) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 6e72c17ef42..009b28b10d5 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -78,6 +78,7 @@ from .helpers import LogMixin, async_get_zha_config_value if TYPE_CHECKING: from ..api import ClusterBinding + from .gateway import ZHAGateway _LOGGER = logging.getLogger(__name__) _UPDATE_ALIVE_INTERVAL = (60, 90) @@ -100,7 +101,7 @@ class ZHADevice(LogMixin): self, hass: HomeAssistant, zigpy_device: zha_typing.ZigpyDeviceType, - zha_gateway: zha_typing.ZhaGatewayType, + zha_gateway: ZHAGateway, ) -> None: """Initialize the gateway.""" self.hass = hass @@ -155,12 +156,12 @@ class ZHADevice(LogMixin): return self._zigpy_device @property - def channels(self) -> zha_typing.ChannelsType: + def channels(self) -> channels.Channels: """Return ZHA channels.""" return self._channels @channels.setter - def channels(self, value: zha_typing.ChannelsType) -> None: + def channels(self, value: channels.Channels) -> None: """Channels setter.""" assert isinstance(value, channels.Channels) self._channels = value @@ -332,7 +333,7 @@ class ZHADevice(LogMixin): cls, hass: HomeAssistant, zigpy_dev: zha_typing.ZigpyDeviceType, - gateway: zha_typing.ZhaGatewayType, + gateway: ZHAGateway, restored: bool = False, ): """Create new device.""" diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 33d68822b9f..1c75846ee7e 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -35,7 +35,7 @@ from .const import ( DATA_ZHA_GATEWAY, ) from .registries import BINDABLE_CLUSTERS -from .typing import ZhaDeviceType, ZigpyClusterType +from .typing import ZigpyClusterType if TYPE_CHECKING: from .device import ZHADevice @@ -82,7 +82,7 @@ async def safe_read( async def get_matched_clusters( - source_zha_device: ZhaDeviceType, target_zha_device: ZhaDeviceType + source_zha_device: ZHADevice, target_zha_device: ZHADevice ) -> list[BindingPair]: """Get matched input/output cluster pairs for 2 devices.""" source_clusters = source_zha_device.async_get_std_clusters() diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index fb00e23ac6f..ed6b047566c 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -17,7 +17,7 @@ from homeassistant.const import Platform # importing channels updates registries from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .decorators import DictRegistry, SetRegistry -from .typing import CALLABLE_T, ChannelType +from .typing import CALLABLE_T if TYPE_CHECKING: from .channels.base import ClientChannel, ZigbeeChannel @@ -161,7 +161,7 @@ class MatchRule: weight += 1 * len(self.aux_channels) return weight - def claim_channels(self, channel_pool: list[ChannelType]) -> list[ChannelType]: + def claim_channels(self, channel_pool: list[ZigbeeChannel]) -> list[ZigbeeChannel]: """Return a list of channels this rule matches + aux channels.""" claimed = [] if isinstance(self.channel_names, frozenset): @@ -216,7 +216,7 @@ class EntityClassAndChannels: """Container for entity class and corresponding channels.""" entity_class: CALLABLE_T - claimed_channel: list[ChannelType] + claimed_channel: list[ZigbeeChannel] class ZHAEntityRegistry: @@ -247,9 +247,9 @@ class ZHAEntityRegistry: component: str, manufacturer: str, model: str, - channels: list[ChannelType], + channels: list[ZigbeeChannel], default: CALLABLE_T = None, - ) -> tuple[CALLABLE_T, list[ChannelType]]: + ) -> tuple[CALLABLE_T, list[ZigbeeChannel]]: """Match a ZHA Channels to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=lambda x: x.weight, reverse=True): @@ -263,11 +263,11 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ChannelType], - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ChannelType]]: + channels: list[ZigbeeChannel], + ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ChannelType] = set() + all_claimed: set[ZigbeeChannel] = set() for component, stop_match_groups in self._multi_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) @@ -287,11 +287,11 @@ class ZHAEntityRegistry: self, manufacturer: str, model: str, - channels: list[ChannelType], - ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ChannelType]]: + channels: list[ZigbeeChannel], + ) -> tuple[dict[str, list[EntityClassAndChannels]], list[ZigbeeChannel]]: """Match ZHA Channels to potentially multiple ZHA Entity classes.""" result: dict[str, list[EntityClassAndChannels]] = collections.defaultdict(list) - all_claimed: set[ChannelType] = set() + all_claimed: set[ZigbeeChannel] = set() for ( component, stop_match_groups, diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py index 28983bdb427..c82f05303a5 100644 --- a/homeassistant/components/zha/core/store.py +++ b/homeassistant/components/zha/core/store.py @@ -5,7 +5,7 @@ from collections import OrderedDict from collections.abc import MutableMapping import datetime import time -from typing import cast +from typing import TYPE_CHECKING, cast import attr @@ -13,7 +13,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store from homeassistant.loader import bind_hass -from .typing import ZhaDeviceType +if TYPE_CHECKING: + from .device import ZHADevice DATA_REGISTRY = "zha_storage" @@ -42,7 +43,7 @@ class ZhaStorage: self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) @callback - def async_create_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_create_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Create a new ZhaDeviceEntry.""" device_entry: ZhaDeviceEntry = ZhaDeviceEntry( name=device.name, ieee=str(device.ieee), last_seen=device.last_seen @@ -52,7 +53,7 @@ class ZhaStorage: return device_entry @callback - def async_get_or_create_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_get_or_create_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Create a new ZhaDeviceEntry.""" ieee_str: str = str(device.ieee) if ieee_str in self.devices: @@ -60,14 +61,14 @@ class ZhaStorage: return self.async_create_device(device) @callback - def async_create_or_update_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_create_or_update_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Create or update a ZhaDeviceEntry.""" if str(device.ieee) in self.devices: return self.async_update_device(device) return self.async_create_device(device) @callback - def async_delete_device(self, device: ZhaDeviceType) -> None: + def async_delete_device(self, device: ZHADevice) -> None: """Delete ZhaDeviceEntry.""" ieee_str: str = str(device.ieee) if ieee_str in self.devices: @@ -75,7 +76,7 @@ class ZhaStorage: self.async_schedule_save() @callback - def async_update_device(self, device: ZhaDeviceType) -> ZhaDeviceEntry: + def async_update_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Update name of ZhaDeviceEntry.""" ieee_str: str = str(device.ieee) old = self.devices[ieee_str] diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py index 7e5cce8fec5..4c513ea7f21 100644 --- a/homeassistant/components/zha/core/typing.py +++ b/homeassistant/components/zha/core/typing.py @@ -1,6 +1,6 @@ """Typing helpers for ZHA component.""" from collections.abc import Callable -from typing import TYPE_CHECKING, TypeVar +from typing import TypeVar import zigpy.device import zigpy.endpoint @@ -10,33 +10,8 @@ import zigpy.zdo # pylint: disable=invalid-name CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) -ChannelType = "ZigbeeChannel" -ChannelsType = "Channels" -ChannelPoolType = "ChannelPool" -ClientChannelType = "ClientChannel" -ZDOChannelType = "ZDOChannel" -ZhaDeviceType = "ZHADevice" -ZhaEntityType = "ZHAEntity" -ZhaGatewayType = "ZHAGateway" -ZhaGroupType = "ZHAGroupType" ZigpyClusterType = zigpy.zcl.Cluster ZigpyDeviceType = zigpy.device.Device ZigpyEndpointType = zigpy.endpoint.Endpoint ZigpyGroupType = zigpy.group.Group ZigpyZdoType = zigpy.zdo.ZDO - -if TYPE_CHECKING: - import homeassistant.components.zha.entity - - from . import channels, device, gateway, group - from .channels import base as base_channels - - ChannelType = base_channels.ZigbeeChannel - ChannelsType = channels.Channels - ChannelPoolType = channels.ChannelPool - ClientChannelType = base_channels.ClientChannel - ZDOChannelType = base_channels.ZDOChannel - ZhaDeviceType = device.ZHADevice - ZhaEntityType = homeassistant.components.zha.entity.ZhaEntity - ZhaGatewayType = gateway.ZHAGateway - ZhaGroupType = group.ZHAGroup diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0fdb4daeaa5..2a61c5b4bc8 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio import functools import logging +from typing import TYPE_CHECKING from zigpy.zcl.foundation import Status @@ -37,9 +38,12 @@ from .core.const import ( SIGNAL_SET_LEVEL, ) from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + _LOGGER = logging.getLogger(__name__) MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.COVER) @@ -191,8 +195,8 @@ class Shade(ZhaEntity, CoverEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Initialize the ZHA light.""" diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index e9ea9ee871a..88dc9454f37 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -29,7 +29,7 @@ from .core.const import ( SIGNAL_REMOVE, ) from .core.helpers import LogMixin -from .core.typing import CALLABLE_T, ChannelType, ZhaDeviceType +from .core.typing import CALLABLE_T if TYPE_CHECKING: from .core.channels.base import ZigbeeChannel @@ -127,7 +127,11 @@ class BaseZhaEntity(LogMixin, entity.Entity): @callback def async_accept_signal( - self, channel: ChannelType, signal: str, func: CALLABLE_T, signal_override=False + self, + channel: ZigbeeChannel, + signal: str, + func: CALLABLE_T, + signal_override=False, ): """Accept a signal from a channel.""" unsub = None @@ -181,8 +185,8 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 30ae9688729..520916d469b 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -7,7 +7,7 @@ import functools import itertools import logging import random -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from zigpy.zcl.clusters.general import Identify, LevelControl, OnOff from zigpy.zcl.clusters.lighting import Color @@ -62,9 +62,11 @@ from .core.const import ( ) from .core.helpers import LogMixin, async_get_zha_config_value from .core.registries import ZHA_ENTITIES -from .core.typing import ZhaDeviceType from .entity import ZhaEntity, ZhaGroupEntity +if TYPE_CHECKING: + from .core.device import ZHADevice + _LOGGER = logging.getLogger(__name__) CAPABILITIES_COLOR_LOOP = 0x4 @@ -341,7 +343,7 @@ class Light(BaseLight, ZhaEntity): _attr_supported_color_modes: set(ColorMode) _REFRESH_INTERVAL = (45, 75) - def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): + def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs): """Initialize the ZHA light.""" super().__init__(unique_id, zha_device, channels, **kwargs) self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 8714d804790..231120ba806 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -4,6 +4,7 @@ from __future__ import annotations from enum import Enum import functools import logging +from typing import TYPE_CHECKING from zigpy import types from zigpy.zcl.clusters.general import OnOff @@ -26,9 +27,13 @@ from .core.const import ( Strobe, ) from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + + CONFIG_DIAGNOSTIC_MATCH = functools.partial( ZHA_ENTITIES.config_diagnostic_match, Platform.SELECT ) @@ -64,14 +69,14 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this select entity.""" self._attr_name = self._enum.__name__ self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] super().__init__(unique_id, zha_device, channels, **kwargs) @property @@ -150,8 +155,8 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -175,13 +180,13 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this select entity.""" self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] super().__init__(unique_id, zha_device, channels, **kwargs) @property diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index cdc37876889..e579967345c 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations import functools import numbers -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.climate.const import HVACAction from homeassistant.components.sensor import ( @@ -63,9 +63,12 @@ from .core.const import ( SIGNAL_ATTR_UPDATED, ) from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + PARALLEL_UPDATES = 5 BATTERY_SIZES = { @@ -121,20 +124,20 @@ class Sensor(ZhaEntity, SensorEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this sensor.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: ChannelType = channels[0] + self._channel: ZigbeeChannel = channels[0] @classmethod def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -213,8 +216,8 @@ class Battery(Sensor): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -637,8 +640,8 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. @@ -762,8 +765,8 @@ class RSSISensor(Sensor, id_suffix="rssi"): def create_entity( cls, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> ZhaEntity | None: """Entity Factory. diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index 38b58b8dc54..b509f9585db 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -2,7 +2,7 @@ from __future__ import annotations import functools -from typing import Any +from typing import TYPE_CHECKING, Any from zigpy.zcl.clusters.security import IasWd as WD @@ -38,9 +38,12 @@ from .core.const import ( Strobe, ) from .core.registries import ZHA_ENTITIES -from .core.typing import ChannelType, ZhaDeviceType from .entity import ZhaEntity +if TYPE_CHECKING: + from .core.channels.base import ZigbeeChannel + from .core.device import ZHADevice + MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.SIREN) DEFAULT_DURATION = 5 # seconds @@ -72,8 +75,8 @@ class ZHASiren(ZhaEntity, SirenEntity): def __init__( self, unique_id: str, - zha_device: ZhaDeviceType, - channels: list[ChannelType], + zha_device: ZHADevice, + channels: list[ZigbeeChannel], **kwargs, ) -> None: """Init this siren.""" diff --git a/mypy.ini b/mypy.ini index f19f5f5dc68..dcbb2839b57 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3000,9 +3000,6 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.switch] ignore_errors = true -[mypy-homeassistant.components.zha.alarm_control_panel] -ignore_errors = true - [mypy-homeassistant.components.zha.api] ignore_errors = true @@ -3018,9 +3015,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.config_flow] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.base] ignore_errors = true @@ -3039,27 +3033,18 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.channels.lighting] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels.lightlink] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.manufacturerspecific] ignore_errors = true [mypy-homeassistant.components.zha.core.channels.measurement] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels.protocol] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.security] ignore_errors = true [mypy-homeassistant.components.zha.core.channels.smartenergy] ignore_errors = true -[mypy-homeassistant.components.zha.core.decorators] -ignore_errors = true - [mypy-homeassistant.components.zha.core.device] ignore_errors = true @@ -3081,9 +3066,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.store] ignore_errors = true -[mypy-homeassistant.components.zha.core.typing] -ignore_errors = true - [mypy-homeassistant.components.zha.cover] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 09a9820e7b4..9de6e9c6ad8 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -149,26 +149,21 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.light", "homeassistant.components.xiaomi_miio.sensor", "homeassistant.components.xiaomi_miio.switch", - "homeassistant.components.zha.alarm_control_panel", "homeassistant.components.zha.api", "homeassistant.components.zha.binary_sensor", "homeassistant.components.zha.button", "homeassistant.components.zha.climate", "homeassistant.components.zha.config_flow", - "homeassistant.components.zha.core.channels", "homeassistant.components.zha.core.channels.base", "homeassistant.components.zha.core.channels.closures", "homeassistant.components.zha.core.channels.general", "homeassistant.components.zha.core.channels.homeautomation", "homeassistant.components.zha.core.channels.hvac", "homeassistant.components.zha.core.channels.lighting", - "homeassistant.components.zha.core.channels.lightlink", "homeassistant.components.zha.core.channels.manufacturerspecific", "homeassistant.components.zha.core.channels.measurement", - "homeassistant.components.zha.core.channels.protocol", "homeassistant.components.zha.core.channels.security", "homeassistant.components.zha.core.channels.smartenergy", - "homeassistant.components.zha.core.decorators", "homeassistant.components.zha.core.device", "homeassistant.components.zha.core.discovery", "homeassistant.components.zha.core.gateway", @@ -176,7 +171,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.zha.core.helpers", "homeassistant.components.zha.core.registries", "homeassistant.components.zha.core.store", - "homeassistant.components.zha.core.typing", "homeassistant.components.zha.cover", "homeassistant.components.zha.device_action", "homeassistant.components.zha.device_tracker", From edeb5b9286229e46de39c7507f498f0a7a2d415a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Jun 2022 11:46:58 +0200 Subject: [PATCH 519/947] Update spotipy to 2.20.0 (#73731) --- homeassistant/components/spotify/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 979f262e54a..2940700d230 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -2,7 +2,7 @@ "domain": "spotify", "name": "Spotify", "documentation": "https://www.home-assistant.io/integrations/spotify", - "requirements": ["spotipy==2.19.0"], + "requirements": ["spotipy==2.20.0"], "zeroconf": ["_spotify-connect._tcp.local."], "dependencies": ["application_credentials"], "codeowners": ["@frenck"], diff --git a/requirements_all.txt b/requirements_all.txt index e15f3a34c59..83227d65d73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2213,7 +2213,7 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.19.0 +spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5298e306f3c..7d9da343c0b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1467,7 +1467,7 @@ speedtest-cli==2.1.3 spiderpy==1.6.1 # homeassistant.components.spotify -spotipy==2.19.0 +spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql From 474e0fd6d041bb9e5842562c3034ad5596741b46 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 20 Jun 2022 12:04:12 +0200 Subject: [PATCH 520/947] Use pydeconz interface controls for climate platform (#73670) * Use pydeconz interface controls for climate * Bump pydeconz to make use of enums in more places --- homeassistant/components/deconz/climate.py | 107 +++++++++--------- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 56 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 6887b4238d2..880e11f080b 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -5,25 +5,10 @@ from typing import Any from pydeconz.models.event import EventType from pydeconz.models.sensor.thermostat import ( - THERMOSTAT_FAN_MODE_AUTO, - THERMOSTAT_FAN_MODE_HIGH, - THERMOSTAT_FAN_MODE_LOW, - THERMOSTAT_FAN_MODE_MEDIUM, - THERMOSTAT_FAN_MODE_OFF, - THERMOSTAT_FAN_MODE_ON, - THERMOSTAT_FAN_MODE_SMART, - THERMOSTAT_MODE_AUTO, - THERMOSTAT_MODE_COOL, - THERMOSTAT_MODE_HEAT, - THERMOSTAT_MODE_OFF, - THERMOSTAT_PRESET_AUTO, - THERMOSTAT_PRESET_BOOST, - THERMOSTAT_PRESET_COMFORT, - THERMOSTAT_PRESET_COMPLEX, - THERMOSTAT_PRESET_ECO, - THERMOSTAT_PRESET_HOLIDAY, - THERMOSTAT_PRESET_MANUAL, Thermostat, + ThermostatFanMode, + ThermostatMode, + ThermostatPreset, ) from homeassistant.components.climate import DOMAIN, ClimateEntity @@ -53,21 +38,21 @@ from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_FAN_SMART = "smart" FAN_MODE_TO_DECONZ = { - DECONZ_FAN_SMART: THERMOSTAT_FAN_MODE_SMART, - FAN_AUTO: THERMOSTAT_FAN_MODE_AUTO, - FAN_HIGH: THERMOSTAT_FAN_MODE_HIGH, - FAN_MEDIUM: THERMOSTAT_FAN_MODE_MEDIUM, - FAN_LOW: THERMOSTAT_FAN_MODE_LOW, - FAN_ON: THERMOSTAT_FAN_MODE_ON, - FAN_OFF: THERMOSTAT_FAN_MODE_OFF, + DECONZ_FAN_SMART: ThermostatFanMode.SMART, + FAN_AUTO: ThermostatFanMode.AUTO, + FAN_HIGH: ThermostatFanMode.HIGH, + FAN_MEDIUM: ThermostatFanMode.MEDIUM, + FAN_LOW: ThermostatFanMode.LOW, + FAN_ON: ThermostatFanMode.ON, + FAN_OFF: ThermostatFanMode.OFF, } DECONZ_TO_FAN_MODE = {value: key for key, value in FAN_MODE_TO_DECONZ.items()} -HVAC_MODE_TO_DECONZ: dict[HVACMode, str] = { - HVACMode.AUTO: THERMOSTAT_MODE_AUTO, - HVACMode.COOL: THERMOSTAT_MODE_COOL, - HVACMode.HEAT: THERMOSTAT_MODE_HEAT, - HVACMode.OFF: THERMOSTAT_MODE_OFF, +HVAC_MODE_TO_DECONZ = { + HVACMode.AUTO: ThermostatMode.AUTO, + HVACMode.COOL: ThermostatMode.COOL, + HVACMode.HEAT: ThermostatMode.HEAT, + HVACMode.OFF: ThermostatMode.OFF, } DECONZ_PRESET_AUTO = "auto" @@ -76,13 +61,13 @@ DECONZ_PRESET_HOLIDAY = "holiday" DECONZ_PRESET_MANUAL = "manual" PRESET_MODE_TO_DECONZ = { - DECONZ_PRESET_AUTO: THERMOSTAT_PRESET_AUTO, - PRESET_BOOST: THERMOSTAT_PRESET_BOOST, - PRESET_COMFORT: THERMOSTAT_PRESET_COMFORT, - DECONZ_PRESET_COMPLEX: THERMOSTAT_PRESET_COMPLEX, - PRESET_ECO: THERMOSTAT_PRESET_ECO, - DECONZ_PRESET_HOLIDAY: THERMOSTAT_PRESET_HOLIDAY, - DECONZ_PRESET_MANUAL: THERMOSTAT_PRESET_MANUAL, + DECONZ_PRESET_AUTO: ThermostatPreset.AUTO, + PRESET_BOOST: ThermostatPreset.BOOST, + PRESET_COMFORT: ThermostatPreset.COMFORT, + DECONZ_PRESET_COMPLEX: ThermostatPreset.COMPLEX, + PRESET_ECO: ThermostatPreset.ECO, + DECONZ_PRESET_HOLIDAY: ThermostatPreset.HOLIDAY, + DECONZ_PRESET_MANUAL: ThermostatPreset.MANUAL, } DECONZ_TO_PRESET_MODE = {value: key for key, value in PRESET_MODE_TO_DECONZ.items()} @@ -168,23 +153,23 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): """Return fan operation.""" if self._device.fan_mode in DECONZ_TO_FAN_MODE: return DECONZ_TO_FAN_MODE[self._device.fan_mode] - return DECONZ_TO_FAN_MODE[FAN_ON if self._device.state_on else FAN_OFF] + return FAN_ON if self._device.state_on else FAN_OFF async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" if fan_mode not in FAN_MODE_TO_DECONZ: raise ValueError(f"Unsupported fan mode {fan_mode}") - await self._device.set_config(fan_mode=FAN_MODE_TO_DECONZ[fan_mode]) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + fan_mode=FAN_MODE_TO_DECONZ[fan_mode], + ) # HVAC control @property def hvac_mode(self) -> HVACMode: - """Return hvac operation ie. heat, cool mode. - - Need to be one of HVAC_MODE_*. - """ + """Return hvac operation ie. heat, cool mode.""" if self._device.mode in self._deconz_to_hvac_mode: return self._deconz_to_hvac_mode[self._device.mode] return HVACMode.HEAT if self._device.state_on else HVACMode.OFF @@ -195,9 +180,15 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): raise ValueError(f"Unsupported HVAC mode {hvac_mode}") if len(self._attr_hvac_modes) == 2: # Only allow turn on and off thermostat - await self._device.set_config(on=hvac_mode != HVACMode.OFF) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + on=hvac_mode != HVACMode.OFF, + ) else: - await self._device.set_config(mode=HVAC_MODE_TO_DECONZ[hvac_mode]) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + mode=HVAC_MODE_TO_DECONZ[hvac_mode], + ) # Preset control @@ -213,7 +204,10 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if preset_mode not in PRESET_MODE_TO_DECONZ: raise ValueError(f"Unsupported preset mode {preset_mode}") - await self._device.set_config(preset=PRESET_MODE_TO_DECONZ[preset_mode]) + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + preset=PRESET_MODE_TO_DECONZ[preset_mode], + ) # Temperature control @@ -225,11 +219,11 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): @property def target_temperature(self) -> float | None: """Return the target temperature.""" - if self._device.mode == THERMOSTAT_MODE_COOL and self._device.cooling_setpoint: - return self._device.cooling_setpoint + if self._device.mode == ThermostatMode.COOL and self._device.cooling_setpoint: + return self._device.scaled_cooling_setpoint if self._device.heating_setpoint: - return self._device.heating_setpoint + return self._device.scaled_heating_setpoint return None @@ -238,11 +232,16 @@ class DeconzThermostat(DeconzDevice, ClimateEntity): if ATTR_TEMPERATURE not in kwargs: raise ValueError(f"Expected attribute {ATTR_TEMPERATURE}") - data = {"heating_setpoint": kwargs[ATTR_TEMPERATURE] * 100} - if self._device.mode == "cool": - data = {"cooling_setpoint": kwargs[ATTR_TEMPERATURE] * 100} - - await self._device.set_config(**data) + if self._device.mode == ThermostatMode.COOL: + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + cooling_setpoint=kwargs[ATTR_TEMPERATURE] * 100, + ) + else: + await self.gateway.api.sensors.thermostat.set_config( + id=self._device.resource_id, + heating_setpoint=kwargs[ATTR_TEMPERATURE] * 100, + ) @property def extra_state_attributes(self) -> dict[str, bool | int]: diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 2a4a5ccf253..2306d088c48 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,7 +3,7 @@ "name": "deCONZ", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", - "requirements": ["pydeconz==92"], + "requirements": ["pydeconz==93"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 83227d65d73..07d2983550d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1441,7 +1441,7 @@ pydaikin==2.7.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==92 +pydeconz==93 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d9da343c0b..c12a0584249 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.7.0 # homeassistant.components.deconz -pydeconz==92 +pydeconz==93 # homeassistant.components.dexcom pydexcom==0.2.3 From b318b9b1967303fb3aef86a8ca2460594ae86cd2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 12:07:33 +0200 Subject: [PATCH 521/947] Improve onvif type hints (#73642) * Remove onvif from mypy ignore list * Adjust parsers * Adjust event * Adjust config_flow --- homeassistant/components/onvif/config_flow.py | 3 +- homeassistant/components/onvif/event.py | 4 +- homeassistant/components/onvif/models.py | 18 ++++---- homeassistant/components/onvif/parsers.py | 42 ++++++++++--------- mypy.ini | 15 ------- script/hassfest/mypy_config.py | 5 --- 6 files changed, 35 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index d7fb46079d9..48e5163ced5 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from pprint import pformat +from typing import Any from urllib.parse import urlparse from onvif.exceptions import ONVIFError @@ -44,7 +45,7 @@ def wsdiscovery() -> list[Service]: return services -async def async_discovery(hass) -> bool: +async def async_discovery(hass) -> list[dict[str, Any]]: """Return if there are devices that can be discovered.""" LOGGER.debug("Starting ONVIF discovery") services = await hass.async_add_executor_job(wsdiscovery) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index 3b4ae981677..3801d8081db 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -114,7 +114,7 @@ class EventManager: await self._subscription.Unsubscribe() self._subscription = None - async def async_restart(self, _now: dt = None) -> None: + async def async_restart(self, _now: dt.datetime | None = None) -> None: """Restart the subscription assuming the camera rebooted.""" if not self.started: return @@ -159,7 +159,7 @@ class EventManager: """Schedule async_pull_messages to run.""" self._unsub_refresh = async_call_later(self.hass, 1, self.async_pull_messages) - async def async_pull_messages(self, _now: dt = None) -> None: + async def async_pull_messages(self, _now: dt.datetime | None = None) -> None: """Pull messages from device.""" if self.hass.state == CoreState.running: try: diff --git a/homeassistant/components/onvif/models.py b/homeassistant/components/onvif/models.py index dea613e3c1c..6cefa6332e2 100644 --- a/homeassistant/components/onvif/models.py +++ b/homeassistant/components/onvif/models.py @@ -11,11 +11,11 @@ from homeassistant.helpers.entity import EntityCategory class DeviceInfo: """Represent device information.""" - manufacturer: str = None - model: str = None - fw_version: str = None - serial_number: str = None - mac: str = None + manufacturer: str | None = None + model: str | None = None + fw_version: str | None = None + serial_number: str | None = None + mac: str | None = None @dataclass @@ -41,7 +41,7 @@ class PTZ: continuous: bool relative: bool absolute: bool - presets: list[str] = None + presets: list[str] | None = None @dataclass @@ -52,7 +52,7 @@ class Profile: token: str name: str video: Video - ptz: PTZ = None + ptz: PTZ | None = None @dataclass @@ -71,8 +71,8 @@ class Event: uid: str name: str platform: str - device_class: str = None - unit_of_measurement: str = None + device_class: str | None = None + unit_of_measurement: str | None = None value: Any = None entity_category: EntityCategory | None = None entity_enabled: bool = True diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 87b901d2c52..2c74f873f77 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -11,7 +11,9 @@ from homeassistant.util.decorator import Registry from .models import Event -PARSERS: Registry[str, Callable[[str, Any], Coroutine[Any, Any, Event]]] = Registry() +PARSERS: Registry[ + str, Callable[[str, Any], Coroutine[Any, Any, Event | None]] +] = Registry() def local_datetime_or_none(value: str) -> datetime.datetime | None: @@ -28,7 +30,7 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None: @PARSERS.register("tns1:VideoSource/MotionAlarm") # pylint: disable=protected-access -async def async_parse_motion_alarm(uid: str, msg) -> Event: +async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/MotionAlarm @@ -51,7 +53,7 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event: @PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService") # pylint: disable=protected-access -async def async_parse_image_too_blurry(uid: str, msg) -> Event: +async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBlurry/* @@ -75,7 +77,7 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event: @PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService") # pylint: disable=protected-access -async def async_parse_image_too_dark(uid: str, msg) -> Event: +async def async_parse_image_too_dark(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooDark/* @@ -99,7 +101,7 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event: @PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService") # pylint: disable=protected-access -async def async_parse_image_too_bright(uid: str, msg) -> Event: +async def async_parse_image_too_bright(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBright/* @@ -123,7 +125,7 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event: @PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService") @PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService") # pylint: disable=protected-access -async def async_parse_scene_change(uid: str, msg) -> Event: +async def async_parse_scene_change(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/GlobalSceneChange/* @@ -144,7 +146,7 @@ async def async_parse_scene_change(uid: str, msg) -> Event: @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") # pylint: disable=protected-access -async def async_parse_detected_sound(uid: str, msg) -> Event: +async def async_parse_detected_sound(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:AudioAnalytics/Audio/DetectedSound @@ -175,7 +177,7 @@ async def async_parse_detected_sound(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") # pylint: disable=protected-access -async def async_parse_field_detector(uid: str, msg) -> Event: +async def async_parse_field_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/FieldDetector/ObjectsInside @@ -207,7 +209,7 @@ async def async_parse_field_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") # pylint: disable=protected-access -async def async_parse_cell_motion_detector(uid: str, msg) -> Event: +async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/CellMotionDetector/Motion @@ -238,7 +240,7 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") # pylint: disable=protected-access -async def async_parse_motion_region_detector(uid: str, msg) -> Event: +async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/MotionRegionDetector/Motion @@ -269,7 +271,7 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") # pylint: disable=protected-access -async def async_parse_tamper_detector(uid: str, msg) -> Event: +async def async_parse_tamper_detector(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RuleEngine/TamperDetector/Tamper @@ -301,7 +303,7 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event: @PARSERS.register("tns1:Device/Trigger/DigitalInput") # pylint: disable=protected-access -async def async_parse_digital_input(uid: str, msg) -> Event: +async def async_parse_digital_input(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/DigitalInput @@ -322,7 +324,7 @@ async def async_parse_digital_input(uid: str, msg) -> Event: @PARSERS.register("tns1:Device/Trigger/Relay") # pylint: disable=protected-access -async def async_parse_relay(uid: str, msg) -> Event: +async def async_parse_relay(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/Relay @@ -343,7 +345,7 @@ async def async_parse_relay(uid: str, msg) -> Event: @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") # pylint: disable=protected-access -async def async_parse_storage_failure(uid: str, msg) -> Event: +async def async_parse_storage_failure(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/HardwareFailure/StorageFailure @@ -365,7 +367,7 @@ async def async_parse_storage_failure(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/ProcessorUsage") # pylint: disable=protected-access -async def async_parse_processor_usage(uid: str, msg) -> Event: +async def async_parse_processor_usage(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/ProcessorUsage @@ -390,7 +392,7 @@ async def async_parse_processor_usage(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") # pylint: disable=protected-access -async def async_parse_last_reboot(uid: str, msg) -> Event: +async def async_parse_last_reboot(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReboot @@ -414,7 +416,7 @@ async def async_parse_last_reboot(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") # pylint: disable=protected-access -async def async_parse_last_reset(uid: str, msg) -> Event: +async def async_parse_last_reset(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReset @@ -439,7 +441,7 @@ async def async_parse_last_reset(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/Backup/Last") # pylint: disable=protected-access -async def async_parse_backup_last(uid: str, msg) -> Event: +async def async_parse_backup_last(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/Backup/Last @@ -465,7 +467,7 @@ async def async_parse_backup_last(uid: str, msg) -> Event: @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") # pylint: disable=protected-access -async def async_parse_last_clock_sync(uid: str, msg) -> Event: +async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization @@ -490,7 +492,7 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event: @PARSERS.register("tns1:RecordingConfig/JobState") # pylint: disable=protected-access -async def async_parse_jobstate(uid: str, msg) -> Event: +async def async_parse_jobstate(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:RecordingConfig/JobState diff --git a/mypy.ini b/mypy.ini index dcbb2839b57..c4561dea5a8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2814,27 +2814,12 @@ ignore_errors = true [mypy-homeassistant.components.onvif.binary_sensor] ignore_errors = true -[mypy-homeassistant.components.onvif.button] -ignore_errors = true - [mypy-homeassistant.components.onvif.camera] ignore_errors = true -[mypy-homeassistant.components.onvif.config_flow] -ignore_errors = true - [mypy-homeassistant.components.onvif.device] ignore_errors = true -[mypy-homeassistant.components.onvif.event] -ignore_errors = true - -[mypy-homeassistant.components.onvif.models] -ignore_errors = true - -[mypy-homeassistant.components.onvif.parsers] -ignore_errors = true - [mypy-homeassistant.components.onvif.sensor] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 9de6e9c6ad8..6b42bcd2cdd 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -87,13 +87,8 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.omnilogic.switch", "homeassistant.components.onvif.base", "homeassistant.components.onvif.binary_sensor", - "homeassistant.components.onvif.button", "homeassistant.components.onvif.camera", - "homeassistant.components.onvif.config_flow", "homeassistant.components.onvif.device", - "homeassistant.components.onvif.event", - "homeassistant.components.onvif.models", - "homeassistant.components.onvif.parsers", "homeassistant.components.onvif.sensor", "homeassistant.components.philips_js", "homeassistant.components.philips_js.config_flow", From 961c33dcc12619f3868a3372a6b28910df4b8794 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 20 Jun 2022 12:27:11 +0200 Subject: [PATCH 522/947] Ditch bluepy wheels (#73732) --- .github/workflows/wheels.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 15aef8b752a..54c2f2594ff 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -245,7 +245,6 @@ jobs: requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do sed -i "s|# pybluez|pybluez|g" ${requirement_file} - sed -i "s|# bluepy|bluepy|g" ${requirement_file} sed -i "s|# beacontools|beacontools|g" ${requirement_file} sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} @@ -254,7 +253,6 @@ jobs: sed -i "s|# pycups|pycups|g" ${requirement_file} sed -i "s|# homekit|homekit|g" ${requirement_file} sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} - sed -i "s|# decora|decora|g" ${requirement_file} sed -i "s|# avion|avion|g" ${requirement_file} sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} From b145aeaf75c89658566c0aeb142d94b4789cc6f3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 20 Jun 2022 12:27:25 +0200 Subject: [PATCH 523/947] Fix flaky recorder test (#73733) --- tests/components/history/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 8c0a80719a8..56ac68d944d 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -994,7 +994,7 @@ async def test_statistics_during_period_in_the_past( assert response["success"] assert response["result"] == {} - past = now - timedelta(days=3) + past = now - timedelta(days=3, hours=1) await client.send_json( { "id": 3, From 2de4b193e3de269bffdb1d65f84859f552195659 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 12:49:40 +0200 Subject: [PATCH 524/947] Remove unnecessary type definitions in zha (#73735) * Cleanup ZigpyClusterType * Cleanup ZigpyDeviceType * Cleanup ZigpyEndpointType * Cleanup ZigpyGroupType * Cleanup ZigpyZdoType --- homeassistant/components/zha/core/channels/base.py | 6 ++---- homeassistant/components/zha/core/channels/general.py | 7 +++---- .../zha/core/channels/manufacturerspecific.py | 8 ++++---- .../components/zha/core/channels/security.py | 7 +++---- .../components/zha/core/channels/smartenergy.py | 7 +++---- homeassistant/components/zha/core/device.py | 9 +++++---- homeassistant/components/zha/core/helpers.py | 4 ++-- homeassistant/components/zha/core/typing.py | 11 ----------- 8 files changed, 22 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index c5df31f0602..9b3ad6d9572 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -8,6 +8,7 @@ import logging from typing import TYPE_CHECKING, Any import zigpy.exceptions +import zigpy.zcl from zigpy.zcl.foundation import ( CommandSchema, ConfigureReportingResponseRecord, @@ -19,7 +20,6 @@ from homeassistant.const import ATTR_COMMAND from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from .. import typing as zha_typing from ..const import ( ATTR_ARGS, ATTR_ATTRIBUTE_ID, @@ -107,9 +107,7 @@ class ZigbeeChannel(LogMixin): # attribute read is acceptable. ZCL_INIT_ATTRS: dict[int | str, bool] = {} - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize ZigbeeChannel.""" self._generic_id = f"channel_0x{cluster.cluster_id:04x}" self._ch_pool = ch_pool diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 99c5a81688b..aa292013081 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -7,13 +7,14 @@ from typing import TYPE_CHECKING, Any import zigpy.exceptions import zigpy.types as t +import zigpy.zcl from zigpy.zcl.clusters import general from zigpy.zcl.foundation import Status from homeassistant.core import callback from homeassistant.helpers.event import async_call_later -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_BATTERY_SAVE, @@ -307,9 +308,7 @@ class OnOffChannel(ZigbeeChannel): "start_up_on_off": True, } - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize OnOffChannel.""" super().__init__(cluster, ch_pool) self._off_listener = None diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index a89c74c54e8..144d5736526 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -4,9 +4,11 @@ from __future__ import annotations import logging from typing import TYPE_CHECKING +import zigpy.zcl + from homeassistant.core import callback -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( ATTR_ATTRIBUTE_ID, ATTR_ATTRIBUTE_NAME, @@ -60,9 +62,7 @@ class OppleRemote(ZigbeeChannel): REPORT_CONFIG = [] - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize Opple channel.""" super().__init__(cluster, ch_pool) if self.cluster.endpoint.model == "lumi.motion.ac02": diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 510237eee60..4c0d6bbfd59 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -10,12 +10,13 @@ import asyncio from typing import TYPE_CHECKING from zigpy.exceptions import ZigbeeException +import zigpy.zcl from zigpy.zcl.clusters import security from zigpy.zcl.clusters.security import IasAce as AceCluster from homeassistant.core import callback -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( SIGNAL_ATTR_UPDATED, WARNING_DEVICE_MODE_EMERGENCY, @@ -51,9 +52,7 @@ SIGNAL_ALARM_TRIGGERED = "zha_armed_triggered" class IasAce(ZigbeeChannel): """IAS Ancillary Control Equipment channel.""" - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize IAS Ancillary Control Equipment channel.""" super().__init__(cluster, ch_pool) self.command_map: dict[int, CALLABLE_T] = { diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index d8f148c3dc5..099571aa69e 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -5,9 +5,10 @@ import enum from functools import partialmethod from typing import TYPE_CHECKING +import zigpy.zcl from zigpy.zcl.clusters import smartenergy -from .. import registries, typing as zha_typing +from .. import registries from ..const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, @@ -118,9 +119,7 @@ class Metering(ZigbeeChannel): DEMAND = 0 SUMMATION = 1 - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: ChannelPool - ) -> None: + def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) self._format_spec = None diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 009b28b10d5..588fcac7ca6 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -12,6 +12,7 @@ import time from typing import TYPE_CHECKING, Any from zigpy import types +import zigpy.device import zigpy.exceptions from zigpy.profiles import PROFILES import zigpy.quirks @@ -27,7 +28,7 @@ from homeassistant.helpers.dispatcher import ( ) from homeassistant.helpers.event import async_track_time_interval -from . import channels, typing as zha_typing +from . import channels from .const import ( ATTR_ARGS, ATTR_ATTRIBUTE, @@ -100,7 +101,7 @@ class ZHADevice(LogMixin): def __init__( self, hass: HomeAssistant, - zigpy_device: zha_typing.ZigpyDeviceType, + zigpy_device: zigpy.device.Device, zha_gateway: ZHAGateway, ) -> None: """Initialize the gateway.""" @@ -151,7 +152,7 @@ class ZHADevice(LogMixin): self._ha_device_id = device_id @property - def device(self) -> zha_typing.ZigpyDeviceType: + def device(self) -> zigpy.device.Device: """Return underlying Zigpy device.""" return self._zigpy_device @@ -332,7 +333,7 @@ class ZHADevice(LogMixin): def new( cls, hass: HomeAssistant, - zigpy_dev: zha_typing.ZigpyDeviceType, + zigpy_dev: zigpy.device.Device, gateway: ZHAGateway, restored: bool = False, ): diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 1c75846ee7e..f387cc99bfe 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -21,6 +21,7 @@ import voluptuous as vol import zigpy.exceptions import zigpy.types import zigpy.util +import zigpy.zcl import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry @@ -35,7 +36,6 @@ from .const import ( DATA_ZHA_GATEWAY, ) from .registries import BINDABLE_CLUSTERS -from .typing import ZigpyClusterType if TYPE_CHECKING: from .device import ZHADevice @@ -48,7 +48,7 @@ _T = TypeVar("_T") class BindingPair: """Information for binding.""" - source_cluster: ZigpyClusterType + source_cluster: zigpy.zcl.Cluster target_ieee: zigpy.types.EUI64 target_ep_id: int diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py index 4c513ea7f21..714dc03ef82 100644 --- a/homeassistant/components/zha/core/typing.py +++ b/homeassistant/components/zha/core/typing.py @@ -2,16 +2,5 @@ from collections.abc import Callable from typing import TypeVar -import zigpy.device -import zigpy.endpoint -import zigpy.group -import zigpy.zcl -import zigpy.zdo - # pylint: disable=invalid-name CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) -ZigpyClusterType = zigpy.zcl.Cluster -ZigpyDeviceType = zigpy.device.Device -ZigpyEndpointType = zigpy.endpoint.Endpoint -ZigpyGroupType = zigpy.group.Group -ZigpyZdoType = zigpy.zdo.ZDO From 3571a80c8d9adc2c70379e235000513e4df261ae Mon Sep 17 00:00:00 2001 From: Thibaut Date: Mon, 20 Jun 2022 12:58:08 +0200 Subject: [PATCH 525/947] Add support for Somfy Thermostat in Overkiz integration (#67169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Somfy Thermostat * Fix linked device Co-authored-by: Vincent Le Bourlot * Mark Somfy thermostat as supported * Fix wrong usage of cast * Update presets to lowercase * Rename constants * Remove _saved_target_temp * Apply black * Clean code * Fix mypy errors * Use constants from pyoverkiz * Use enum for target temp * Add comment * Use ClimateEntityFeature * Ease code Co-authored-by: Mick Vleeshouwer * Remove unused imports * Use HVACAction * Use HVACMode * Use more Overkiz constants * Don’t copy/paste * Don’t use magic number Co-authored-by: Vincent Le Bourlot Co-authored-by: Mick Vleeshouwer --- .../overkiz/climate_entities/__init__.py | 2 + .../climate_entities/somfy_thermostat.py | 172 ++++++++++++++++++ homeassistant/components/overkiz/const.py | 1 + homeassistant/components/overkiz/executor.py | 4 + .../components/overkiz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/overkiz/climate_entities/somfy_thermostat.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index e38a92b755c..d44aa85d167 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -3,8 +3,10 @@ from pyoverkiz.enums.ui import UIWidget from .atlantic_electrical_heater import AtlanticElectricalHeater from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl +from .somfy_thermostat import SomfyThermostat WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, + UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, } diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py new file mode 100644 index 00000000000..cfea49881f4 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -0,0 +1,172 @@ +"""Support for Somfy Smart Thermostat.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + PRESET_AWAY, + PRESET_HOME, + PRESET_NONE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +from ..coordinator import OverkizDataUpdateCoordinator +from ..entity import OverkizEntity + +PRESET_FREEZE = "freeze" +PRESET_NIGHT = "night" + +STATE_DEROGATION_ACTIVE = "active" +STATE_DEROGATION_INACTIVE = "inactive" + + +OVERKIZ_TO_HVAC_MODES: dict[str, HVACMode] = { + STATE_DEROGATION_ACTIVE: HVACMode.HEAT, + STATE_DEROGATION_INACTIVE: HVACMode.AUTO, +} +HVAC_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODES.items()} + +OVERKIZ_TO_PRESET_MODES: dict[OverkizCommandParam, str] = { + OverkizCommandParam.AT_HOME_MODE: PRESET_HOME, + OverkizCommandParam.AWAY_MODE: PRESET_AWAY, + OverkizCommandParam.FREEZE_MODE: PRESET_FREEZE, + OverkizCommandParam.MANUAL_MODE: PRESET_NONE, + OverkizCommandParam.SLEEPING_MODE: PRESET_NIGHT, + OverkizCommandParam.SUDDEN_DROP_MODE: PRESET_NONE, +} +PRESET_MODES_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODES.items()} +TARGET_TEMP_TO_OVERKIZ = { + PRESET_HOME: OverkizState.SOMFY_THERMOSTAT_AT_HOME_TARGET_TEMPERATURE, + PRESET_AWAY: OverkizState.SOMFY_THERMOSTAT_AWAY_MODE_TARGET_TEMPERATURE, + PRESET_FREEZE: OverkizState.SOMFY_THERMOSTAT_FREEZE_MODE_TARGET_TEMPERATURE, + PRESET_NIGHT: OverkizState.SOMFY_THERMOSTAT_SLEEPING_MODE_TARGET_TEMPERATURE, +} + +# controllableName is somfythermostat:SomfyThermostatTemperatureSensor +TEMPERATURE_SENSOR_DEVICE_INDEX = 2 + + +class SomfyThermostat(OverkizEntity, ClimateEntity): + """Representation of Somfy Smart Thermostat.""" + + _attr_temperature_unit = TEMP_CELSIUS + _attr_supported_features = ( + ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE + ) + _attr_hvac_modes = [*HVAC_MODES_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODES_TO_OVERKIZ] + # Both min and max temp values have been retrieved from the Somfy Application. + _attr_min_temp = 15.0 + _attr_max_temp = 26.0 + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + return OVERKIZ_TO_HVAC_MODES[ + cast( + str, self.executor.select_state(OverkizState.CORE_DEROGATION_ACTIVATION) + ) + ] + + @property + def hvac_action(self) -> str: + """Return the current running hvac operation if supported.""" + if not self.current_temperature or not self.target_temperature: + return HVACAction.IDLE + if self.current_temperature < self.target_temperature: + return HVACAction.HEATING + return HVACAction.IDLE + + @property + def preset_mode(self) -> str: + """Return the current preset mode, e.g., home, away, temp.""" + if self.hvac_mode == HVACMode.AUTO: + state_key = OverkizState.SOMFY_THERMOSTAT_HEATING_MODE + else: + state_key = OverkizState.SOMFY_THERMOSTAT_DEROGATION_HEATING_MODE + + state = cast(str, self.executor.select_state(state_key)) + + return OVERKIZ_TO_PRESET_MODES[OverkizCommandParam(state)] + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return cast(float, temperature.value) + return None + + @property + def target_temperature(self) -> float | None: + """Return the temperature we try to reach.""" + if self.hvac_mode == HVACMode.AUTO: + if self.preset_mode == PRESET_NONE: + return None + return cast( + float, + self.executor.select_state(TARGET_TEMP_TO_OVERKIZ[self.preset_mode]), + ) + return cast( + float, + self.executor.select_state(OverkizState.CORE_DEROGATED_TARGET_TEMPERATURE), + ) + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + temperature, + OverkizCommandParam.FURTHER_NOTICE, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_MODE_TEMPERATURE, + OverkizCommandParam.MANUAL_MODE, + temperature, + ) + await self.executor.async_execute_command(OverkizCommand.REFRESH_STATE) + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVACMode.AUTO: + await self.executor.async_execute_command(OverkizCommand.EXIT_DEROGATION) + await self.executor.async_execute_command(OverkizCommand.REFRESH_STATE) + else: + await self.async_set_preset_mode(PRESET_NONE) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + if preset_mode in [PRESET_FREEZE, PRESET_NIGHT, PRESET_AWAY, PRESET_HOME]: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + PRESET_MODES_TO_OVERKIZ[preset_mode], + OverkizCommandParam.FURTHER_NOTICE, + ) + elif preset_mode == PRESET_NONE: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATION, + self.target_temperature, + OverkizCommandParam.FURTHER_NOTICE, + ) + await self.executor.async_execute_command( + OverkizCommand.SET_MODE_TEMPERATURE, + OverkizCommandParam.MANUAL_MODE, + self.target_temperature, + ) + await self.executor.async_execute_command(OverkizCommand.REFRESH_STATE) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index dedf5c6d4a6..447ebc5ac42 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -70,6 +70,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.RTD_OUTDOOR_SIREN: Platform.SWITCH, # widgetName, uiClass is Siren (not supported) UIWidget.RTS_GENERIC: Platform.COVER, # widgetName, uiClass is Generic (not supported) UIWidget.SIREN_STATUS: None, # widgetName, uiClass is Siren (siren) + UIWidget.SOMFY_THERMOSTAT: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.STATELESS_ALARM_CONTROLLER: Platform.SWITCH, # widgetName, uiClass is Alarm (not supported) UIWidget.STATEFUL_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.STATELESS_EXTERIOR_HEATING: Platform.SWITCH, # widgetName, uiClass is ExteriorHeatingSystem (not supported) diff --git a/homeassistant/components/overkiz/executor.py b/homeassistant/components/overkiz/executor.py index 9bf7ef43b02..e82a6e21f63 100644 --- a/homeassistant/components/overkiz/executor.py +++ b/homeassistant/components/overkiz/executor.py @@ -37,6 +37,10 @@ class OverkizExecutor: """Return Overkiz device linked to this entity.""" return self.coordinator.data[self.device_url] + def linked_device(self, index: int) -> Device: + """Return Overkiz device sharing the same base url.""" + return self.coordinator.data[f"{self.base_device_url}#{index}"] + def select_command(self, *commands: str) -> str | None: """Select first existing command in a list of commands.""" existing_commands = self.device.definition.commands diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index c81de1e6139..432988a6dc4 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -3,7 +3,7 @@ "name": "Overkiz (by Somfy)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/overkiz", - "requirements": ["pyoverkiz==1.4.0"], + "requirements": ["pyoverkiz==1.4.1"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 07d2983550d..7ad94a23ea2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1720,7 +1720,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.4.0 +pyoverkiz==1.4.1 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c12a0584249..e4acef811f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1169,7 +1169,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.4.0 +pyoverkiz==1.4.1 # homeassistant.components.openweathermap pyowm==3.2.0 From c075760ca0872d047e6a7c0bb7101bc66878c602 Mon Sep 17 00:00:00 2001 From: w-marco <92536586+w-marco@users.noreply.github.com> Date: Mon, 20 Jun 2022 13:03:43 +0200 Subject: [PATCH 526/947] Display Windows as TYPE_WINDOW in Google Home (#73533) * Display Windows as TYPE_WINDOW in Google Home * set window type to window in smart_home test --- homeassistant/components/google_assistant/const.py | 3 ++- tests/components/google_assistant/test_smart_home.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index a19707bffbc..339cddae883 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -86,6 +86,7 @@ TYPE_SPEAKER = f"{PREFIX_TYPES}SPEAKER" TYPE_SWITCH = f"{PREFIX_TYPES}SWITCH" TYPE_THERMOSTAT = f"{PREFIX_TYPES}THERMOSTAT" TYPE_TV = f"{PREFIX_TYPES}TV" +TYPE_WINDOW = f"{PREFIX_TYPES}WINDOW" TYPE_VACUUM = f"{PREFIX_TYPES}VACUUM" SERVICE_REQUEST_SYNC = "request_sync" @@ -147,7 +148,7 @@ DEVICE_CLASS_TO_GOOGLE_TYPES = { (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.DOOR): TYPE_DOOR, (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.LOCK): TYPE_SENSOR, (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.OPENING): TYPE_SENSOR, - (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.WINDOW): TYPE_SENSOR, + (binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.WINDOW): TYPE_WINDOW, ( binary_sensor.DOMAIN, binary_sensor.BinarySensorDeviceClass.GARAGE_DOOR, diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 4b11910999a..684a6db2640 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1026,7 +1026,7 @@ async def test_device_class_switch(hass, device_class, google_type): ("garage_door", "action.devices.types.GARAGE"), ("lock", "action.devices.types.SENSOR"), ("opening", "action.devices.types.SENSOR"), - ("window", "action.devices.types.SENSOR"), + ("window", "action.devices.types.WINDOW"), ], ) async def test_device_class_binary_sensor(hass, device_class, google_type): From b6d3e34ebc2a7348584052073ae40025a4bd7bf9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 14:50:27 +0200 Subject: [PATCH 527/947] Drop custom type (CALLABLE_T) from zha (#73736) * Drop CALLABLE_T from zha * Adjust .coveragerc * Apply suggestions from code review Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Add TypeVar * Apply suggestions from code review Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * One more Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> * Flake8 Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .coveragerc | 1 - .../components/zha/core/channels/security.py | 8 ++-- .../components/zha/core/registries.py | 42 +++++++++++-------- homeassistant/components/zha/core/typing.py | 6 --- homeassistant/components/zha/entity.py | 6 +-- 5 files changed, 31 insertions(+), 32 deletions(-) delete mode 100644 homeassistant/components/zha/core/typing.py diff --git a/.coveragerc b/.coveragerc index 46cbed3a0dc..eba89e5f238 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1516,7 +1516,6 @@ omit = homeassistant/components/zha/core/gateway.py homeassistant/components/zha/core/helpers.py homeassistant/components/zha/core/registries.py - homeassistant/components/zha/core/typing.py homeassistant/components/zha/entity.py homeassistant/components/zha/light.py homeassistant/components/zha/sensor.py diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 4c0d6bbfd59..789e792e149 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -7,7 +7,8 @@ https://home-assistant.io/integrations/zha/ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING +from collections.abc import Callable +from typing import TYPE_CHECKING, Any from zigpy.exceptions import ZigbeeException import zigpy.zcl @@ -25,7 +26,6 @@ from ..const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) -from ..typing import CALLABLE_T from .base import ChannelStatus, ZigbeeChannel if TYPE_CHECKING: @@ -55,7 +55,7 @@ class IasAce(ZigbeeChannel): def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize IAS Ancillary Control Equipment channel.""" super().__init__(cluster, ch_pool) - self.command_map: dict[int, CALLABLE_T] = { + self.command_map: dict[int, Callable[..., Any]] = { IAS_ACE_ARM: self.arm, IAS_ACE_BYPASS: self._bypass, IAS_ACE_EMERGENCY: self._emergency, @@ -67,7 +67,7 @@ class IasAce(ZigbeeChannel): IAS_ACE_GET_BYPASSED_ZONE_LIST: self._get_bypassed_zone_list, IAS_ACE_GET_ZONE_STATUS: self._get_zone_status, } - self.arm_map: dict[AceCluster.ArmMode, CALLABLE_T] = { + self.arm_map: dict[AceCluster.ArmMode, Callable[..., Any]] = { AceCluster.ArmMode.Disarm: self._disarm, AceCluster.ArmMode.Arm_All_Zones: self._arm_away, AceCluster.ArmMode.Arm_Day_Home_Only: self._arm_day, diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index ed6b047566c..7e2114b5911 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -4,7 +4,7 @@ from __future__ import annotations import collections from collections.abc import Callable import dataclasses -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar import attr from zigpy import zcl @@ -17,11 +17,15 @@ from homeassistant.const import Platform # importing channels updates registries from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .decorators import DictRegistry, SetRegistry -from .typing import CALLABLE_T if TYPE_CHECKING: + from ..entity import ZhaEntity, ZhaGroupEntity from .channels.base import ClientChannel, ZigbeeChannel + +_ZhaEntityT = TypeVar("_ZhaEntityT", bound=type["ZhaEntity"]) +_ZhaGroupEntityT = TypeVar("_ZhaGroupEntityT", bound=type["ZhaGroupEntity"]) + GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] PHILLIPS_REMOTE_CLUSTER = 0xFC00 @@ -215,7 +219,7 @@ class MatchRule: class EntityClassAndChannels: """Container for entity class and corresponding channels.""" - entity_class: CALLABLE_T + entity_class: type[ZhaEntity] claimed_channel: list[ZigbeeChannel] @@ -225,19 +229,19 @@ class ZHAEntityRegistry: def __init__(self): """Initialize Registry instance.""" self._strict_registry: dict[ - str, dict[MatchRule, CALLABLE_T] + str, dict[MatchRule, type[ZhaEntity]] ] = collections.defaultdict(dict) self._multi_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[CALLABLE_T]]] + str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) self._config_diagnostic_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[CALLABLE_T]]] + str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) - self._group_registry: dict[str, CALLABLE_T] = {} + self._group_registry: dict[str, type[ZhaGroupEntity]] = {} self.single_device_matches: dict[ Platform, dict[EUI64, list[str]] ] = collections.defaultdict(lambda: collections.defaultdict(list)) @@ -248,8 +252,8 @@ class ZHAEntityRegistry: manufacturer: str, model: str, channels: list[ZigbeeChannel], - default: CALLABLE_T = None, - ) -> tuple[CALLABLE_T, list[ZigbeeChannel]]: + default: type[ZhaEntity] | None = None, + ) -> tuple[type[ZhaEntity] | None, list[ZigbeeChannel]]: """Match a ZHA Channels to a ZHA Entity class.""" matches = self._strict_registry[component] for match in sorted(matches, key=lambda x: x.weight, reverse=True): @@ -310,7 +314,7 @@ class ZHAEntityRegistry: return result, list(all_claimed) - def get_group_entity(self, component: str) -> CALLABLE_T: + def get_group_entity(self, component: str) -> type[ZhaGroupEntity] | None: """Match a ZHA group to a ZHA Entity class.""" return self._group_registry.get(component) @@ -322,14 +326,14 @@ class ZHAEntityRegistry: manufacturers: Callable | set[str] | str = None, models: Callable | set[str] | str = None, aux_channels: Callable | set[str] | str = None, - ) -> Callable[[CALLABLE_T], CALLABLE_T]: + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" rule = MatchRule( channel_names, generic_ids, manufacturers, models, aux_channels ) - def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: """Register a strict match rule. All non empty fields of a match rule must match. @@ -348,7 +352,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str = None, aux_channels: Callable | set[str] | str = None, stop_on_match_group: int | str | None = None, - ) -> Callable[[CALLABLE_T], CALLABLE_T]: + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( @@ -359,7 +363,7 @@ class ZHAEntityRegistry: aux_channels, ) - def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: """Register a loose match rule. All non empty fields of a match rule must match. @@ -381,7 +385,7 @@ class ZHAEntityRegistry: models: Callable | set[str] | str = None, aux_channels: Callable | set[str] | str = None, stop_on_match_group: int | str | None = None, - ) -> Callable[[CALLABLE_T], CALLABLE_T]: + ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" rule = MatchRule( @@ -392,7 +396,7 @@ class ZHAEntityRegistry: aux_channels, ) - def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: """Register a loose match rule. All non empty fields of a match rule must match. @@ -405,10 +409,12 @@ class ZHAEntityRegistry: return decorator - def group_match(self, component: str) -> Callable[[CALLABLE_T], CALLABLE_T]: + def group_match( + self, component: str + ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: """Decorate a group match rule.""" - def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: + def decorator(zha_ent: _ZhaGroupEntityT) -> _ZhaGroupEntityT: """Register a group match rule.""" self._group_registry[component] = zha_ent return zha_ent diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py deleted file mode 100644 index 714dc03ef82..00000000000 --- a/homeassistant/components/zha/core/typing.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Typing helpers for ZHA component.""" -from collections.abc import Callable -from typing import TypeVar - -# pylint: disable=invalid-name -CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 88dc9454f37..fb1a35ff72b 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections.abc import Callable import functools import logging from typing import TYPE_CHECKING, Any @@ -29,7 +30,6 @@ from .core.const import ( SIGNAL_REMOVE, ) from .core.helpers import LogMixin -from .core.typing import CALLABLE_T if TYPE_CHECKING: from .core.channels.base import ZigbeeChannel @@ -57,7 +57,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): self._state: Any = None self._extra_state_attributes: dict[str, Any] = {} self._zha_device = zha_device - self._unsubs: list[CALLABLE_T] = [] + self._unsubs: list[Callable[[], None]] = [] self.remove_future: asyncio.Future[Any] = asyncio.Future() @property @@ -130,7 +130,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): self, channel: ZigbeeChannel, signal: str, - func: CALLABLE_T, + func: Callable[[], Any], signal_override=False, ): """Accept a signal from a channel.""" From 670bf0641a06676ec90c38fcc90953e8572f6a21 Mon Sep 17 00:00:00 2001 From: Alessandro Ghedini Date: Mon, 20 Jun 2022 14:08:50 +0100 Subject: [PATCH 528/947] Update london-tube-status for TfL API breaking change (#73671) * Update london-tube-status for TfL API breaking change The TfL API used by the london_underground component (through the london-tube-status module) introduced breaking changes recently, which in turn broke the component, and require updating the london-tube-status dependency to fix. However the newer module versions also introduced other changes, including switching from requests to aiohttp, which require converting the london_underground component to use async APIs. Fixes #73442 * Update sensor.py Co-authored-by: Erik Montnemery --- .../london_underground/manifest.json | 2 +- .../components/london_underground/sensor.py | 18 +++++++++++------- requirements_all.txt | 2 +- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index eed2ec45dd7..e3223eb109f 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -2,7 +2,7 @@ "domain": "london_underground", "name": "London Underground", "documentation": "https://www.home-assistant.io/integrations/london_underground", - "requirements": ["london-tube-status==0.2"], + "requirements": ["london-tube-status==0.5"], "codeowners": [], "iot_class": "cloud_polling", "loggers": ["london_tube_status"] diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index a73909295b6..a4cc66a8447 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity from homeassistant.const import ATTR_ATTRIBUTION from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -43,21 +44,24 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Tube sensor.""" - data = TubeData() - data.update() + session = async_get_clientsession(hass) + + data = TubeData(session) + await data.update() + sensors = [] for line in config[CONF_LINE]: sensors.append(LondonTubeSensor(line, data)) - add_entities(sensors, True) + async_add_entities(sensors, True) class LondonTubeSensor(SensorEntity): @@ -92,8 +96,8 @@ class LondonTubeSensor(SensorEntity): self.attrs["Description"] = self._description return self.attrs - def update(self): + async def async_update(self): """Update the sensor.""" - self._data.update() + await self._data.update() self._state = self._data.data[self.name]["State"] self._description = self._data.data[self.name]["Description"] diff --git a/requirements_all.txt b/requirements_all.txt index 7ad94a23ea2..1ffbe1a76ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -969,7 +969,7 @@ locationsharinglib==4.1.5 logi_circle==0.2.3 # homeassistant.components.london_underground -london-tube-status==0.2 +london-tube-status==0.5 # homeassistant.components.recorder lru-dict==1.1.7 From 483406dea5c83cf321b7f9298d4db07a773a2884 Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 20 Jun 2022 15:46:28 +0200 Subject: [PATCH 529/947] Code cleanup fibaro switch and binary sensor (#73386) --- .../components/fibaro/binary_sensor.py | 6 +++-- homeassistant/components/fibaro/switch.py | 22 ++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 3c423dc0ce8..359869efc25 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Fibaro binary sensors.""" from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import ( ENTITY_ID_FORMAT, BinarySensorDeviceClass, @@ -49,7 +51,7 @@ async def async_setup_entry( class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): """Representation of a Fibaro Binary Sensor.""" - def __init__(self, fibaro_device): + def __init__(self, fibaro_device: Any) -> None: """Initialize the binary_sensor.""" super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) @@ -62,6 +64,6 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): self._attr_device_class = SENSOR_TYPES[stype][2] self._attr_icon = SENSOR_TYPES[stype][1] - def update(self): + def update(self) -> None: """Get the latest data and update the state.""" self._attr_is_on = self.current_binary_state diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index fe2b35866b0..66aad4d673b 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -1,6 +1,8 @@ """Support for Fibaro switches.""" from __future__ import annotations +from typing import Any + from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -31,27 +33,21 @@ async def async_setup_entry( class FibaroSwitch(FibaroDevice, SwitchEntity): """Representation of a Fibaro Switch.""" - def __init__(self, fibaro_device): + def __init__(self, fibaro_device: Any) -> None: """Initialize the Fibaro device.""" - self._state = False super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - def turn_on(self, **kwargs): + def turn_on(self, **kwargs: Any) -> None: """Turn device on.""" self.call_turn_on() - self._state = True + self._attr_is_on = True - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" self.call_turn_off() - self._state = False + self._attr_is_on = False - @property - def is_on(self): - """Return true if device is on.""" - return self._state - - def update(self): + def update(self) -> None: """Update device state.""" - self._state = self.current_binary_state + self._attr_is_on = self.current_binary_state From 3824703a64cde109a27a5b9b0b684ee309b160d7 Mon Sep 17 00:00:00 2001 From: Joel <34544090+JoelKle@users.noreply.github.com> Date: Mon, 20 Jun 2022 16:08:43 +0200 Subject: [PATCH 530/947] Fix homematicip cloud cover tilt position (#73410) * cover slats fixed set tilt position * Update cover.py * Adjust tests Co-authored-by: Erik Montnemery --- homeassistant/components/homematicip_cloud/cover.py | 10 +++++++--- tests/components/homematicip_cloud/test_cover.py | 12 ++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index f4af4d88a8e..b5076fa74ec 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -241,15 +241,19 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 - await self._device.set_slats_level(level, self._channel) + await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel) async def async_open_cover_tilt(self, **kwargs) -> None: """Open the slats.""" - await self._device.set_slats_level(HMIP_SLATS_OPEN, self._channel) + await self._device.set_slats_level( + slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel + ) async def async_close_cover_tilt(self, **kwargs) -> None: """Close the slats.""" - await self._device.set_slats_level(HMIP_SLATS_CLOSED, self._channel) + await self._device.set_slats_level( + slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel + ) async def async_stop_cover_tilt(self, **kwargs) -> None: """Stop the device if in motion.""" diff --git a/tests/components/homematicip_cloud/test_cover.py b/tests/components/homematicip_cloud/test_cover.py index a35576ed353..023ba9d5f0e 100644 --- a/tests/components/homematicip_cloud/test_cover.py +++ b/tests/components/homematicip_cloud/test_cover.py @@ -109,7 +109,7 @@ async def test_hmip_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 1 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0, 1) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0) ha_state = hass.states.get(entity_id) @@ -125,7 +125,7 @@ async def test_hmip_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 4 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0.5, 1) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN @@ -137,7 +137,7 @@ async def test_hmip_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 6 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (1, 1) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 1, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN @@ -187,7 +187,7 @@ async def test_hmip_multi_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 1 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0, 4) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0} await async_manipulate_test_data(hass, hmip_device, "shutterLevel", 0, channel=4) await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0, channel=4) ha_state = hass.states.get(entity_id) @@ -203,7 +203,7 @@ async def test_hmip_multi_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 4 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (0.5, 4) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 0.5} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 0.5, channel=4) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN @@ -215,7 +215,7 @@ async def test_hmip_multi_cover_slats(hass, default_mock_hap_factory): ) assert len(hmip_device.mock_calls) == service_call_counter + 6 assert hmip_device.mock_calls[-1][0] == "set_slats_level" - assert hmip_device.mock_calls[-1][1] == (1, 4) + assert hmip_device.mock_calls[-1][2] == {"channelIndex": 4, "slatsLevel": 1} await async_manipulate_test_data(hass, hmip_device, "slatsLevel", 1, channel=4) ha_state = hass.states.get(entity_id) assert ha_state.state == STATE_OPEN From 81e3ed790da1ae7408c87a4b46616d71e2b3ec53 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 20 Jun 2022 17:09:58 +0300 Subject: [PATCH 531/947] Add re-authentication for `transmission` (#73124) * Add reauth flow to transmission * fix async_setup * add strings * fix test coverage --- .../components/transmission/__init__.py | 13 +-- .../components/transmission/config_flow.py | 47 +++++++++ .../components/transmission/strings.json | 10 +- .../transmission/translations/en.json | 10 +- .../transmission/test_config_flow.py | 96 +++++++++++++++++++ tests/components/transmission/test_init.py | 4 +- 6 files changed, 168 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index ac6659b048e..ae5d2dacf61 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -85,8 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b client = TransmissionClient(hass, config_entry) hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = client - if not await client.async_setup(): - return False + await client.async_setup() return True @@ -152,15 +151,15 @@ class TransmissionClient: """Return the TransmissionData object.""" return self._tm_data - async def async_setup(self): + async def async_setup(self) -> None: """Set up the Transmission client.""" try: self.tm_api = await get_api(self.hass, self.config_entry.data) except CannotConnect as error: raise ConfigEntryNotReady from error - except (AuthenticationError, UnknownError): - return False + except (AuthenticationError, UnknownError) as error: + raise ConfigEntryAuthFailed from error self._tm_data = TransmissionData(self.hass, self.config_entry, self.tm_api) @@ -262,8 +261,6 @@ class TransmissionClient: self.config_entry.add_update_listener(self.async_options_updated) - return True - def add_options(self): """Add options for entry.""" if not self.config_entry.options: diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index b57279ebbbb..c0bc51b0683 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Transmission Bittorent Client.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + import voluptuous as vol from homeassistant import config_entries @@ -13,6 +16,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from . import get_api from .const import ( @@ -43,6 +47,7 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle Tansmission config flow.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None @staticmethod @callback @@ -87,6 +92,48 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + errors = {} + assert self._reauth_entry + if user_input is not None: + user_input = {**self._reauth_entry.data, **user_input} + try: + await get_api(self.hass, user_input) + + except AuthenticationError: + errors[CONF_PASSWORD] = "invalid_auth" + except (CannotConnect, UnknownError): + errors["base"] = "cannot_connect" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + description_placeholders={ + CONF_USERNAME: self._reauth_entry.data[CONF_USERNAME] + }, + step_id="reauth_confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + class TransmissionOptionsFlowHandler(config_entries.OptionsFlow): """Handle Transmission client options.""" diff --git a/homeassistant/components/transmission/strings.json b/homeassistant/components/transmission/strings.json index 81725ad7d16..0194917c416 100644 --- a/homeassistant/components/transmission/strings.json +++ b/homeassistant/components/transmission/strings.json @@ -10,6 +10,13 @@ "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]" } + }, + "reauth_confirm": { + "description": "The password for {username} is invalid.", + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { @@ -18,7 +25,8 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/transmission/translations/en.json b/homeassistant/components/transmission/translations/en.json index e92e307d3bc..3726f6f0a7e 100644 --- a/homeassistant/components/transmission/translations/en.json +++ b/homeassistant/components/transmission/translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Device is already configured" + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" }, "error": { "cannot_connect": "Failed to connect", @@ -9,6 +10,13 @@ "name_exists": "Name already exists" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "The password for {username} is invalid.", + "title": "Reauthenticate Integration" + }, "user": { "data": { "host": "Host", diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index 4e3a7c73d6c..7588736e997 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -257,3 +257,99 @@ async def test_error_on_unknown_error(hass, unknown_error): ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "cannot_connect"} + + +async def test_reauth_success(hass, api): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=transmission.DOMAIN, + data=MOCK_ENTRY, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_ENTRY, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + assert result["description_placeholders"] == {CONF_USERNAME: USERNAME} + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "reauth_successful" + + +async def test_reauth_failed(hass, auth_error): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=transmission.DOMAIN, + data=MOCK_ENTRY, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_ENTRY, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-wrong-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == { + CONF_PASSWORD: "invalid_auth", + } + + +async def test_reauth_failed_conn_error(hass, conn_error): + """Test we can reauth.""" + entry = MockConfigEntry( + domain=transmission.DOMAIN, + data=MOCK_ENTRY, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + }, + data=MOCK_ENTRY, + ) + + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_PASSWORD: "test-wrong-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/transmission/test_init.py b/tests/components/transmission/test_init.py index 78fddc5be86..c3dc924c54e 100644 --- a/tests/components/transmission/test_init.py +++ b/tests/components/transmission/test_init.py @@ -6,7 +6,7 @@ import pytest from transmissionrpc.error import TransmissionError from homeassistant.components import transmission -from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, mock_coro @@ -105,7 +105,7 @@ async def test_setup_failed(hass): with patch( "transmissionrpc.Client", side_effect=TransmissionError("401: Unauthorized") - ): + ), pytest.raises(ConfigEntryAuthFailed): assert await transmission.async_setup_entry(hass, entry) is False From be2aa44559dcb19fa7211e084cfb9372cbb9e7f5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 16:25:24 +0200 Subject: [PATCH 532/947] Fix mypy issues in zha config_flow (#73744) --- homeassistant/components/zha/config_flow.py | 3 +-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 1832424587b..03be28b9d8c 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -168,11 +168,10 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): radio_type = discovery_info.properties.get("radio_type") or local_name node_name = local_name[: -len(".local")] host = discovery_info.host + port = discovery_info.port if local_name.startswith("tube") or "efr32" in local_name: # This is hard coded to work with legacy devices port = 6638 - else: - port = discovery_info.port device_path = f"socket://{host}:{port}" if current_entry := await self.async_set_unique_id(node_name): diff --git a/mypy.ini b/mypy.ini index c4561dea5a8..d5b5ca39183 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2997,9 +2997,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.climate] ignore_errors = true -[mypy-homeassistant.components.zha.config_flow] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.base] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 6b42bcd2cdd..bb46ba39212 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -148,7 +148,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.zha.binary_sensor", "homeassistant.components.zha.button", "homeassistant.components.zha.climate", - "homeassistant.components.zha.config_flow", "homeassistant.components.zha.core.channels.base", "homeassistant.components.zha.core.channels.closures", "homeassistant.components.zha.core.channels.general", From 9a95649a226e5e6279d84745946656bda4f9dbff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 17:29:45 +0200 Subject: [PATCH 533/947] Use a TypedDict for REPORT_CONFIG in zha (#73629) * Introduce ReportConfig TypedDict in zha * Fix hint * Always use Tuple * Replace empty list with empty tuple * Allow float for third config tuple value * ReportConfig -> AttrReportConfig * dict -> AttrReportConfig * Allow int attributes * Add coments --- .../components/zha/core/channels/base.py | 14 ++- .../components/zha/core/channels/closures.py | 10 +- .../components/zha/core/channels/general.py | 54 +++++--- .../zha/core/channels/homeautomation.py | 20 +-- .../components/zha/core/channels/hvac.py | 34 +++-- .../components/zha/core/channels/lighting.py | 8 +- .../zha/core/channels/manufacturerspecific.py | 28 ++--- .../zha/core/channels/measurement.py | 118 ++++++++++-------- .../zha/core/channels/smartenergy.py | 8 +- 9 files changed, 173 insertions(+), 121 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 9b3ad6d9572..c9472af5938 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -5,7 +5,7 @@ import asyncio from enum import Enum from functools import partialmethod, wraps import logging -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, TypedDict import zigpy.exceptions import zigpy.zcl @@ -46,6 +46,16 @@ if TYPE_CHECKING: _LOGGER = logging.getLogger(__name__) +class AttrReportConfig(TypedDict, total=True): + """Configuration to report for the attributes.""" + + # Could be either an attribute name or attribute id + attr: str | int + # The config for the attribute reporting configuration consists of a tuple for + # (minimum_reported_time_interval_s, maximum_reported_time_interval_s, value_delta) + config: tuple[int, int, int | float] + + def parse_and_log_command(channel, tsn, command_id, args): """Parse and log a zigbee cluster command.""" cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] @@ -99,7 +109,7 @@ class ChannelStatus(Enum): class ZigbeeChannel(LogMixin): """Base channel for a Zigbee cluster.""" - REPORT_CONFIG: tuple[dict[int | str, tuple[int, int, int | float]]] = () + REPORT_CONFIG: tuple[AttrReportConfig] = () BIND: bool = True # Dict of attributes to read on channel initialization. diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index bf50c8fc4ba..de2dcaf38e9 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -5,7 +5,7 @@ from homeassistant.core import callback from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED -from .base import ClientChannel, ZigbeeChannel +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register(closures.DoorLock.cluster_id) @@ -13,7 +13,9 @@ class DoorLockChannel(ZigbeeChannel): """Door lock channel.""" _value_attribute = 0 - REPORT_CONFIG = ({"attr": "lock_state", "config": REPORT_CONFIG_IMMEDIATE},) + REPORT_CONFIG = ( + AttrReportConfig(attr="lock_state", config=REPORT_CONFIG_IMMEDIATE), + ) async def async_update(self): """Retrieve latest state.""" @@ -121,7 +123,9 @@ class WindowCovering(ZigbeeChannel): _value_attribute = 8 REPORT_CONFIG = ( - {"attr": "current_position_lift_percentage", "config": REPORT_CONFIG_IMMEDIATE}, + AttrReportConfig( + attr="current_position_lift_percentage", config=REPORT_CONFIG_IMMEDIATE + ), ) async def async_update(self): diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index aa292013081..8886085bf47 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -27,7 +27,7 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_UPDATE_DEVICE, ) -from .base import ClientChannel, ZigbeeChannel, parse_and_log_command +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel, parse_and_log_command if TYPE_CHECKING: from . import ChannelPool @@ -42,7 +42,9 @@ class Alarms(ZigbeeChannel): class AnalogInput(ZigbeeChannel): """Analog Input channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.BINDABLE_CLUSTERS.register(general.AnalogOutput.cluster_id) @@ -50,7 +52,9 @@ class AnalogInput(ZigbeeChannel): class AnalogOutput(ZigbeeChannel): """Analog Output channel.""" - REPORT_CONFIG = ({"attr": "present_value", "config": REPORT_CONFIG_DEFAULT},) + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) ZCL_INIT_ATTRS = { "min_present_value": True, "max_present_value": True, @@ -119,7 +123,9 @@ class AnalogOutput(ZigbeeChannel): class AnalogValue(ZigbeeChannel): """Analog Value channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.ApplianceControl.cluster_id) @@ -151,21 +157,27 @@ class BasicChannel(ZigbeeChannel): class BinaryInput(ZigbeeChannel): """Binary Input channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryOutput.cluster_id) class BinaryOutput(ZigbeeChannel): """Binary Output channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryValue.cluster_id) class BinaryValue(ZigbeeChannel): """Binary Value channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Commissioning.cluster_id) @@ -177,12 +189,12 @@ class Commissioning(ZigbeeChannel): class DeviceTemperature(ZigbeeChannel): """Device Temperature channel.""" - REPORT_CONFIG = [ + REPORT_CONFIG = ( { "attr": "current_temperature", "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), - } - ] + }, + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.GreenPowerProxy.cluster_id) @@ -225,7 +237,7 @@ class LevelControlChannel(ZigbeeChannel): """Channel for the LevelControl Zigbee cluster.""" CURRENT_LEVEL = 0 - REPORT_CONFIG = ({"attr": "current_level", "config": REPORT_CONFIG_ASAP},) + REPORT_CONFIG = (AttrReportConfig(attr="current_level", config=REPORT_CONFIG_ASAP),) ZCL_INIT_ATTRS = { "on_off_transition_time": True, "on_level": True, @@ -275,21 +287,27 @@ class LevelControlChannel(ZigbeeChannel): class MultistateInput(ZigbeeChannel): """Multistate Input channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateOutput.cluster_id) class MultistateOutput(ZigbeeChannel): """Multistate Output channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateValue.cluster_id) class MultistateValue(ZigbeeChannel): """Multistate Value channel.""" - REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="present_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.CLIENT_CHANNELS_REGISTRY.register(general.OnOff.cluster_id) @@ -303,7 +321,7 @@ class OnOffChannel(ZigbeeChannel): """Channel for the OnOff Zigbee cluster.""" ON_OFF = 0 - REPORT_CONFIG = ({"attr": "on_off", "config": REPORT_CONFIG_IMMEDIATE},) + REPORT_CONFIG = (AttrReportConfig(attr="on_off", config=REPORT_CONFIG_IMMEDIATE),) ZCL_INIT_ATTRS = { "start_up_on_off": True, } @@ -472,8 +490,10 @@ class PowerConfigurationChannel(ZigbeeChannel): """Channel for the zigbee power configuration cluster.""" REPORT_CONFIG = ( - {"attr": "battery_voltage", "config": REPORT_CONFIG_BATTERY_SAVE}, - {"attr": "battery_percentage_remaining", "config": REPORT_CONFIG_BATTERY_SAVE}, + AttrReportConfig(attr="battery_voltage", config=REPORT_CONFIG_BATTERY_SAVE), + AttrReportConfig( + attr="battery_percentage_remaining", config=REPORT_CONFIG_BATTERY_SAVE + ), ) def async_initialize_channel_specific(self, from_cache: bool) -> Coroutine: diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index 60c33c93003..52036706f19 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -12,7 +12,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import ZigbeeChannel +from .base import AttrReportConfig, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -63,15 +63,15 @@ class ElectricalMeasurementChannel(ZigbeeChannel): POWER_QUALITY_MEASUREMENT = 256 REPORT_CONFIG = ( - {"attr": "active_power", "config": REPORT_CONFIG_OP}, - {"attr": "active_power_max", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "apparent_power", "config": REPORT_CONFIG_OP}, - {"attr": "rms_current", "config": REPORT_CONFIG_OP}, - {"attr": "rms_current_max", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "rms_voltage", "config": REPORT_CONFIG_OP}, - {"attr": "rms_voltage_max", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "ac_frequency", "config": REPORT_CONFIG_OP}, - {"attr": "ac_frequency_max", "config": REPORT_CONFIG_DEFAULT}, + AttrReportConfig(attr="active_power", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="active_power_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="apparent_power", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="rms_current", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="rms_current_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="rms_voltage", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="rms_voltage_max", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="ac_frequency", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="ac_frequency_max", config=REPORT_CONFIG_DEFAULT), ) ZCL_INIT_ATTRS = { "ac_current_divisor": True, diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 5b102d062cb..53f18a0fd0f 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -22,7 +22,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import ZigbeeChannel +from .base import AttrReportConfig, ZigbeeChannel AttributeUpdateRecord = namedtuple("AttributeUpdateRecord", "attr_id, attr_name, value") REPORT_CONFIG_CLIMATE = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 25) @@ -41,7 +41,7 @@ class FanChannel(ZigbeeChannel): _value_attribute = 0 - REPORT_CONFIG = ({"attr": "fan_mode", "config": REPORT_CONFIG_OP},) + REPORT_CONFIG = (AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_OP),) ZCL_INIT_ATTRS = {"fan_mode_sequence": True} @property @@ -90,17 +90,25 @@ class ThermostatChannel(ZigbeeChannel): """Thermostat channel.""" REPORT_CONFIG = ( - {"attr": "local_temperature", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_cooling_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "unoccupied_heating_setpoint", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "running_state", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "system_mode", "config": REPORT_CONFIG_CLIMATE}, - {"attr": "occupancy", "config": REPORT_CONFIG_CLIMATE_DISCRETE}, - {"attr": "pi_cooling_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, - {"attr": "pi_heating_demand", "config": REPORT_CONFIG_CLIMATE_DEMAND}, + AttrReportConfig(attr="local_temperature", config=REPORT_CONFIG_CLIMATE), + AttrReportConfig( + attr="occupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig( + attr="occupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig( + attr="unoccupied_cooling_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig( + attr="unoccupied_heating_setpoint", config=REPORT_CONFIG_CLIMATE + ), + AttrReportConfig(attr="running_mode", config=REPORT_CONFIG_CLIMATE), + AttrReportConfig(attr="running_state", config=REPORT_CONFIG_CLIMATE_DEMAND), + AttrReportConfig(attr="system_mode", config=REPORT_CONFIG_CLIMATE), + AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_CLIMATE_DISCRETE), + AttrReportConfig(attr="pi_cooling_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), + AttrReportConfig(attr="pi_heating_demand", config=REPORT_CONFIG_CLIMATE_DEMAND), ) ZCL_INIT_ATTRS: dict[int | str, bool] = { "abs_min_heat_setpoint_limit": True, diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 13d5b4c2742..99e6101b0bd 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -7,7 +7,7 @@ from zigpy.zcl.clusters import lighting from .. import registries from ..const import REPORT_CONFIG_DEFAULT -from .base import ClientChannel, ZigbeeChannel +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register(lighting.Ballast.cluster_id) @@ -29,9 +29,9 @@ class ColorChannel(ZigbeeChannel): CAPABILITIES_COLOR_TEMP = 0x10 UNSUPPORTED_ATTRIBUTE = 0x86 REPORT_CONFIG = ( - {"attr": "current_x", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "current_y", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "color_temperature", "config": REPORT_CONFIG_DEFAULT}, + AttrReportConfig(attr="current_x", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="current_y", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="color_temperature", config=REPORT_CONFIG_DEFAULT), ) MAX_MIREDS: int = 500 MIN_MIREDS: int = 153 diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 144d5736526..0c246e28db7 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -19,7 +19,7 @@ from ..const import ( SIGNAL_ATTR_UPDATED, UNKNOWN, ) -from .base import ClientChannel, ZigbeeChannel +from .base import AttrReportConfig, ClientChannel, ZigbeeChannel if TYPE_CHECKING: from . import ChannelPool @@ -31,12 +31,12 @@ _LOGGER = logging.getLogger(__name__) class SmartThingsHumidity(ZigbeeChannel): """Smart Things Humidity channel.""" - REPORT_CONFIG = [ + REPORT_CONFIG = ( { "attr": "measured_value", "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), - } - ] + }, + ) @registries.CHANNEL_ONLY_CLUSTERS.register(0xFD00) @@ -44,7 +44,7 @@ class SmartThingsHumidity(ZigbeeChannel): class OsramButton(ZigbeeChannel): """Osram button channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () @registries.CHANNEL_ONLY_CLUSTERS.register(registries.PHILLIPS_REMOTE_CLUSTER) @@ -52,7 +52,7 @@ class OsramButton(ZigbeeChannel): class PhillipsRemote(ZigbeeChannel): """Phillips remote channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () @registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0) @@ -60,7 +60,7 @@ class PhillipsRemote(ZigbeeChannel): class OppleRemote(ZigbeeChannel): """Opple button channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize Opple channel.""" @@ -87,12 +87,12 @@ class OppleRemote(ZigbeeChannel): class SmartThingsAcceleration(ZigbeeChannel): """Smart Things Acceleration channel.""" - REPORT_CONFIG = [ - {"attr": "acceleration", "config": REPORT_CONFIG_ASAP}, - {"attr": "x_axis", "config": REPORT_CONFIG_ASAP}, - {"attr": "y_axis", "config": REPORT_CONFIG_ASAP}, - {"attr": "z_axis", "config": REPORT_CONFIG_ASAP}, - ] + REPORT_CONFIG = ( + AttrReportConfig(attr="acceleration", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="x_axis", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="y_axis", config=REPORT_CONFIG_ASAP), + AttrReportConfig(attr="z_axis", config=REPORT_CONFIG_ASAP), + ) @callback def attribute_updated(self, attrid, value): @@ -121,4 +121,4 @@ class SmartThingsAcceleration(ZigbeeChannel): class InovelliCluster(ClientChannel): """Inovelli Button Press Event channel.""" - REPORT_CONFIG = [] + REPORT_CONFIG = () diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 7368309cf99..fa6f9c07dee 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -8,14 +8,16 @@ from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) -from .base import ZigbeeChannel +from .base import AttrReportConfig, ZigbeeChannel @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id) class FlowMeasurement(ZigbeeChannel): """Flow Measurement channel.""" - REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -24,7 +26,9 @@ class FlowMeasurement(ZigbeeChannel): class IlluminanceLevelSensing(ZigbeeChannel): """Illuminance Level Sensing channel.""" - REPORT_CONFIG = [{"attr": "level_status", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="level_status", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -33,57 +37,63 @@ class IlluminanceLevelSensing(ZigbeeChannel): class IlluminanceMeasurement(ZigbeeChannel): """Illuminance Measurement channel.""" - REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.OccupancySensing.cluster_id) class OccupancySensing(ZigbeeChannel): """Occupancy Sensing channel.""" - REPORT_CONFIG = [{"attr": "occupancy", "config": REPORT_CONFIG_IMMEDIATE}] + REPORT_CONFIG = ( + AttrReportConfig(attr="occupancy", config=REPORT_CONFIG_IMMEDIATE), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id) class PressureMeasurement(ZigbeeChannel): """Pressure measurement channel.""" - REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] + REPORT_CONFIG = ( + AttrReportConfig(attr="measured_value", config=REPORT_CONFIG_DEFAULT), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.RelativeHumidity.cluster_id) class RelativeHumidity(ZigbeeChannel): """Relative Humidity measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.SoilMoisture.cluster_id) class SoilMoisture(ZigbeeChannel): """Soil Moisture measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.LeafWetness.cluster_id) class LeafWetness(ZigbeeChannel): """Leaf Wetness measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 100), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -92,12 +102,12 @@ class LeafWetness(ZigbeeChannel): class TemperatureMeasurement(ZigbeeChannel): """Temperature measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 50), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -106,12 +116,12 @@ class TemperatureMeasurement(ZigbeeChannel): class CarbonMonoxideConcentration(ZigbeeChannel): """Carbon Monoxide measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -120,24 +130,24 @@ class CarbonMonoxideConcentration(ZigbeeChannel): class CarbonDioxideConcentration(ZigbeeChannel): """Carbon Dioxide measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PM25.cluster_id) class PM25(ZigbeeChannel): """Particulate Matter 2.5 microns or less measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.1), + ), + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( @@ -146,9 +156,9 @@ class PM25(ZigbeeChannel): class FormaldehydeConcentration(ZigbeeChannel): """Formaldehyde measurement channel.""" - REPORT_CONFIG = [ - { - "attr": "measured_value", - "config": (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), - } - ] + REPORT_CONFIG = ( + AttrReportConfig( + attr="measured_value", + config=(REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, 0.000001), + ), + ) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 099571aa69e..731ec003011 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -15,7 +15,7 @@ from ..const import ( REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED, ) -from .base import ZigbeeChannel +from .base import AttrReportConfig, ZigbeeChannel if TYPE_CHECKING: from . import ChannelPool @@ -66,9 +66,9 @@ class Metering(ZigbeeChannel): """Metering channel.""" REPORT_CONFIG = ( - {"attr": "instantaneous_demand", "config": REPORT_CONFIG_OP}, - {"attr": "current_summ_delivered", "config": REPORT_CONFIG_DEFAULT}, - {"attr": "status", "config": REPORT_CONFIG_ASAP}, + AttrReportConfig(attr="instantaneous_demand", config=REPORT_CONFIG_OP), + AttrReportConfig(attr="current_summ_delivered", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="status", config=REPORT_CONFIG_ASAP), ) ZCL_INIT_ATTRS = { "demand_formatting": True, From f43cc18aa305c9014e9892006497fceb6b851241 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 20 Jun 2022 17:31:16 +0200 Subject: [PATCH 534/947] Fix type hints in zha platforms (#73745) * Adjust binary_sensor * Adjust device_action * Adjust device_tracker * Adjust fan * Adjust lock * Adjust siren --- homeassistant/components/zha/binary_sensor.py | 6 ++++-- homeassistant/components/zha/device_action.py | 6 ++++-- homeassistant/components/zha/device_tracker.py | 6 +++--- homeassistant/components/zha/fan.py | 2 +- homeassistant/components/zha/lock.py | 8 ++++---- homeassistant/components/zha/siren.py | 7 ++++--- mypy.ini | 18 ------------------ script/hassfest/mypy_config.py | 6 ------ 8 files changed, 20 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 954c60fa895..8130e7d5f98 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -1,4 +1,6 @@ """Binary sensors on Zigbee Home Automation networks.""" +from __future__ import annotations + import functools from homeassistant.components.binary_sensor import ( @@ -60,7 +62,7 @@ async def async_setup_entry( class BinarySensor(ZhaEntity, BinarySensorEntity): """ZHA BinarySensor.""" - SENSOR_ATTR = None + SENSOR_ATTR: str | None = None def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA binary sensor.""" @@ -161,7 +163,7 @@ class IASZone(BinarySensor): SENSOR_ATTR = "zone_status" @property - def device_class(self) -> str: + def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" return CLASS_MAPPING.get(self._channel.cluster.get("zone_type")) diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 049ffbd40f3..3ee8694b09c 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -1,6 +1,8 @@ """Provides device actions for ZHA devices.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_TYPE @@ -82,9 +84,9 @@ async def async_get_actions( async def _execute_service_based_action( hass: HomeAssistant, - config: ACTION_SCHEMA, + config: dict[str, Any], variables: TemplateVarsType, - context: Context, + context: Context | None, ) -> None: action_type = config[CONF_TYPE] service_name = SERVICE_NAMES[action_type] diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index c08491ab782..cf4a830f4da 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -107,10 +107,10 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """ return self._battery_level - @property + @property # type: ignore[misc] def device_info( # pylint: disable=overridden-final-method self, - ) -> DeviceInfo | None: + ) -> DeviceInfo: """Return device info.""" # We opt ZHA device tracker back into overriding this method because # it doesn't track IP-based devices. @@ -118,7 +118,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): return super(ZhaEntity, self).device_info @property - def unique_id(self) -> str | None: + def unique_id(self) -> str: """Return unique ID.""" # Call Super because ScannerEntity overrode it. return super(ZhaEntity, self).unique_id diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 1c2f52c6038..a4b9baa5f0a 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -97,7 +97,7 @@ class BaseFan(FanEntity): """Turn the entity off.""" await self.async_set_percentage(0) - async def async_set_percentage(self, percentage: int | None) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percenage of the fan.""" fan_mode = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) await self._async_set_fan_mode(fan_mode) diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index b26d7087b75..449fd1089eb 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -53,7 +53,7 @@ async def async_setup_entry( platform = entity_platform.async_get_current_platform() - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_SET_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), @@ -62,7 +62,7 @@ async def async_setup_entry( "async_set_lock_user_code", ) - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_ENABLE_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), @@ -70,7 +70,7 @@ async def async_setup_entry( "async_enable_lock_user_code", ) - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_DISABLE_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), @@ -78,7 +78,7 @@ async def async_setup_entry( "async_disable_lock_user_code", ) - platform.async_register_entity_service( # type: ignore + platform.async_register_entity_service( SERVICE_CLEAR_LOCK_USER_CODE, { vol.Required("code_slot"): vol.Coerce(int), diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index b509f9585db..66cd2bf4002 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -1,8 +1,9 @@ """Support for ZHA sirens.""" from __future__ import annotations +from collections.abc import Callable import functools -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from zigpy.zcl.clusters.security import IasWd as WD @@ -96,9 +97,9 @@ class ZHASiren(ZhaEntity, SirenEntity): WARNING_DEVICE_MODE_EMERGENCY_PANIC: "Emergency Panic", } super().__init__(unique_id, zha_device, channels, **kwargs) - self._channel: IasWd = channels[0] + self._channel: IasWd = cast(IasWd, channels[0]) self._attr_is_on: bool = False - self._off_listener = None + self._off_listener: Callable[[], None] | None = None async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" diff --git a/mypy.ini b/mypy.ini index d5b5ca39183..27aa6653357 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2988,9 +2988,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.api] ignore_errors = true -[mypy-homeassistant.components.zha.binary_sensor] -ignore_errors = true - [mypy-homeassistant.components.zha.button] ignore_errors = true @@ -3051,32 +3048,17 @@ ignore_errors = true [mypy-homeassistant.components.zha.cover] ignore_errors = true -[mypy-homeassistant.components.zha.device_action] -ignore_errors = true - -[mypy-homeassistant.components.zha.device_tracker] -ignore_errors = true - [mypy-homeassistant.components.zha.entity] ignore_errors = true -[mypy-homeassistant.components.zha.fan] -ignore_errors = true - [mypy-homeassistant.components.zha.light] ignore_errors = true -[mypy-homeassistant.components.zha.lock] -ignore_errors = true - [mypy-homeassistant.components.zha.select] ignore_errors = true [mypy-homeassistant.components.zha.sensor] ignore_errors = true -[mypy-homeassistant.components.zha.siren] -ignore_errors = true - [mypy-homeassistant.components.zha.switch] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index bb46ba39212..a16faf7f34e 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -145,7 +145,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.sensor", "homeassistant.components.xiaomi_miio.switch", "homeassistant.components.zha.api", - "homeassistant.components.zha.binary_sensor", "homeassistant.components.zha.button", "homeassistant.components.zha.climate", "homeassistant.components.zha.core.channels.base", @@ -166,15 +165,10 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.zha.core.registries", "homeassistant.components.zha.core.store", "homeassistant.components.zha.cover", - "homeassistant.components.zha.device_action", - "homeassistant.components.zha.device_tracker", "homeassistant.components.zha.entity", - "homeassistant.components.zha.fan", "homeassistant.components.zha.light", - "homeassistant.components.zha.lock", "homeassistant.components.zha.select", "homeassistant.components.zha.sensor", - "homeassistant.components.zha.siren", "homeassistant.components.zha.switch", ] From 66b02ecff038672df3aa5b53a408c79e26236bae Mon Sep 17 00:00:00 2001 From: Gordon Allott Date: Mon, 20 Jun 2022 19:27:39 +0100 Subject: [PATCH 535/947] Ensure metoffice daily are returned once daily (#72440) * ensure metoffice daily are returned once daily * Fixes metoffice tests for MODE_DAILY --- homeassistant/components/metoffice/helpers.py | 4 ++ tests/components/metoffice/test_weather.py | 45 ++++++++++--------- 2 files changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/metoffice/helpers.py b/homeassistant/components/metoffice/helpers.py index 31ac4c141a9..00d5e73501d 100644 --- a/homeassistant/components/metoffice/helpers.py +++ b/homeassistant/components/metoffice/helpers.py @@ -7,6 +7,7 @@ import datapoint from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.util.dt import utcnow +from .const import MODE_3HOURLY from .data import MetOfficeData _LOGGER = logging.getLogger(__name__) @@ -39,6 +40,9 @@ def fetch_data(connection: datapoint.Manager, site, mode) -> MetOfficeData: for day in forecast.days for timestep in day.timesteps if timestep.date > time_now + and ( + mode == MODE_3HOURLY or timestep.date.hour > 6 + ) # ensures only one result per day in MODE_DAILY ], site, ) diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index fb00203661b..bf279ff3cf7 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -163,16 +163,17 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check - assert len(weather.attributes.get("forecast")) == 8 + # ensures that daily filters out multiple results per day + assert len(weather.attributes.get("forecast")) == 4 assert ( - weather.attributes.get("forecast")[7]["datetime"] == "2020-04-29T12:00:00+00:00" + weather.attributes.get("forecast")[3]["datetime"] == "2020-04-29T12:00:00+00:00" ) - assert weather.attributes.get("forecast")[7]["condition"] == "rainy" - assert weather.attributes.get("forecast")[7]["precipitation_probability"] == 59 - assert weather.attributes.get("forecast")[7]["temperature"] == 13 - assert weather.attributes.get("forecast")[7]["wind_speed"] == 13 - assert weather.attributes.get("forecast")[7]["wind_bearing"] == "SE" + assert weather.attributes.get("forecast")[3]["condition"] == "rainy" + assert weather.attributes.get("forecast")[3]["precipitation_probability"] == 59 + assert weather.attributes.get("forecast")[3]["temperature"] == 13 + assert weather.attributes.get("forecast")[3]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" @freeze_time(datetime.datetime(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)) @@ -258,16 +259,17 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check - assert len(weather.attributes.get("forecast")) == 8 + # ensures that daily filters out multiple results per day + assert len(weather.attributes.get("forecast")) == 4 assert ( - weather.attributes.get("forecast")[7]["datetime"] == "2020-04-29T12:00:00+00:00" + weather.attributes.get("forecast")[3]["datetime"] == "2020-04-29T12:00:00+00:00" ) - assert weather.attributes.get("forecast")[7]["condition"] == "rainy" - assert weather.attributes.get("forecast")[7]["precipitation_probability"] == 59 - assert weather.attributes.get("forecast")[7]["temperature"] == 13 - assert weather.attributes.get("forecast")[7]["wind_speed"] == 13 - assert weather.attributes.get("forecast")[7]["wind_bearing"] == "SE" + assert weather.attributes.get("forecast")[3]["condition"] == "rainy" + assert weather.attributes.get("forecast")[3]["precipitation_probability"] == 59 + assert weather.attributes.get("forecast")[3]["temperature"] == 13 + assert weather.attributes.get("forecast")[3]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" # King's Lynn 3-hourly weather platform expected results weather = hass.states.get("weather.met_office_king_s_lynn_3_hourly") @@ -305,13 +307,14 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("humidity") == 75 # All should have Forecast added - again, just picking out 1 entry to check - assert len(weather.attributes.get("forecast")) == 8 + # ensures daily filters out multiple results per day + assert len(weather.attributes.get("forecast")) == 4 assert ( - weather.attributes.get("forecast")[5]["datetime"] == "2020-04-28T12:00:00+00:00" + weather.attributes.get("forecast")[2]["datetime"] == "2020-04-28T12:00:00+00:00" ) - assert weather.attributes.get("forecast")[5]["condition"] == "cloudy" - assert weather.attributes.get("forecast")[5]["precipitation_probability"] == 14 - assert weather.attributes.get("forecast")[5]["temperature"] == 11 - assert weather.attributes.get("forecast")[5]["wind_speed"] == 7 - assert weather.attributes.get("forecast")[5]["wind_bearing"] == "ESE" + assert weather.attributes.get("forecast")[2]["condition"] == "cloudy" + assert weather.attributes.get("forecast")[2]["precipitation_probability"] == 14 + assert weather.attributes.get("forecast")[2]["temperature"] == 11 + assert weather.attributes.get("forecast")[2]["wind_speed"] == 7 + assert weather.attributes.get("forecast")[2]["wind_bearing"] == "ESE" From 16e7593a7b0f37e69b3d8582e241b0994dc2ce48 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 20 Jun 2022 20:29:50 +0200 Subject: [PATCH 536/947] Add state class to Flipr sensors (#73747) --- homeassistant/components/flipr/sensor.py | 5 +++++ tests/components/flipr/test_sensor.py | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index e78031bd5cb..9cf788d7170 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -5,6 +5,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ELECTRIC_POTENTIAL_MILLIVOLT, TEMP_CELSIUS @@ -20,17 +21,20 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Chlorine", native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, icon="mdi:pool", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="ph", name="pH", icon="mdi:pool", + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temperature", name="Water Temp", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="date_time", @@ -42,6 +46,7 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Red OX", native_unit_of_measurement=ELECTRIC_POTENTIAL_MILLIVOLT, icon="mdi:pool", + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/tests/components/flipr/test_sensor.py b/tests/components/flipr/test_sensor.py index c5ab3dc1541..30468064dae 100644 --- a/tests/components/flipr/test_sensor.py +++ b/tests/components/flipr/test_sensor.py @@ -5,6 +5,7 @@ from unittest.mock import patch from flipr_api.exceptions import FliprError from homeassistant.components.flipr.const import CONF_FLIPR_ID, DOMAIN +from homeassistant.components.sensor import ATTR_STATE_CLASS, SensorStateClass from homeassistant.const import ( ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, @@ -62,30 +63,35 @@ async def test_sensors(hass: HomeAssistant) -> None: assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "7.03" state = hass.states.get("sensor.flipr_myfliprid_water_temp") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is TEMP_CELSIUS + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "10.5" state = hass.states.get("sensor.flipr_myfliprid_last_measured") assert state assert state.attributes.get(ATTR_ICON) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.state == "2021-02-15T09:10:32+00:00" state = hass.states.get("sensor.flipr_myfliprid_red_ox") assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "657.58" state = hass.states.get("sensor.flipr_myfliprid_chlorine") assert state assert state.attributes.get(ATTR_ICON) == "mdi:pool" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "mV" + assert state.attributes.get(ATTR_STATE_CLASS) is SensorStateClass.MEASUREMENT assert state.state == "0.23654886" From c98419b031c93d00b83efad673c83221cba32b24 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 20 Jun 2022 13:59:29 -0500 Subject: [PATCH 537/947] Bump soco to 0.28.0 (#73750) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 9144ca559f2..b8506cd2783 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["soco==0.27.1"], + "requirements": ["soco==0.28.0"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "spotify", "zeroconf", "media_source"], "zeroconf": ["_sonos._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index 1ffbe1a76ef..5f512cdd088 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2186,7 +2186,7 @@ smhi-pkg==1.0.15 snapcast==2.1.3 # homeassistant.components.sonos -soco==0.27.1 +soco==0.28.0 # homeassistant.components.solaredge_local solaredge-local==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e4acef811f9..660d41d66eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1443,7 +1443,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.15 # homeassistant.components.sonos -soco==0.27.1 +soco==0.28.0 # homeassistant.components.solaredge solaredge==0.0.2 From 6cf3c0ede29d0af9bb173738d7b800e25c4097cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Jun 2022 21:00:00 +0200 Subject: [PATCH 538/947] Bump home-assistant/builder from 2022.03.1 to 2022.06.1 (#73466) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 664f9b3910e..62cbee9321c 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -135,7 +135,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.03.1 + uses: home-assistant/builder@2022.06.1 with: args: | $BUILD_ARGS \ @@ -200,7 +200,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.03.1 + uses: home-assistant/builder@2022.06.1 with: args: | $BUILD_ARGS \ From 55eca2e2b4ebcf11486749035fd3c7e77ea14b8f Mon Sep 17 00:00:00 2001 From: Nate Harris Date: Mon, 20 Jun 2022 13:04:31 -0600 Subject: [PATCH 539/947] Bump pycketcasts to 1.0.1 (#73262) * Replace outdated pocketcast dependency * Fix pycketcasts in requirements_all.txt * Fix pycketcasts in requirements_all.txt * Fix pycketcasts in requirements_all.txt --- homeassistant/components/pocketcasts/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/pocketcasts/manifest.json b/homeassistant/components/pocketcasts/manifest.json index f74c77ed3a9..8ee74496447 100644 --- a/homeassistant/components/pocketcasts/manifest.json +++ b/homeassistant/components/pocketcasts/manifest.json @@ -2,7 +2,7 @@ "domain": "pocketcasts", "name": "Pocket Casts", "documentation": "https://www.home-assistant.io/integrations/pocketcasts", - "requirements": ["pycketcasts==1.0.0"], + "requirements": ["pycketcasts==1.0.1"], "codeowners": [], "iot_class": "cloud_polling", "loggers": ["pycketcasts"] diff --git a/requirements_all.txt b/requirements_all.txt index 5f512cdd088..013ef770615 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1411,7 +1411,7 @@ pychannels==1.0.0 pychromecast==12.1.3 # homeassistant.components.pocketcasts -pycketcasts==1.0.0 +pycketcasts==1.0.1 # homeassistant.components.climacell pyclimacell==0.18.2 From 4bc13144995f0a68d24376aac375becce5ca92b7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Jun 2022 00:32:32 +0200 Subject: [PATCH 540/947] Fix REPORT_CONFIG type hint in zha (#73762) Fix REPORT_CONFIG type hint --- homeassistant/components/zha/core/channels/base.py | 2 +- mypy.ini | 12 ------------ script/hassfest/mypy_config.py | 4 ---- 3 files changed, 1 insertion(+), 17 deletions(-) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index c9472af5938..de943ebac16 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -109,7 +109,7 @@ class ChannelStatus(Enum): class ZigbeeChannel(LogMixin): """Base channel for a Zigbee cluster.""" - REPORT_CONFIG: tuple[AttrReportConfig] = () + REPORT_CONFIG: tuple[AttrReportConfig, ...] = () BIND: bool = True # Dict of attributes to read on channel initialization. diff --git a/mypy.ini b/mypy.ini index 27aa6653357..98884d333e7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2997,9 +2997,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.channels.base] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels.closures] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.general] ignore_errors = true @@ -3009,15 +3006,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.channels.hvac] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels.lighting] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.manufacturerspecific] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.measurement] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.security] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index a16faf7f34e..b519e7f2daf 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -148,13 +148,9 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.zha.button", "homeassistant.components.zha.climate", "homeassistant.components.zha.core.channels.base", - "homeassistant.components.zha.core.channels.closures", "homeassistant.components.zha.core.channels.general", "homeassistant.components.zha.core.channels.homeautomation", "homeassistant.components.zha.core.channels.hvac", - "homeassistant.components.zha.core.channels.lighting", - "homeassistant.components.zha.core.channels.manufacturerspecific", - "homeassistant.components.zha.core.channels.measurement", "homeassistant.components.zha.core.channels.security", "homeassistant.components.zha.core.channels.smartenergy", "homeassistant.components.zha.core.device", From b956d125f90531b69392c873244b5a1509d446f7 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Mon, 20 Jun 2022 20:10:59 -0400 Subject: [PATCH 541/947] Fix UniFi Protect write rate sensor (#73759) --- homeassistant/components/unifiprotect/sensor.py | 2 +- tests/components/unifiprotect/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index c30cc7fb80f..48337dc416b 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -171,7 +171,7 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( native_unit_of_measurement=DATA_RATE_BYTES_PER_SECOND, entity_category=EntityCategory.DIAGNOSTIC, state_class=SensorStateClass.MEASUREMENT, - ufp_value="stats.storage.rate", + ufp_value="stats.storage.rate_per_second", precision=2, ), ProtectSensorEntityDescription( diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index dff746c167f..4f2540b28c4 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -159,7 +159,7 @@ async def camera_fixture( camera_obj.stats.video.recording_start = now camera_obj.stats.storage.used = 100.0 camera_obj.stats.storage.used = 100.0 - camera_obj.stats.storage.rate = 100.0 + camera_obj.stats.storage.rate = 0.1 camera_obj.voltage = 20.0 mock_entry.api.bootstrap.reset_objects() From 109d1844b3a6b870cfb13af78627d49a7faa2aeb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 21 Jun 2022 00:22:51 +0000 Subject: [PATCH 542/947] [ci skip] Translation update --- .../components/abode/translations/sv.json | 5 +++++ .../aladdin_connect/translations/sv.json | 11 ++++++++++ .../components/blink/translations/sv.json | 11 ++++++++++ .../components/brunt/translations/sv.json | 11 ++++++++++ .../components/bsblan/translations/sv.json | 7 +++++++ .../components/canary/translations/sv.json | 11 ++++++++++ .../components/control4/translations/sv.json | 11 ++++++++++ .../components/deluge/translations/sv.json | 11 ++++++++++ .../devolo_home_control/translations/sv.json | 5 +++++ .../components/dexcom/translations/sv.json | 11 ++++++++++ .../eight_sleep/translations/sv.json | 12 +++++++++++ .../components/elkm1/translations/sv.json | 12 +++++++++++ .../components/elmax/translations/sv.json | 11 ++++++++++ .../components/fibaro/translations/sv.json | 11 ++++++++++ .../fireservicerota/translations/sv.json | 11 ++++++++++ .../components/flo/translations/sv.json | 11 ++++++++++ .../components/foscam/translations/sv.json | 11 ++++++++++ .../components/fritz/translations/sv.json | 21 +++++++++++++++++++ .../components/fritzbox/translations/sv.json | 5 +++++ .../fritzbox_callmonitor/translations/sv.json | 11 ++++++++++ .../components/generic/translations/sv.json | 16 ++++++++++++++ .../components/gogogate2/translations/sv.json | 11 ++++++++++ .../growatt_server/translations/sv.json | 11 ++++++++++ .../components/honeywell/translations/sv.json | 11 ++++++++++ .../huisbaasje/translations/sv.json | 7 +++++++ .../hvv_departures/translations/sv.json | 9 ++++++++ .../components/insteon/translations/sv.json | 16 ++++++++++++++ .../intellifire/translations/sv.json | 7 +++++++ .../components/iotawatt/translations/sv.json | 11 ++++++++++ .../components/jellyfin/translations/sv.json | 11 ++++++++++ .../components/kmtronic/translations/sv.json | 11 ++++++++++ .../components/kodi/translations/sv.json | 11 ++++++++++ .../litterrobot/translations/sv.json | 11 ++++++++++ .../components/meater/translations/sv.json | 3 +++ .../components/mill/translations/sv.json | 5 +++++ .../components/mjpeg/translations/sv.json | 20 ++++++++++++++++++ .../components/motioneye/translations/sv.json | 11 ++++++++++ .../components/nam/translations/sv.json | 10 +++++++++ .../components/netgear/translations/sv.json | 11 ++++++++++ .../components/nzbget/translations/sv.json | 11 ++++++++++ .../components/octoprint/translations/sv.json | 3 ++- .../components/omnilogic/translations/sv.json | 11 ++++++++++ .../components/onvif/translations/sv.json | 5 +++++ .../components/overkiz/translations/sv.json | 7 +++++++ .../components/picnic/translations/sv.json | 11 ++++++++++ .../plum_lightpad/translations/sv.json | 11 ++++++++++ .../components/prosegur/translations/sv.json | 16 ++++++++++++++ .../components/qnap_qsw/translations/sv.json | 7 +++++++ .../components/renault/translations/sv.json | 11 ++++++++++ .../components/ridwell/translations/sv.json | 11 ++++++++++ .../components/risco/translations/sv.json | 11 ++++++++++ .../ruckus_unleashed/translations/sv.json | 11 ++++++++++ .../components/scrape/translations/sv.json | 20 ++++++++++++++++++ .../components/sharkiq/translations/sv.json | 5 +++++ .../components/shelly/translations/sv.json | 11 ++++++++++ .../components/slack/translations/sv.json | 11 ++++++++++ .../components/sleepiq/translations/sv.json | 11 ++++++++++ .../smart_meter_texas/translations/sv.json | 11 ++++++++++ .../components/spider/translations/sv.json | 11 ++++++++++ .../squeezebox/translations/sv.json | 11 ++++++++++ .../components/subaru/translations/sv.json | 11 ++++++++++ .../surepetcare/translations/sv.json | 11 ++++++++++ .../synology_dsm/translations/sv.json | 5 +++++ .../components/tile/translations/sv.json | 11 ++++++++++ .../transmission/translations/ca.json | 10 ++++++++- .../transmission/translations/et.json | 10 ++++++++- .../transmission/translations/fr.json | 10 ++++++++- .../transmission/translations/hu.json | 10 ++++++++- .../unifiprotect/translations/sv.json | 11 ++++++++++ .../components/upcloud/translations/sv.json | 11 ++++++++++ .../components/venstar/translations/sv.json | 11 ++++++++++ .../components/vicare/translations/sv.json | 11 ++++++++++ .../components/wallbox/translations/sv.json | 16 ++++++++++++++ .../components/watttime/translations/sv.json | 11 ++++++++++ .../components/whirlpool/translations/sv.json | 11 ++++++++++ .../xiaomi_miio/translations/sv.json | 3 ++- .../yale_smart_alarm/translations/sv.json | 16 ++++++++++++++ .../zoneminder/translations/sv.json | 1 + 78 files changed, 804 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/aladdin_connect/translations/sv.json create mode 100644 homeassistant/components/blink/translations/sv.json create mode 100644 homeassistant/components/brunt/translations/sv.json create mode 100644 homeassistant/components/canary/translations/sv.json create mode 100644 homeassistant/components/control4/translations/sv.json create mode 100644 homeassistant/components/deluge/translations/sv.json create mode 100644 homeassistant/components/dexcom/translations/sv.json create mode 100644 homeassistant/components/eight_sleep/translations/sv.json create mode 100644 homeassistant/components/elmax/translations/sv.json create mode 100644 homeassistant/components/fibaro/translations/sv.json create mode 100644 homeassistant/components/fireservicerota/translations/sv.json create mode 100644 homeassistant/components/flo/translations/sv.json create mode 100644 homeassistant/components/foscam/translations/sv.json create mode 100644 homeassistant/components/fritz/translations/sv.json create mode 100644 homeassistant/components/fritzbox_callmonitor/translations/sv.json create mode 100644 homeassistant/components/gogogate2/translations/sv.json create mode 100644 homeassistant/components/growatt_server/translations/sv.json create mode 100644 homeassistant/components/honeywell/translations/sv.json create mode 100644 homeassistant/components/iotawatt/translations/sv.json create mode 100644 homeassistant/components/jellyfin/translations/sv.json create mode 100644 homeassistant/components/kmtronic/translations/sv.json create mode 100644 homeassistant/components/kodi/translations/sv.json create mode 100644 homeassistant/components/litterrobot/translations/sv.json create mode 100644 homeassistant/components/mjpeg/translations/sv.json create mode 100644 homeassistant/components/motioneye/translations/sv.json create mode 100644 homeassistant/components/netgear/translations/sv.json create mode 100644 homeassistant/components/nzbget/translations/sv.json create mode 100644 homeassistant/components/omnilogic/translations/sv.json create mode 100644 homeassistant/components/picnic/translations/sv.json create mode 100644 homeassistant/components/plum_lightpad/translations/sv.json create mode 100644 homeassistant/components/prosegur/translations/sv.json create mode 100644 homeassistant/components/renault/translations/sv.json create mode 100644 homeassistant/components/ridwell/translations/sv.json create mode 100644 homeassistant/components/risco/translations/sv.json create mode 100644 homeassistant/components/ruckus_unleashed/translations/sv.json create mode 100644 homeassistant/components/scrape/translations/sv.json create mode 100644 homeassistant/components/shelly/translations/sv.json create mode 100644 homeassistant/components/slack/translations/sv.json create mode 100644 homeassistant/components/sleepiq/translations/sv.json create mode 100644 homeassistant/components/smart_meter_texas/translations/sv.json create mode 100644 homeassistant/components/spider/translations/sv.json create mode 100644 homeassistant/components/squeezebox/translations/sv.json create mode 100644 homeassistant/components/subaru/translations/sv.json create mode 100644 homeassistant/components/surepetcare/translations/sv.json create mode 100644 homeassistant/components/tile/translations/sv.json create mode 100644 homeassistant/components/upcloud/translations/sv.json create mode 100644 homeassistant/components/venstar/translations/sv.json create mode 100644 homeassistant/components/vicare/translations/sv.json create mode 100644 homeassistant/components/wallbox/translations/sv.json create mode 100644 homeassistant/components/watttime/translations/sv.json create mode 100644 homeassistant/components/whirlpool/translations/sv.json create mode 100644 homeassistant/components/yale_smart_alarm/translations/sv.json diff --git a/homeassistant/components/abode/translations/sv.json b/homeassistant/components/abode/translations/sv.json index 9faf392be51..ef61917ad43 100644 --- a/homeassistant/components/abode/translations/sv.json +++ b/homeassistant/components/abode/translations/sv.json @@ -4,6 +4,11 @@ "single_instance_allowed": "Endast en enda konfiguration av Abode \u00e4r till\u00e5ten." }, "step": { + "reauth_confirm": { + "data": { + "username": "E-postadress" + } + }, "user": { "data": { "password": "L\u00f6senord", diff --git a/homeassistant/components/aladdin_connect/translations/sv.json b/homeassistant/components/aladdin_connect/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/aladdin_connect/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/blink/translations/sv.json b/homeassistant/components/blink/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/blink/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/brunt/translations/sv.json b/homeassistant/components/brunt/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/brunt/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/sv.json b/homeassistant/components/bsblan/translations/sv.json index 46631acc69a..2d2d9662e4b 100644 --- a/homeassistant/components/bsblan/translations/sv.json +++ b/homeassistant/components/bsblan/translations/sv.json @@ -2,6 +2,13 @@ "config": { "abort": { "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/canary/translations/sv.json b/homeassistant/components/canary/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/canary/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/control4/translations/sv.json b/homeassistant/components/control4/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/control4/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deluge/translations/sv.json b/homeassistant/components/deluge/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/deluge/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/devolo_home_control/translations/sv.json b/homeassistant/components/devolo_home_control/translations/sv.json index 4479e25b250..13e09780b40 100644 --- a/homeassistant/components/devolo_home_control/translations/sv.json +++ b/homeassistant/components/devolo_home_control/translations/sv.json @@ -7,6 +7,11 @@ "password": "L\u00f6senord", "username": "E-postadress / devolo-ID" } + }, + "zeroconf_confirm": { + "data": { + "username": "E-postadress / devolo-id" + } } } } diff --git a/homeassistant/components/dexcom/translations/sv.json b/homeassistant/components/dexcom/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/dexcom/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/eight_sleep/translations/sv.json b/homeassistant/components/eight_sleep/translations/sv.json new file mode 100644 index 00000000000..78879942876 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/translations/sv.json b/homeassistant/components/elkm1/translations/sv.json index 19d9bb17e4b..e49782de873 100644 --- a/homeassistant/components/elkm1/translations/sv.json +++ b/homeassistant/components/elkm1/translations/sv.json @@ -4,6 +4,18 @@ "cannot_connect": "Det gick inte att ansluta, f\u00f6rs\u00f6k igen", "invalid_auth": "Ogiltig autentisering", "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "discovered_connection": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "manual_connection": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/elmax/translations/sv.json b/homeassistant/components/elmax/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/elmax/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fibaro/translations/sv.json b/homeassistant/components/fibaro/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/fibaro/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fireservicerota/translations/sv.json b/homeassistant/components/fireservicerota/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/fireservicerota/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flo/translations/sv.json b/homeassistant/components/flo/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/flo/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/foscam/translations/sv.json b/homeassistant/components/foscam/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/foscam/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritz/translations/sv.json b/homeassistant/components/fritz/translations/sv.json new file mode 100644 index 00000000000..02cd1e39b0e --- /dev/null +++ b/homeassistant/components/fritz/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/fritzbox/translations/sv.json b/homeassistant/components/fritzbox/translations/sv.json index 2d5586b6bc6..b611b3a9893 100644 --- a/homeassistant/components/fritzbox/translations/sv.json +++ b/homeassistant/components/fritzbox/translations/sv.json @@ -8,6 +8,11 @@ }, "description": "Do vill du konfigurera {name}?" }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd eller IP-adress", diff --git a/homeassistant/components/fritzbox_callmonitor/translations/sv.json b/homeassistant/components/fritzbox_callmonitor/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/fritzbox_callmonitor/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/generic/translations/sv.json b/homeassistant/components/generic/translations/sv.json index 78033d17c6e..96aee85c779 100644 --- a/homeassistant/components/generic/translations/sv.json +++ b/homeassistant/components/generic/translations/sv.json @@ -2,6 +2,22 @@ "config": { "abort": { "no_devices_found": "Inga enheter hittades i n\u00e4tverket" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/gogogate2/translations/sv.json b/homeassistant/components/gogogate2/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/gogogate2/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/growatt_server/translations/sv.json b/homeassistant/components/growatt_server/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/growatt_server/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/honeywell/translations/sv.json b/homeassistant/components/honeywell/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/honeywell/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huisbaasje/translations/sv.json b/homeassistant/components/huisbaasje/translations/sv.json index d52e8b8362c..4a6100815d6 100644 --- a/homeassistant/components/huisbaasje/translations/sv.json +++ b/homeassistant/components/huisbaasje/translations/sv.json @@ -2,6 +2,13 @@ "config": { "error": { "cannot_connect": "Kunde inte ansluta" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/hvv_departures/translations/sv.json b/homeassistant/components/hvv_departures/translations/sv.json index ff31d1c1484..8d17443df9f 100644 --- a/homeassistant/components/hvv_departures/translations/sv.json +++ b/homeassistant/components/hvv_departures/translations/sv.json @@ -1,4 +1,13 @@ { + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, "options": { "step": { "init": { diff --git a/homeassistant/components/insteon/translations/sv.json b/homeassistant/components/insteon/translations/sv.json index b8b6834022c..4992e704f9e 100644 --- a/homeassistant/components/insteon/translations/sv.json +++ b/homeassistant/components/insteon/translations/sv.json @@ -2,6 +2,22 @@ "config": { "abort": { "not_insteon_device": "Uppt\u00e4ckt enhet \u00e4r inte en Insteon-enhet" + }, + "step": { + "hubv2": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "change_hub_config": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/intellifire/translations/sv.json b/homeassistant/components/intellifire/translations/sv.json index be36fec5fe3..afffc97862b 100644 --- a/homeassistant/components/intellifire/translations/sv.json +++ b/homeassistant/components/intellifire/translations/sv.json @@ -5,6 +5,13 @@ }, "error": { "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "api_config": { + "data": { + "username": "E-postadress" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/iotawatt/translations/sv.json b/homeassistant/components/iotawatt/translations/sv.json new file mode 100644 index 00000000000..d1d69759ba6 --- /dev/null +++ b/homeassistant/components/iotawatt/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "auth": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/jellyfin/translations/sv.json b/homeassistant/components/jellyfin/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/jellyfin/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kmtronic/translations/sv.json b/homeassistant/components/kmtronic/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/kmtronic/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/kodi/translations/sv.json b/homeassistant/components/kodi/translations/sv.json new file mode 100644 index 00000000000..36b53053594 --- /dev/null +++ b/homeassistant/components/kodi/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "credentials": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/litterrobot/translations/sv.json b/homeassistant/components/litterrobot/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/litterrobot/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meater/translations/sv.json b/homeassistant/components/meater/translations/sv.json index b920ba3abde..44b4ba96acf 100644 --- a/homeassistant/components/meater/translations/sv.json +++ b/homeassistant/components/meater/translations/sv.json @@ -11,6 +11,9 @@ "description": "Bekr\u00e4fta l\u00f6senordet f\u00f6r Meater Cloud-kontot {username}." }, "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + }, "data_description": { "username": "Meater Cloud anv\u00e4ndarnamn, vanligtvis en e-postadress." } diff --git a/homeassistant/components/mill/translations/sv.json b/homeassistant/components/mill/translations/sv.json index cd5effd10d3..8cef92a32b9 100644 --- a/homeassistant/components/mill/translations/sv.json +++ b/homeassistant/components/mill/translations/sv.json @@ -1,6 +1,11 @@ { "config": { "step": { + "cloud": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "connection_type": "V\u00e4lj anslutningstyp" diff --git a/homeassistant/components/mjpeg/translations/sv.json b/homeassistant/components/mjpeg/translations/sv.json new file mode 100644 index 00000000000..291fbedbcfb --- /dev/null +++ b/homeassistant/components/mjpeg/translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/motioneye/translations/sv.json b/homeassistant/components/motioneye/translations/sv.json new file mode 100644 index 00000000000..8fd6e00680b --- /dev/null +++ b/homeassistant/components/motioneye/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "admin_username": "Admin Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/translations/sv.json b/homeassistant/components/nam/translations/sv.json index 15a583f12a2..9f12ea5a385 100644 --- a/homeassistant/components/nam/translations/sv.json +++ b/homeassistant/components/nam/translations/sv.json @@ -8,6 +8,16 @@ "unknown": "Ov\u00e4ntat fel" }, "step": { + "credentials": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd" diff --git a/homeassistant/components/netgear/translations/sv.json b/homeassistant/components/netgear/translations/sv.json new file mode 100644 index 00000000000..2672bc03eef --- /dev/null +++ b/homeassistant/components/netgear/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn (frivilligt)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nzbget/translations/sv.json b/homeassistant/components/nzbget/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/nzbget/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/sv.json b/homeassistant/components/octoprint/translations/sv.json index e17feb4bbe6..dad4741f152 100644 --- a/homeassistant/components/octoprint/translations/sv.json +++ b/homeassistant/components/octoprint/translations/sv.json @@ -3,7 +3,8 @@ "step": { "user": { "data": { - "ssl": "Anv\u00e4nd SSL" + "ssl": "Anv\u00e4nd SSL", + "username": "Anv\u00e4ndarnamn" } } } diff --git a/homeassistant/components/omnilogic/translations/sv.json b/homeassistant/components/omnilogic/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/omnilogic/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/onvif/translations/sv.json b/homeassistant/components/onvif/translations/sv.json index 626daebc7b2..f2fd2e8429e 100644 --- a/homeassistant/components/onvif/translations/sv.json +++ b/homeassistant/components/onvif/translations/sv.json @@ -1,6 +1,11 @@ { "config": { "step": { + "configure": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "configure_profile": { "title": "Konfigurera Profiler" } diff --git a/homeassistant/components/overkiz/translations/sv.json b/homeassistant/components/overkiz/translations/sv.json index 5ba4512fb35..c825e3cd616 100644 --- a/homeassistant/components/overkiz/translations/sv.json +++ b/homeassistant/components/overkiz/translations/sv.json @@ -3,6 +3,13 @@ "abort": { "reauth_successful": "\u00c5terautentisering lyckades", "reauth_wrong_account": "Du kan bara \u00e5terautentisera denna post med samma Overkiz-konto och hub" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/picnic/translations/sv.json b/homeassistant/components/picnic/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/picnic/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plum_lightpad/translations/sv.json b/homeassistant/components/plum_lightpad/translations/sv.json new file mode 100644 index 00000000000..26e9f2d6a49 --- /dev/null +++ b/homeassistant/components/plum_lightpad/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/prosegur/translations/sv.json b/homeassistant/components/prosegur/translations/sv.json new file mode 100644 index 00000000000..8a60ea1a5dc --- /dev/null +++ b/homeassistant/components/prosegur/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/qnap_qsw/translations/sv.json b/homeassistant/components/qnap_qsw/translations/sv.json index 416ef964cf3..0234fcb9860 100644 --- a/homeassistant/components/qnap_qsw/translations/sv.json +++ b/homeassistant/components/qnap_qsw/translations/sv.json @@ -3,6 +3,13 @@ "abort": { "already_configured": "Enheten \u00e4r redan konfigurerad", "invalid_id": "Enheten returnerade ett ogiltigt unikt ID" + }, + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/renault/translations/sv.json b/homeassistant/components/renault/translations/sv.json new file mode 100644 index 00000000000..26e9f2d6a49 --- /dev/null +++ b/homeassistant/components/renault/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ridwell/translations/sv.json b/homeassistant/components/ridwell/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/ridwell/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/risco/translations/sv.json b/homeassistant/components/risco/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/risco/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ruckus_unleashed/translations/sv.json b/homeassistant/components/ruckus_unleashed/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/ruckus_unleashed/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/sv.json b/homeassistant/components/scrape/translations/sv.json new file mode 100644 index 00000000000..291fbedbcfb --- /dev/null +++ b/homeassistant/components/scrape/translations/sv.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sharkiq/translations/sv.json b/homeassistant/components/sharkiq/translations/sv.json index 75f4175c9af..cae80c6c25f 100644 --- a/homeassistant/components/sharkiq/translations/sv.json +++ b/homeassistant/components/sharkiq/translations/sv.json @@ -9,6 +9,11 @@ "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } } } } diff --git a/homeassistant/components/shelly/translations/sv.json b/homeassistant/components/shelly/translations/sv.json new file mode 100644 index 00000000000..36b53053594 --- /dev/null +++ b/homeassistant/components/shelly/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "credentials": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/slack/translations/sv.json b/homeassistant/components/slack/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/slack/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sleepiq/translations/sv.json b/homeassistant/components/sleepiq/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/sleepiq/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smart_meter_texas/translations/sv.json b/homeassistant/components/smart_meter_texas/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/smart_meter_texas/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spider/translations/sv.json b/homeassistant/components/spider/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/spider/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/squeezebox/translations/sv.json b/homeassistant/components/squeezebox/translations/sv.json new file mode 100644 index 00000000000..8dbe191c902 --- /dev/null +++ b/homeassistant/components/squeezebox/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "edit": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/subaru/translations/sv.json b/homeassistant/components/subaru/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/subaru/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/surepetcare/translations/sv.json b/homeassistant/components/surepetcare/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/surepetcare/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/synology_dsm/translations/sv.json b/homeassistant/components/synology_dsm/translations/sv.json index 04814596518..012d092de41 100644 --- a/homeassistant/components/synology_dsm/translations/sv.json +++ b/homeassistant/components/synology_dsm/translations/sv.json @@ -12,6 +12,11 @@ }, "description": "Do vill du konfigurera {name} ({host})?" }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { "data": { "host": "V\u00e4rd", diff --git a/homeassistant/components/tile/translations/sv.json b/homeassistant/components/tile/translations/sv.json new file mode 100644 index 00000000000..26e9f2d6a49 --- /dev/null +++ b/homeassistant/components/tile/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/translations/ca.json b/homeassistant/components/transmission/translations/ca.json index 4049cca3840..235e05bb78a 100644 --- a/homeassistant/components/transmission/translations/ca.json +++ b/homeassistant/components/transmission/translations/ca.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "El dispositiu ja est\u00e0 configurat" + "already_configured": "El dispositiu ja est\u00e0 configurat", + "reauth_successful": "Re-autenticaci\u00f3 realitzada correctament" }, "error": { "cannot_connect": "Ha fallat la connexi\u00f3", @@ -9,6 +10,13 @@ "name_exists": "Nom ja existeix" }, "step": { + "reauth_confirm": { + "data": { + "password": "Contrasenya" + }, + "description": "La contrasenya de {username} \u00e9s inv\u00e0lida.", + "title": "Reautenticaci\u00f3 de la integraci\u00f3" + }, "user": { "data": { "host": "Amfitri\u00f3", diff --git a/homeassistant/components/transmission/translations/et.json b/homeassistant/components/transmission/translations/et.json index 1329444f7cf..745ef1030af 100644 --- a/homeassistant/components/transmission/translations/et.json +++ b/homeassistant/components/transmission/translations/et.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + "already_configured": "Seade on juba h\u00e4\u00e4lestatud", + "reauth_successful": "Taastuvastamine \u00f5nnestus" }, "error": { "cannot_connect": "\u00dchendamine nurjus", @@ -9,6 +10,13 @@ "name_exists": "Nimi on juba olemas" }, "step": { + "reauth_confirm": { + "data": { + "password": "Salas\u00f5na" + }, + "description": "Kasutaja {username} salas\u00f5na on kehtetu", + "title": "Taastuvasta sidumine" + }, "user": { "data": { "host": "", diff --git a/homeassistant/components/transmission/translations/fr.json b/homeassistant/components/transmission/translations/fr.json index f027b6909e8..539764ee64f 100644 --- a/homeassistant/components/transmission/translations/fr.json +++ b/homeassistant/components/transmission/translations/fr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "reauth_successful": "La r\u00e9-authentification a r\u00e9ussi" }, "error": { "cannot_connect": "\u00c9chec de connexion", @@ -9,6 +10,13 @@ "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9" }, "step": { + "reauth_confirm": { + "data": { + "password": "Mot de passe" + }, + "description": "Le mot de passe pour {username} n'est pas valide.", + "title": "R\u00e9-authentifier l'int\u00e9gration" + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/transmission/translations/hu.json b/homeassistant/components/transmission/translations/hu.json index 79a60dc2b5b..1bd7129ed6b 100644 --- a/homeassistant/components/transmission/translations/hu.json +++ b/homeassistant/components/transmission/translations/hu.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van", + "reauth_successful": "Az \u00fajrahiteles\u00edt\u00e9s sikeres volt." }, "error": { "cannot_connect": "Sikertelen csatlakoz\u00e1s", @@ -9,6 +10,13 @@ "name_exists": "A n\u00e9v m\u00e1r foglalt" }, "step": { + "reauth_confirm": { + "data": { + "password": "Jelsz\u00f3" + }, + "description": "{username} jelszava \u00e9rv\u00e9nytelen.", + "title": "Integr\u00e1ci\u00f3 \u00fajrahiteles\u00edt\u00e9se" + }, "user": { "data": { "host": "C\u00edm", diff --git a/homeassistant/components/unifiprotect/translations/sv.json b/homeassistant/components/unifiprotect/translations/sv.json index e2bfaa9118c..702dcbecdb7 100644 --- a/homeassistant/components/unifiprotect/translations/sv.json +++ b/homeassistant/components/unifiprotect/translations/sv.json @@ -2,9 +2,20 @@ "config": { "step": { "discovery_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + }, "title": "UniFi Protect uppt\u00e4ckt" }, + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + }, "description": "Du beh\u00f6ver en lokal anv\u00e4ndare skapad i din UniFi OS-konsol f\u00f6r att logga in med. Ubiquiti Cloud-anv\u00e4ndare kommer inte att fungera. F\u00f6r mer information: {local_user_documentation_url}" } } diff --git a/homeassistant/components/upcloud/translations/sv.json b/homeassistant/components/upcloud/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/upcloud/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/venstar/translations/sv.json b/homeassistant/components/venstar/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/venstar/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vicare/translations/sv.json b/homeassistant/components/vicare/translations/sv.json new file mode 100644 index 00000000000..26e9f2d6a49 --- /dev/null +++ b/homeassistant/components/vicare/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-postadress" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/wallbox/translations/sv.json b/homeassistant/components/wallbox/translations/sv.json new file mode 100644 index 00000000000..8a60ea1a5dc --- /dev/null +++ b/homeassistant/components/wallbox/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/watttime/translations/sv.json b/homeassistant/components/watttime/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/watttime/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/sv.json b/homeassistant/components/whirlpool/translations/sv.json new file mode 100644 index 00000000000..23c825f256f --- /dev/null +++ b/homeassistant/components/whirlpool/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/xiaomi_miio/translations/sv.json b/homeassistant/components/xiaomi_miio/translations/sv.json index 17b92cb5058..20e4d8c6d07 100644 --- a/homeassistant/components/xiaomi_miio/translations/sv.json +++ b/homeassistant/components/xiaomi_miio/translations/sv.json @@ -3,7 +3,8 @@ "step": { "cloud": { "data": { - "cloud_password": "Molnl\u00f6senord" + "cloud_password": "Molnl\u00f6senord", + "cloud_username": "Molnanv\u00e4ndarnamn" } } } diff --git a/homeassistant/components/yale_smart_alarm/translations/sv.json b/homeassistant/components/yale_smart_alarm/translations/sv.json new file mode 100644 index 00000000000..8a60ea1a5dc --- /dev/null +++ b/homeassistant/components/yale_smart_alarm/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + }, + "user": { + "data": { + "username": "Anv\u00e4ndarnamn" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zoneminder/translations/sv.json b/homeassistant/components/zoneminder/translations/sv.json index 37fd73d32f0..4f0da20207f 100644 --- a/homeassistant/components/zoneminder/translations/sv.json +++ b/homeassistant/components/zoneminder/translations/sv.json @@ -9,6 +9,7 @@ "step": { "user": { "data": { + "username": "Anv\u00e4ndarnamn", "verify_ssl": "Verifiera SSL-certifikat" } } From 3851c7b4b4cafab4ead7aac67b37ce07823ca50c Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Mon, 20 Jun 2022 23:09:13 -0400 Subject: [PATCH 543/947] Bumps version of pyunifiprotect to 4.0.4 (#73722) --- .../components/unifiprotect/__init__.py | 5 +- .../components/unifiprotect/config_flow.py | 5 +- homeassistant/components/unifiprotect/data.py | 19 +- .../components/unifiprotect/manifest.json | 2 +- .../components/unifiprotect/services.py | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifiprotect/conftest.py | 105 +-- .../fixtures/sample_bootstrap.json | 633 ++++++++++++++++++ .../unifiprotect/test_binary_sensor.py | 21 +- tests/components/unifiprotect/test_button.py | 2 +- tests/components/unifiprotect/test_camera.py | 25 +- tests/components/unifiprotect/test_init.py | 6 +- tests/components/unifiprotect/test_light.py | 2 +- tests/components/unifiprotect/test_lock.py | 2 +- .../unifiprotect/test_media_player.py | 2 +- tests/components/unifiprotect/test_number.py | 21 +- tests/components/unifiprotect/test_select.py | 17 +- tests/components/unifiprotect/test_sensor.py | 17 +- .../components/unifiprotect/test_services.py | 15 +- tests/components/unifiprotect/test_switch.py | 18 +- 21 files changed, 770 insertions(+), 155 deletions(-) create mode 100644 tests/components/unifiprotect/fixtures/sample_bootstrap.json diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index a24777f9ecd..c83221b0ccf 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -7,7 +7,8 @@ import logging from aiohttp import CookieJar from aiohttp.client_exceptions import ServerDisconnectedError -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient +from pyunifiprotect import ProtectApiClient +from pyunifiprotect.exceptions import ClientError, NotAuthorized from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -68,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: nvr_info = await protect.get_nvr() except NotAuthorized as err: raise ConfigEntryAuthFailed(err) from err - except (asyncio.TimeoutError, NvrError, ServerDisconnectedError) as err: + except (asyncio.TimeoutError, ClientError, ServerDisconnectedError) as err: raise ConfigEntryNotReady from err if nvr_info.version < MIN_REQUIRED_PROTECT_V: diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index e183edf4259..9cd15c4e3c2 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -6,8 +6,9 @@ import logging from typing import Any from aiohttp import CookieJar -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient +from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import NVR +from pyunifiprotect.exceptions import ClientError, NotAuthorized from unifi_discovery import async_console_is_alive import voluptuous as vol @@ -253,7 +254,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except NotAuthorized as ex: _LOGGER.debug(ex) errors[CONF_PASSWORD] = "invalid_auth" - except NvrError as ex: + except ClientError as ex: _LOGGER.debug(ex) errors["base"] = "cannot_connect" else: diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 1e9729f7930..2a30e18d586 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -6,7 +6,7 @@ from datetime import timedelta import logging from typing import Any -from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient +from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( Bootstrap, Event, @@ -15,6 +15,7 @@ from pyunifiprotect.data import ( WSSubscriptionMessage, ) from pyunifiprotect.data.base import ProtectAdoptableDeviceModel +from pyunifiprotect.exceptions import ClientError, NotAuthorized from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback @@ -100,23 +101,27 @@ class ProtectData: try: updates = await self.api.update(force=force) - except NvrError: - if self.last_update_success: - _LOGGER.exception("Error while updating") - self.last_update_success = False - # manually trigger update to mark entities unavailable - self._async_process_updates(self.api.bootstrap) except NotAuthorized: await self.async_stop() _LOGGER.exception("Reauthentication required") self._entry.async_start_reauth(self._hass) self.last_update_success = False + except ClientError: + if self.last_update_success: + _LOGGER.exception("Error while updating") + self.last_update_success = False + # manually trigger update to mark entities unavailable + self._async_process_updates(self.api.bootstrap) else: self.last_update_success = True self._async_process_updates(updates) @callback def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: + # removed packets are not processed yet + if message.new_obj is None: # pragma: no cover + return + if message.new_obj.model in DEVICES_WITH_ENTITIES: self.async_signal_device_id_update(message.new_obj.id) # trigger update for all Cameras with LCD screens when NVR Doorbell settings updates diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 2554d12c866..dfa748835f5 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==3.9.2", "unifi-discovery==1.1.4"], + "requirements": ["pyunifiprotect==4.0.4", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/homeassistant/components/unifiprotect/services.py b/homeassistant/components/unifiprotect/services.py index 3b7b3db026f..915c51b6c0a 100644 --- a/homeassistant/components/unifiprotect/services.py +++ b/homeassistant/components/unifiprotect/services.py @@ -8,7 +8,7 @@ from typing import Any, cast from pydantic import ValidationError from pyunifiprotect.api import ProtectApiClient from pyunifiprotect.data import Chime -from pyunifiprotect.exceptions import BadRequest +from pyunifiprotect.exceptions import ClientError import voluptuous as vol from homeassistant.components.binary_sensor import BinarySensorDeviceClass @@ -100,7 +100,7 @@ async def _async_service_call_nvr( await asyncio.gather( *(getattr(i.bootstrap.nvr, method)(*args, **kwargs) for i in instances) ) - except (BadRequest, ValidationError) as err: + except (ClientError, ValidationError) as err: raise HomeAssistantError(str(err)) from err diff --git a/requirements_all.txt b/requirements_all.txt index 013ef770615..ece350a33c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.2 +pyunifiprotect==4.0.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 660d41d66eb..d7c81558ac5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==3.9.2 +pyunifiprotect==4.0.4 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 9892bcc3ec6..adc69cc8bf9 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -13,6 +13,7 @@ from unittest.mock import AsyncMock, Mock, patch import pytest from pyunifiprotect.data import ( NVR, + Bootstrap, Camera, Chime, Doorlock, @@ -39,69 +40,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture MAC_ADDR = "aa:bb:cc:dd:ee:ff" -@dataclass -class MockBootstrap: - """Mock for Bootstrap.""" - - nvr: NVR - cameras: dict[str, Any] - lights: dict[str, Any] - sensors: dict[str, Any] - viewers: dict[str, Any] - liveviews: dict[str, Any] - events: dict[str, Any] - doorlocks: dict[str, Any] - chimes: dict[str, Any] - - def reset_objects(self) -> None: - """Reset all devices on bootstrap for tests.""" - self.cameras = {} - self.lights = {} - self.sensors = {} - self.viewers = {} - self.liveviews = {} - self.events = {} - self.doorlocks = {} - self.chimes = {} - - def process_ws_packet(self, msg: WSSubscriptionMessage) -> None: - """Fake process method for tests.""" - pass - - def unifi_dict(self) -> dict[str, Any]: - """Return UniFi formatted dict representation of the NVR.""" - return { - "nvr": self.nvr.unifi_dict(), - "cameras": [c.unifi_dict() for c in self.cameras.values()], - "lights": [c.unifi_dict() for c in self.lights.values()], - "sensors": [c.unifi_dict() for c in self.sensors.values()], - "viewers": [c.unifi_dict() for c in self.viewers.values()], - "liveviews": [c.unifi_dict() for c in self.liveviews.values()], - "doorlocks": [c.unifi_dict() for c in self.doorlocks.values()], - "chimes": [c.unifi_dict() for c in self.chimes.values()], - } - - def get_device_from_mac(self, mac: str) -> ProtectAdoptableDeviceModel | None: - """Return device for MAC address.""" - - mac = mac.lower().replace(":", "").replace("-", "").replace("_", "") - - all_devices = ( - self.cameras.values(), - self.lights.values(), - self.sensors.values(), - self.viewers.values(), - self.liveviews.values(), - self.doorlocks.values(), - self.chimes.values(), - ) - for devices in all_devices: - for device in devices: - if device.mac.lower() == mac: - return device - return None - - @dataclass class MockEntityFixture: """Mock for NVR.""" @@ -155,27 +93,42 @@ def mock_old_nvr_fixture(): @pytest.fixture(name="mock_bootstrap") def mock_bootstrap_fixture(mock_nvr: NVR): """Mock Bootstrap fixture.""" - return MockBootstrap( - nvr=mock_nvr, - cameras={}, - lights={}, - sensors={}, - viewers={}, - liveviews={}, - events={}, - doorlocks={}, - chimes={}, - ) + data = json.loads(load_fixture("sample_bootstrap.json", integration=DOMAIN)) + data["nvr"] = mock_nvr + data["cameras"] = [] + data["lights"] = [] + data["sensors"] = [] + data["viewers"] = [] + data["liveviews"] = [] + data["events"] = [] + data["doorlocks"] = [] + data["chimes"] = [] + + return Bootstrap.from_unifi_dict(**data) + + +def reset_objects(bootstrap: Bootstrap): + """Reset bootstrap objects.""" + + bootstrap.cameras = {} + bootstrap.lights = {} + bootstrap.sensors = {} + bootstrap.viewers = {} + bootstrap.liveviews = {} + bootstrap.events = {} + bootstrap.doorlocks = {} + bootstrap.chimes = {} @pytest.fixture -def mock_client(mock_bootstrap: MockBootstrap): +def mock_client(mock_bootstrap: Bootstrap): """Mock ProtectApiClient for testing.""" client = Mock() client.bootstrap = mock_bootstrap - nvr = mock_bootstrap.nvr + nvr = client.bootstrap.nvr nvr._api = client + client.bootstrap._api = client client.base_url = "https://127.0.0.1" client.connection_host = IPv4Address("127.0.0.1") diff --git a/tests/components/unifiprotect/fixtures/sample_bootstrap.json b/tests/components/unifiprotect/fixtures/sample_bootstrap.json new file mode 100644 index 00000000000..2b7326831eb --- /dev/null +++ b/tests/components/unifiprotect/fixtures/sample_bootstrap.json @@ -0,0 +1,633 @@ +{ + "authUserId": "4c5f03a8c8bd48ad8e066285", + "accessKey": "8528571101220:340ff666bffb58bc404b859a:8f3f41a7b180b1ff7463fe4f7f13b528ac3d28668f25d0ecaa30c8e7888559e782b38d4335b40861030b75126eb7cea8385f3f9ab59dfa9a993e50757c277053", + "users": [ + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": true, + "enableNotifications": false, + "settings": { + "flags": {} + }, + "groups": ["b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "custom", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [ + { + "inheritFromParent": true, + "motion": [], + "person": [], + "vehicle": [], + "camera": "61b3f5c7033ea703e7000424", + "trigger": { + "when": "always", + "location": "away", + "schedules": [] + } + } + ], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "fe4c12ae2c1348edb7854e2f", + "hasAcceptedInvite": true, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": { + "firstName": "Qpvfly", + "lastName": "Ikjzilt", + "email": "QhoFvCv@example.com", + "profileImg": null, + "user": "fe4c12ae2c1348edb7854e2f", + "id": "9efc4511-4539-4402-9581-51cee8b65cf5", + "cloudId": "9efc4511-4539-4402-9581-51cee8b65cf5", + "name": "Qpvfly Ikjzilt", + "modelKey": "cloudIdentity" + }, + "name": "Qpvfly Ikjzilt", + "firstName": "Qpvfly", + "lastName": "Ikjzilt", + "email": "QhoFvCv@example.com", + "localUsername": "QhoFvCv", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": null, + "groups": ["a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "dcaef9cb8aed05c7db658a46", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Uxqg Wcbz", + "firstName": "Uxqg", + "lastName": "Wcbz", + "email": "epHDEhE@example.com", + "localUsername": "epHDEhE", + "modelKey": "user" + }, + { + "permissions": [ + "liveview:*:d65bb41c14d6aa92bfa4a6d1", + "liveview:*:49bbb5005424a0d35152671a", + "liveview:*:b28c38f1220f6b43f3930dff", + "liveview:*:b9861b533a87ea639fa4d438" + ], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": { + "flags": {}, + "web": { + "dewarp": { + "61ddb66b018e2703e7008c19": { + "dewarp": false, + "state": { + "pan": 0, + "tilt": -1.5707963267948966, + "zoom": 1.5707963267948966, + "panning": 0, + "tilting": 0 + } + } + }, + "liveview.includeGlobal": true, + "elements.events_viewmode": "grid", + "elements.viewmode": "list" + } + }, + "groups": ["b061186823695fb901973177"], + "location": { + "isAway": true, + "latitude": null, + "longitude": null + }, + "alertRules": [], + "notificationsV2": { + "state": "custom", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [ + { + "inheritFromParent": true, + "motion": [], + "camera": "61b3f5c703d2a703e7000427", + "trigger": { + "when": "always", + "location": "away", + "schedules": [] + } + }, + { + "inheritFromParent": true, + "motion": [], + "person": [], + "vehicle": [], + "camera": "61b3f5c7033ea703e7000424", + "trigger": { + "when": "always", + "location": "away", + "schedules": [] + } + } + ], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "4c5f03a8c8bd48ad8e066285", + "hasAcceptedInvite": false, + "allPermissions": [ + "liveview:*:d65bb41c14d6aa92bfa4a6d1", + "liveview:*:49bbb5005424a0d35152671a", + "liveview:*:b28c38f1220f6b43f3930dff", + "liveview:*:b9861b533a87ea639fa4d438", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Ptcmsdo Tfiyoep", + "firstName": "Ptcmsdo", + "lastName": "Tfiyoep", + "email": "EQAoXL@example.com", + "localUsername": "EQAoXL", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": { + "flags": {}, + "web": { + "dewarp": { + "61c4d1db02c82a03e700429c": { + "dewarp": false, + "state": { + "pan": 0, + "tilt": 0, + "zoom": 1.5707963267948966, + "panning": 0, + "tilting": 0 + } + } + }, + "liveview.includeGlobal": true + } + }, + "groups": ["a7f3b2eb71b4c4e56f1f45ac"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "bc3dd633553907952a6fe20d", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*" + ], + "cloudAccount": null, + "name": "Evdxou Zgyv", + "firstName": "Evdxou", + "lastName": "Zgyv", + "email": "FMZuD@example.com", + "localUsername": "FMZuD", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": null, + "groups": ["a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "adec5334b69f56f6a6c47520", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Qpv Elqfgq", + "firstName": "Qpv", + "lastName": "Elqfgq", + "email": "xdr@example.com", + "localUsername": "xdr", + "modelKey": "user" + }, + { + "permissions": [], + "lastLoginIp": null, + "lastLoginTime": null, + "isOwner": false, + "enableNotifications": false, + "settings": null, + "groups": ["a7f3b2eb71b4c4e56f1f45ac", "b061186823695fb901973177"], + "alertRules": [], + "notificationsV2": { + "state": "auto", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "8593657a25b7826a4288b6af", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*", + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "cloudAccount": null, + "name": "Sgpy Ooevsme", + "firstName": "Sgpy", + "lastName": "Ooevsme", + "email": "WQJNT@example.com", + "localUsername": "WQJNT", + "modelKey": "user" + }, + { + "permissions": [], + "isOwner": false, + "enableNotifications": false, + "groups": ["a7f3b2eb71b4c4e56f1f45ac"], + "alertRules": [], + "notificationsV2": { + "state": "off", + "motionNotifications": { + "trigger": { + "when": "inherit", + "location": "away", + "schedules": [] + }, + "cameras": [], + "doorbells": [], + "lights": [], + "doorlocks": [], + "sensors": [] + }, + "systemNotifications": {} + }, + "featureFlags": { + "notificationsV2": true + }, + "id": "abf647aed3650a781ceba13f", + "hasAcceptedInvite": false, + "allPermissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*" + ], + "cloudAccount": null, + "name": "Yiiyq Glx", + "firstName": "Yiiyq", + "lastName": "Glx", + "email": "fBjmm@example.com", + "localUsername": "fBjmm", + "modelKey": "user" + } + ], + "groups": [ + { + "name": "Kubw Xnbb", + "permissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "nvr:write,delete:*", + "group:create,read,write,delete:*", + "user:create,read,write,delete:*", + "schedule:create,read,write,delete:*", + "legacyUFV:read,write,delete:*", + "bridge:create,read,write,delete:*", + "camera:create,read,write,delete,readmedia,deletemedia:*", + "light:create,read,write,delete:*", + "sensor:create,read,write,delete:*", + "doorlock:create,read,write,delete:*", + "viewer:create,read,write,delete:*", + "display:create,read,write,delete:*", + "chime:create,read,write,delete:*" + ], + "type": "preset", + "isDefault": true, + "id": "b061186823695fb901973177", + "modelKey": "group" + }, + { + "name": "Pmbrvp Wyzqs", + "permissions": [ + "nvr:read:*", + "liveview:create", + "user:read,write,delete:$", + "bridge:read:*", + "camera:read,readmedia:*", + "doorlock:read:*", + "light:read:*", + "sensor:read:*", + "viewer:read:*", + "display:read:*", + "chime:read:*" + ], + "type": "preset", + "isDefault": false, + "id": "a7f3b2eb71b4c4e56f1f45ac", + "modelKey": "group" + } + ], + "schedules": [], + "legacyUFVs": [], + "lastUpdateId": "ebf25bac-d5a1-4f1d-a0ee-74c15981eb70", + "displays": [], + "bridges": [ + { + "mac": "A28D0DB15AE1", + "host": "192.168.231.68", + "connectionHost": "192.168.102.63", + "type": "UFP-UAP-B", + "name": "Sffde Gxcaqe", + "upSince": 1639807977891, + "uptime": 3247782, + "lastSeen": 1643055759891, + "connectedSince": 1642374159304, + "state": "CONNECTED", + "hardwareRevision": 19, + "firmwareVersion": "0.3.1", + "latestFirmwareVersion": null, + "firmwareBuild": null, + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "wiredConnectionState": { + "phyRate": null + }, + "id": "1f5a055254fb9169d7536fb9", + "isConnected": true, + "platform": "mt7621", + "modelKey": "bridge" + }, + { + "mac": "C65C557CCA95", + "host": "192.168.87.68", + "connectionHost": "192.168.102.63", + "type": "UFP-UAP-B", + "name": "Axiwj Bbd", + "upSince": 1641257260772, + "uptime": null, + "lastSeen": 1643052750862, + "connectedSince": 1643052754695, + "state": "CONNECTED", + "hardwareRevision": 19, + "firmwareVersion": "0.3.1", + "latestFirmwareVersion": null, + "firmwareBuild": null, + "isUpdating": false, + "isAdopting": false, + "isAdopted": true, + "isAdoptedByOther": false, + "isProvisioned": false, + "isRebooting": false, + "isSshEnabled": false, + "canAdopt": false, + "isAttemptingToConnect": false, + "wiredConnectionState": { + "phyRate": null + }, + "id": "e6901e3665a4c0eab0d9c1a5", + "isConnected": true, + "platform": "mt7621", + "modelKey": "bridge" + } + ] +} diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 8fbaf61aca1..644665cc659 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -36,6 +36,7 @@ from .conftest import ( MockEntityFixture, assert_entity_counts, ids_from_device_description, + reset_objects, ) @@ -51,7 +52,7 @@ async def camera_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -62,7 +63,7 @@ async def camera_fixture( camera_obj.is_dark = False camera_obj.is_motion_detected = False - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, @@ -87,14 +88,14 @@ async def light_fixture( # disable pydantic validation so mocking can happen Light.__config__.validate_assignment = False - light_obj = mock_light.copy(deep=True) + light_obj = mock_light.copy() light_obj._api = mock_entry.api light_obj.name = "Test Light" light_obj.is_dark = False light_obj.is_pir_motion_detected = False light_obj.last_motion = now - timedelta(hours=1) - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.lights = { light_obj.id: light_obj, @@ -119,7 +120,7 @@ async def camera_none_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -129,7 +130,7 @@ async def camera_none_fixture( camera_obj.is_dark = False camera_obj.is_motion_detected = False - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, @@ -157,7 +158,7 @@ async def sensor_fixture( # disable pydantic validation so mocking can happen Sensor.__config__.validate_assignment = False - sensor_obj = mock_sensor.copy(deep=True) + sensor_obj = mock_sensor.copy() sensor_obj._api = mock_entry.api sensor_obj.name = "Test Sensor" sensor_obj.mount_type = MountType.DOOR @@ -170,7 +171,7 @@ async def sensor_fixture( sensor_obj.alarm_triggered_at = now - timedelta(hours=1) sensor_obj.tampering_detected_at = None - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.sensors = { sensor_obj.id: sensor_obj, @@ -198,7 +199,7 @@ async def sensor_none_fixture( # disable pydantic validation so mocking can happen Sensor.__config__.validate_assignment = False - sensor_obj = mock_sensor.copy(deep=True) + sensor_obj = mock_sensor.copy() sensor_obj._api = mock_entry.api sensor_obj.name = "Test Sensor" sensor_obj.mount_type = MountType.LEAK @@ -206,7 +207,7 @@ async def sensor_none_fixture( sensor_obj.alarm_settings.is_enabled = False sensor_obj.tampering_detected_at = None - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.sensors = { sensor_obj.id: sensor_obj, diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index 5b7122f6227..9a1c7009660 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -21,7 +21,7 @@ async def chime_fixture( ): """Fixture for a single camera for testing the button platform.""" - chime_obj = mock_chime.copy(deep=True) + chime_obj = mock_chime.copy() chime_obj._api = mock_entry.api chime_obj.name = "Test Chime" diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 2f8d2607da0..03b52c7e52e 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -52,7 +52,7 @@ async def camera_fixture( # disable pydantic validation so mocking can happen ProtectCamera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -83,7 +83,7 @@ async def camera_package_fixture( ): """Fixture for a single camera for testing the camera platform.""" - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -95,7 +95,7 @@ async def camera_package_fixture( camera_obj.channels[0].rtsp_alias = "test_high_alias" camera_obj.channels[1].is_rtsp_enabled = False camera_obj.channels[2].is_rtsp_enabled = False - package_channel = camera_obj.channels[0].copy(deep=True) + package_channel = camera_obj.channels[0].copy() package_channel.is_rtsp_enabled = False package_channel.name = "Package Camera" package_channel.id = 3 @@ -246,8 +246,9 @@ async def test_basic_setup( ): """Test working setup of unifiprotect entry.""" - camera_high_only = mock_camera.copy(deep=True) + camera_high_only = mock_camera.copy() camera_high_only._api = mock_entry.api + camera_high_only.channels = [c.copy() for c in mock_camera.channels] camera_high_only.channels[0]._api = mock_entry.api camera_high_only.channels[1]._api = mock_entry.api camera_high_only.channels[2]._api = mock_entry.api @@ -259,8 +260,9 @@ async def test_basic_setup( camera_high_only.channels[2].is_rtsp_enabled = False regenerate_device_ids(camera_high_only) - camera_medium_only = mock_camera.copy(deep=True) + camera_medium_only = mock_camera.copy() camera_medium_only._api = mock_entry.api + camera_medium_only.channels = [c.copy() for c in mock_camera.channels] camera_medium_only.channels[0]._api = mock_entry.api camera_medium_only.channels[1]._api = mock_entry.api camera_medium_only.channels[2]._api = mock_entry.api @@ -272,8 +274,9 @@ async def test_basic_setup( camera_medium_only.channels[2].is_rtsp_enabled = False regenerate_device_ids(camera_medium_only) - camera_all_channels = mock_camera.copy(deep=True) + camera_all_channels = mock_camera.copy() camera_all_channels._api = mock_entry.api + camera_all_channels.channels = [c.copy() for c in mock_camera.channels] camera_all_channels.channels[0]._api = mock_entry.api camera_all_channels.channels[1]._api = mock_entry.api camera_all_channels.channels[2]._api = mock_entry.api @@ -289,8 +292,9 @@ async def test_basic_setup( camera_all_channels.channels[2].rtsp_alias = "test_low_alias" regenerate_device_ids(camera_all_channels) - camera_no_channels = mock_camera.copy(deep=True) + camera_no_channels = mock_camera.copy() camera_no_channels._api = mock_entry.api + camera_no_channels.channels = [c.copy() for c in camera_no_channels.channels] camera_no_channels.channels[0]._api = mock_entry.api camera_no_channels.channels[1]._api = mock_entry.api camera_no_channels.channels[2]._api = mock_entry.api @@ -301,8 +305,9 @@ async def test_basic_setup( camera_no_channels.channels[2].is_rtsp_enabled = False regenerate_device_ids(camera_no_channels) - camera_package = mock_camera.copy(deep=True) + camera_package = mock_camera.copy() camera_package._api = mock_entry.api + camera_package.channels = [c.copy() for c in mock_camera.channels] camera_package.channels[0]._api = mock_entry.api camera_package.channels[1]._api = mock_entry.api camera_package.channels[2]._api = mock_entry.api @@ -313,7 +318,7 @@ async def test_basic_setup( camera_package.channels[1].is_rtsp_enabled = False camera_package.channels[2].is_rtsp_enabled = False regenerate_device_ids(camera_package) - package_channel = camera_package.channels[0].copy(deep=True) + package_channel = camera_package.channels[0].copy() package_channel.is_rtsp_enabled = False package_channel.name = "Package Camera" package_channel.id = 3 @@ -398,7 +403,7 @@ async def test_missing_channels( ): """Test setting up camera with no camera channels.""" - camera = mock_camera.copy(deep=True) + camera = mock_camera.copy() camera.channels = [] mock_entry.api.bootstrap.cameras = {camera.id: camera} diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 23dfa12fc97..d36183ba135 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch import aiohttp from pyunifiprotect import NotAuthorized, NvrError -from pyunifiprotect.data import NVR, Light +from pyunifiprotect.data import NVR, Bootstrap, Light from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from . import _patch_discovery -from .conftest import MockBootstrap, MockEntityFixture, regenerate_device_ids +from .conftest import MockEntityFixture, regenerate_device_ids from tests.common import MockConfigEntry @@ -52,7 +52,7 @@ async def test_setup_multiple( hass: HomeAssistant, mock_entry: MockEntityFixture, mock_client, - mock_bootstrap: MockBootstrap, + mock_bootstrap: Bootstrap, ): """Test working setup of unifiprotect entry.""" diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index a3686fdfbd9..c4f324f30fd 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -32,7 +32,7 @@ async def light_fixture( # disable pydantic validation so mocking can happen Light.__config__.validate_assignment = False - light_obj = mock_light.copy(deep=True) + light_obj = mock_light.copy() light_obj._api = mock_entry.api light_obj.name = "Test Light" light_obj.is_light_on = False diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index abcea4ec04e..36b3d140871 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -35,7 +35,7 @@ async def doorlock_fixture( # disable pydantic validation so mocking can happen Doorlock.__config__.validate_assignment = False - lock_obj = mock_doorlock.copy(deep=True) + lock_obj = mock_doorlock.copy() lock_obj._api = mock_entry.api lock_obj.name = "Test Lock" lock_obj.lock_status = LockStatusType.OPEN diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index d6404ee3fe5..a4cbc9e8d22 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -38,7 +38,7 @@ async def camera_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index f516ad64a0b..043feae7925 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -23,6 +23,7 @@ from .conftest import ( MockEntityFixture, assert_entity_counts, ids_from_device_description, + reset_objects, ) @@ -35,13 +36,13 @@ async def light_fixture( # disable pydantic validation so mocking can happen Light.__config__.validate_assignment = False - light_obj = mock_light.copy(deep=True) + light_obj = mock_light.copy() light_obj._api = mock_entry.api light_obj.name = "Test Light" light_obj.light_device_settings.pir_sensitivity = 45 light_obj.light_device_settings.pir_duration = timedelta(seconds=45) - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.lights = { light_obj.id: light_obj, } @@ -65,7 +66,7 @@ async def camera_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -79,7 +80,7 @@ async def camera_fixture( camera_obj.mic_volume = 0 camera_obj.isp_settings.zoom_position = 0 - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -103,12 +104,12 @@ async def doorlock_fixture( # disable pydantic validation so mocking can happen Doorlock.__config__.validate_assignment = False - lock_obj = mock_doorlock.copy(deep=True) + lock_obj = mock_doorlock.copy() lock_obj._api = mock_entry.api lock_obj.name = "Test Lock" lock_obj.auto_close_time = timedelta(seconds=45) - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.doorlocks = { lock_obj.id: lock_obj, } @@ -174,7 +175,7 @@ async def test_number_setup_camera_none( ): """Test number entity setup for camera devices (no features).""" - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -185,7 +186,7 @@ async def test_number_setup_camera_none( # has_wdr is an the inverse of has HDR camera_obj.feature_flags.has_hdr = True - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -204,7 +205,7 @@ async def test_number_setup_camera_missing_attr( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -214,7 +215,7 @@ async def test_number_setup_camera_missing_attr( Camera.__config__.validate_assignment = True - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index fc0abbe29ca..01263a13cd9 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -44,6 +44,7 @@ from .conftest import ( MockEntityFixture, assert_entity_counts, ids_from_device_description, + reset_objects, ) @@ -59,12 +60,12 @@ async def viewer_fixture( # disable pydantic validation so mocking can happen Viewer.__config__.validate_assignment = False - viewer_obj = mock_viewer.copy(deep=True) + viewer_obj = mock_viewer.copy() viewer_obj._api = mock_entry.api viewer_obj.name = "Test Viewer" viewer_obj.liveview_id = mock_liveview.id - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.viewers = { viewer_obj.id: viewer_obj, } @@ -89,7 +90,7 @@ async def camera_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -102,7 +103,7 @@ async def camera_fixture( camera_obj.lcd_message = None camera_obj.chime_duration = 0 - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -129,14 +130,14 @@ async def light_fixture( # disable pydantic validation so mocking can happen Light.__config__.validate_assignment = False - light_obj = mock_light.copy(deep=True) + light_obj = mock_light.copy() light_obj._api = mock_entry.api light_obj.name = "Test Light" light_obj.camera_id = None light_obj.light_mode_settings.mode = LightModeType.MOTION light_obj.light_mode_settings.enable_at = LightModeEnableType.DARK - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = {camera.id: camera} mock_entry.api.bootstrap.lights = { light_obj.id: light_obj, @@ -161,7 +162,7 @@ async def camera_none_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -172,7 +173,7 @@ async def camera_none_fixture( camera_obj.recording_settings.mode = RecordingMode.ALWAYS camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 4f2540b28c4..eb2558aae3d 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -47,6 +47,7 @@ from .conftest import ( assert_entity_counts, enable_entity, ids_from_device_description, + reset_objects, time_changed, ) @@ -63,7 +64,7 @@ async def sensor_fixture( # disable pydantic validation so mocking can happen Sensor.__config__.validate_assignment = False - sensor_obj = mock_sensor.copy(deep=True) + sensor_obj = mock_sensor.copy() sensor_obj._api = mock_entry.api sensor_obj.name = "Test Sensor" sensor_obj.battery_status.percentage = 10.0 @@ -77,7 +78,7 @@ async def sensor_fixture( sensor_obj.up_since = now sensor_obj.bluetooth_connection_state.signal_strength = -50.0 - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.sensors = { sensor_obj.id: sensor_obj, } @@ -102,7 +103,7 @@ async def sensor_none_fixture( # disable pydantic validation so mocking can happen Sensor.__config__.validate_assignment = False - sensor_obj = mock_sensor.copy(deep=True) + sensor_obj = mock_sensor.copy() sensor_obj._api = mock_entry.api sensor_obj.name = "Test Sensor" sensor_obj.battery_status.percentage = 10.0 @@ -113,7 +114,7 @@ async def sensor_none_fixture( sensor_obj.up_since = now sensor_obj.bluetooth_connection_state.signal_strength = -50.0 - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.sensors = { sensor_obj.id: sensor_obj, } @@ -141,7 +142,7 @@ async def camera_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -162,7 +163,7 @@ async def camera_fixture( camera_obj.stats.storage.rate = 0.1 camera_obj.voltage = 20.0 - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, @@ -262,7 +263,7 @@ async def test_sensor_setup_nvr( ): """Test sensor entity setup for NVR device.""" - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) nvr: NVR = mock_entry.api.bootstrap.nvr nvr.up_since = now nvr.system_info.cpu.average_load = 50.0 @@ -339,7 +340,7 @@ async def test_sensor_nvr_missing_values( ): """Test NVR sensor sensors if no data available.""" - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) nvr: NVR = mock_entry.api.bootstrap.nvr nvr.system_info.memory.available = None nvr.system_info.memory.total = None diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index 2ad3821cc40..d957fe16b4b 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -5,8 +5,8 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import Camera, Light, ModelType -from pyunifiprotect.data.devices import Chime +from pyunifiprotect.data import Camera, Chime, Light, ModelType +from pyunifiprotect.data.bootstrap import ProtectDeviceRef from pyunifiprotect.exceptions import BadRequest from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN @@ -163,6 +163,11 @@ async def test_set_chime_paired_doorbells( mock_entry.api.bootstrap.chimes = { mock_chime.id: mock_chime, } + mock_entry.api.bootstrap.mac_lookup = { + mock_chime.mac.lower(): ProtectDeviceRef( + model=mock_chime.model, id=mock_chime.id + ) + } camera1 = mock_camera.copy() camera1.name = "Test Camera 1" @@ -186,6 +191,12 @@ async def test_set_chime_paired_doorbells( camera1.id: camera1, camera2.id: camera2, } + mock_entry.api.bootstrap.mac_lookup[camera1.mac.lower()] = ProtectDeviceRef( + model=camera1.model, id=camera1.id + ) + mock_entry.api.bootstrap.mac_lookup[camera2.mac.lower()] = ProtectDeviceRef( + model=camera2.model, id=camera2.id + ) await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 8ca1ef9b533..1bd6dbeb349 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -28,6 +28,7 @@ from .conftest import ( assert_entity_counts, enable_entity, ids_from_device_description, + reset_objects, ) CAMERA_SWITCHES_BASIC = [ @@ -51,13 +52,13 @@ async def light_fixture( # disable pydantic validation so mocking can happen Light.__config__.validate_assignment = False - light_obj = mock_light.copy(deep=True) + light_obj = mock_light.copy() light_obj._api = mock_entry.api light_obj.name = "Test Light" light_obj.is_ssh_enabled = False light_obj.light_device_settings.is_indicator_enabled = False - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.lights = { light_obj.id: light_obj, } @@ -81,7 +82,7 @@ async def camera_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -110,7 +111,7 @@ async def camera_fixture( camera_obj.osd_settings.is_debug_enabled = False camera_obj.smart_detect_settings.object_types = [] - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -134,7 +135,7 @@ async def camera_none_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -153,7 +154,7 @@ async def camera_none_fixture( camera_obj.osd_settings.is_logo_enabled = False camera_obj.osd_settings.is_debug_enabled = False - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -177,7 +178,8 @@ async def camera_privacy_fixture( # disable pydantic validation so mocking can happen Camera.__config__.validate_assignment = False - camera_obj = mock_camera.copy(deep=True) + # mock_camera._update_lock = None + camera_obj = mock_camera.copy() camera_obj._api = mock_entry.api camera_obj.channels[0]._api = mock_entry.api camera_obj.channels[1]._api = mock_entry.api @@ -197,7 +199,7 @@ async def camera_privacy_fixture( camera_obj.osd_settings.is_logo_enabled = False camera_obj.osd_settings.is_debug_enabled = False - mock_entry.api.bootstrap.reset_objects() + reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } From 0007178d63bd58ce2092d49885a35908ef9a0064 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 21 Jun 2022 05:33:47 +0200 Subject: [PATCH 544/947] Add filters and service to Sensibo (#73687) --- .../components/sensibo/binary_sensor.py | 9 ++ homeassistant/components/sensibo/button.py | 68 +++++++++++ homeassistant/components/sensibo/const.py | 1 + homeassistant/components/sensibo/entity.py | 2 + homeassistant/components/sensibo/sensor.py | 11 ++ tests/components/sensibo/fixtures/data.json | 2 +- tests/components/sensibo/test_button.py | 110 ++++++++++++++++++ 7 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/sensibo/button.py create mode 100644 tests/components/sensibo/test_button.py diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 503717c61e4..04e4a0b873d 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -52,6 +52,13 @@ class SensiboDeviceBinarySensorEntityDescription( """Describes Sensibo Motion sensor entity.""" +FILTER_CLEAN_REQUIRED_DESCRIPTION = SensiboDeviceBinarySensorEntityDescription( + key="filter_clean", + device_class=BinarySensorDeviceClass.PROBLEM, + name="Filter Clean Required", + value_fn=lambda data: data.filter_clean, +) + MOTION_SENSOR_TYPES: tuple[SensiboMotionBinarySensorEntityDescription, ...] = ( SensiboMotionBinarySensorEntityDescription( key="alive", @@ -85,6 +92,7 @@ MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, .. icon="mdi:motion-sensor", value_fn=lambda data: data.room_occupied, ), + FILTER_CLEAN_REQUIRED_DESCRIPTION, ) PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( @@ -127,6 +135,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( icon="mdi:connection", value_fn=lambda data: data.pure_prime_integration, ), + FILTER_CLEAN_REQUIRED_DESCRIPTION, ) diff --git a/homeassistant/components/sensibo/button.py b/homeassistant/components/sensibo/button.py new file mode 100644 index 00000000000..97ae6321f7e --- /dev/null +++ b/homeassistant/components/sensibo/button.py @@ -0,0 +1,68 @@ +"""Button platform for Sensibo integration.""" +from __future__ import annotations + +from homeassistant.components.button import ButtonEntity, ButtonEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import SensiboDataUpdateCoordinator +from .entity import SensiboDeviceBaseEntity + +PARALLEL_UPDATES = 0 + +DEVICE_BUTTON_TYPES: ButtonEntityDescription = ButtonEntityDescription( + key="reset_filter", + name="Reset Filter", + icon="mdi:air-filter", + entity_category=EntityCategory.CONFIG, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Sensibo binary sensor platform.""" + + coordinator: SensiboDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities: list[SensiboDeviceButton] = [] + + entities.extend( + SensiboDeviceButton(coordinator, device_id, DEVICE_BUTTON_TYPES) + for device_id, device_data in coordinator.data.parsed.items() + ) + + async_add_entities(entities) + + +class SensiboDeviceButton(SensiboDeviceBaseEntity, ButtonEntity): + """Representation of a Sensibo Device Binary Sensor.""" + + entity_description: ButtonEntityDescription + + def __init__( + self, + coordinator: SensiboDataUpdateCoordinator, + device_id: str, + entity_description: ButtonEntityDescription, + ) -> None: + """Initiate Sensibo Device Button.""" + super().__init__( + coordinator, + device_id, + ) + self.entity_description = entity_description + self._attr_unique_id = f"{device_id}-{entity_description.key}" + self._attr_name = f"{self.device_data.name} {entity_description.name}" + + async def async_press(self) -> None: + """Press the button.""" + result = await self.async_send_command("reset_filter") + if result["status"] == "success": + await self.coordinator.async_request_refresh() + return + raise HomeAssistantError(f"Could not set calibration for device {self.name}") diff --git a/homeassistant/components/sensibo/const.py b/homeassistant/components/sensibo/const.py index 736d663b144..d6dbe957def 100644 --- a/homeassistant/components/sensibo/const.py +++ b/homeassistant/components/sensibo/const.py @@ -14,6 +14,7 @@ DEFAULT_SCAN_INTERVAL = 60 DOMAIN = "sensibo" PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.NUMBER, Platform.SELECT, diff --git a/homeassistant/components/sensibo/entity.py b/homeassistant/components/sensibo/entity.py index bf70f499ec6..ac2ec24fac1 100644 --- a/homeassistant/components/sensibo/entity.py +++ b/homeassistant/components/sensibo/entity.py @@ -106,6 +106,8 @@ class SensiboDeviceBaseEntity(SensiboBaseEntity): self._device_id, params, ) + if command == "reset_filter": + result = await self._client.async_reset_filter(self._device_id) return result diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 259a24ab876..fad22fdd677 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -63,6 +63,15 @@ class SensiboDeviceSensorEntityDescription( """Describes Sensibo Motion sensor entity.""" +FILTER_LAST_RESET_DESCRIPTION = SensiboDeviceSensorEntityDescription( + key="filter_last_reset", + device_class=SensorDeviceClass.TIMESTAMP, + name="Filter Last Reset", + icon="mdi:timer", + value_fn=lambda data: data.filter_last_reset, + extra_fn=None, +) + MOTION_SENSOR_TYPES: tuple[SensiboMotionSensorEntityDescription, ...] = ( SensiboMotionSensorEntityDescription( key="rssi", @@ -122,6 +131,7 @@ PURE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( value_fn=lambda data: data.pure_sensitivity, extra_fn=None, ), + FILTER_LAST_RESET_DESCRIPTION, ) DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( @@ -133,6 +143,7 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceSensorEntityDescription, ...] = ( value_fn=lambda data: data.timer_time, extra_fn=lambda data: {"id": data.timer_id, "turn_on": data.timer_state_on}, ), + FILTER_LAST_RESET_DESCRIPTION, ) diff --git a/tests/components/sensibo/fixtures/data.json b/tests/components/sensibo/fixtures/data.json index 6c44b44821f..5837296d154 100644 --- a/tests/components/sensibo/fixtures/data.json +++ b/tests/components/sensibo/fixtures/data.json @@ -156,7 +156,7 @@ "time": "2022-03-12T15:24:26Z", "secondsAgo": 4219143 }, - "shouldCleanFilters": false + "shouldCleanFilters": true }, "serviceSubscriptions": [], "roomIsOccupied": true, diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py new file mode 100644 index 00000000000..66b7a1258b1 --- /dev/null +++ b/tests/components/sensibo/test_button.py @@ -0,0 +1,110 @@ +"""The test for the sensibo button platform.""" +from __future__ import annotations + +from datetime import datetime, timedelta +from unittest.mock import patch + +from freezegun.api import FrozenDateTimeFactory +from pysensibo.model import SensiboData +from pytest import MonkeyPatch, raises + +from homeassistant.components.button import SERVICE_PRESS +from homeassistant.components.button.const import DOMAIN as BUTTON_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt + +from tests.common import async_fire_time_changed + + +async def test_button( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Sensibo button.""" + + state_button = hass.states.get("button.hallway_reset_filter") + state_filter_clean = hass.states.get("binary_sensor.hallway_filter_clean_required") + state_filter_last_reset = hass.states.get("sensor.hallway_filter_last_reset") + + assert state_button.state is STATE_UNKNOWN + assert state_filter_clean.state is STATE_ON + assert state_filter_last_reset.state == "2022-03-12T15:24:26+00:00" + + freezer.move_to(datetime(2022, 6, 19, 20, 0, 0)) + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", + return_value={"status": "success"}, + ): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: state_button.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["ABC999111"], "filter_clean", False) + monkeypatch.setattr( + get_data.parsed["ABC999111"], + "filter_last_reset", + datetime(2022, 6, 19, 20, 0, 0, tzinfo=dt.UTC), + ) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state_button = hass.states.get("button.hallway_reset_filter") + state_filter_clean = hass.states.get("binary_sensor.hallway_filter_clean_required") + state_filter_last_reset = hass.states.get("sensor.hallway_filter_last_reset") + assert ( + state_button.state == datetime(2022, 6, 19, 20, 0, 0, tzinfo=dt.UTC).isoformat() + ) + assert state_filter_clean.state is STATE_OFF + assert state_filter_last_reset.state == "2022-06-19T20:00:00+00:00" + + +async def test_button_failure( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo button fails.""" + + state_button = hass.states.get("button.hallway_reset_filter") + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_reset_filter", + return_value={"status": "failure"}, + ): + with raises(HomeAssistantError): + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + { + ATTR_ENTITY_ID: state_button.entity_id, + }, + blocking=True, + ) From 9d132521424804cfd482229524124f3ab5742123 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 20 Jun 2022 22:52:41 -0500 Subject: [PATCH 545/947] Cleanup device callbacks in unifiprotect (#73463) --- .../components/unifiprotect/binary_sensor.py | 18 +++++++---- .../components/unifiprotect/camera.py | 11 +++++-- homeassistant/components/unifiprotect/data.py | 30 ++++++++++--------- .../components/unifiprotect/entity.py | 23 +++++++------- .../components/unifiprotect/light.py | 6 ++-- homeassistant/components/unifiprotect/lock.py | 6 ++-- .../components/unifiprotect/media_player.py | 12 ++++---- .../components/unifiprotect/migrate.py | 4 +-- .../components/unifiprotect/number.py | 6 ++-- .../components/unifiprotect/select.py | 5 ++-- .../components/unifiprotect/sensor.py | 13 ++++---- 11 files changed, 74 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 34c1119eb60..81832de7d44 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -5,7 +5,15 @@ from copy import copy from dataclasses import dataclass import logging -from pyunifiprotect.data import NVR, Camera, Event, Light, MountType, Sensor +from pyunifiprotect.data import ( + NVR, + Camera, + Event, + Light, + MountType, + ProtectModelWithId, + Sensor, +) from pyunifiprotect.data.nvr import UOSDisk from homeassistant.components.binary_sensor import ( @@ -205,8 +213,8 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): entity_description: ProtectBinaryEntityDescription @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_is_on = self.entity_description.get_ufp_value(self.device) # UP Sense can be any of the 3 contact sensor device classes @@ -240,8 +248,8 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): super().__init__(data, device, description) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) slot = self._disk.slot self._attr_available = False diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index d59ee59b760..c78e8e2f77a 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -5,7 +5,12 @@ from collections.abc import Generator import logging from pyunifiprotect.api import ProtectApiClient -from pyunifiprotect.data import Camera as UFPCamera, CameraChannel, StateType +from pyunifiprotect.data import ( + Camera as UFPCamera, + CameraChannel, + ProtectModelWithId, + StateType, +) from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry @@ -137,8 +142,8 @@ class ProtectCamera(ProtectDeviceEntity, Camera): ) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self.channel = self.device.channels[self.channel.id] motion_enabled = self.device.recording_settings.enable_motion_detection self._attr_motion_detection_enabled = ( diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 2a30e18d586..78a3c5ebac8 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -1,7 +1,7 @@ """Base class for protect data.""" from __future__ import annotations -from collections.abc import Generator, Iterable +from collections.abc import Callable, Generator, Iterable from datetime import timedelta import logging from typing import Any @@ -12,9 +12,10 @@ from pyunifiprotect.data import ( Event, Liveview, ModelType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, WSSubscriptionMessage, ) -from pyunifiprotect.data.base import ProtectAdoptableDeviceModel from pyunifiprotect.exceptions import ClientError, NotAuthorized from homeassistant.config_entries import ConfigEntry @@ -54,7 +55,7 @@ class ProtectData: self._entry = entry self._hass = hass self._update_interval = update_interval - self._subscriptions: dict[str, list[CALLBACK_TYPE]] = {} + self._subscriptions: dict[str, list[Callable[[ProtectModelWithId], None]]] = {} self._unsub_interval: CALLBACK_TYPE | None = None self._unsub_websocket: CALLBACK_TYPE | None = None @@ -123,7 +124,7 @@ class ProtectData: return if message.new_obj.model in DEVICES_WITH_ENTITIES: - self.async_signal_device_id_update(message.new_obj.id) + self._async_signal_device_update(message.new_obj) # trigger update for all Cameras with LCD screens when NVR Doorbell settings updates if "doorbell_settings" in message.changed_data: _LOGGER.debug( @@ -132,15 +133,15 @@ class ProtectData: self.api.bootstrap.nvr.update_all_messages() for camera in self.api.bootstrap.cameras.values(): if camera.feature_flags.has_lcd_screen: - self.async_signal_device_id_update(camera.id) + self._async_signal_device_update(camera) # trigger updates for camera that the event references elif isinstance(message.new_obj, Event): if message.new_obj.camera is not None: - self.async_signal_device_id_update(message.new_obj.camera.id) + self._async_signal_device_update(message.new_obj.camera) elif message.new_obj.light is not None: - self.async_signal_device_id_update(message.new_obj.light.id) + self._async_signal_device_update(message.new_obj.light) elif message.new_obj.sensor is not None: - self.async_signal_device_id_update(message.new_obj.sensor.id) + self._async_signal_device_update(message.new_obj.sensor) # alert user viewport needs restart so voice clients can get new options elif len(self.api.bootstrap.viewers) > 0 and isinstance( message.new_obj, Liveview @@ -157,13 +158,13 @@ class ProtectData: if updates is None: return - self.async_signal_device_id_update(self.api.bootstrap.nvr.id) + self._async_signal_device_update(self.api.bootstrap.nvr) for device in async_get_devices(self.api.bootstrap, DEVICES_THAT_ADOPT): - self.async_signal_device_id_update(device.id) + self._async_signal_device_update(device) @callback def async_subscribe_device_id( - self, device_id: str, update_callback: CALLBACK_TYPE + self, device_id: str, update_callback: Callable[[ProtectModelWithId], None] ) -> CALLBACK_TYPE: """Add an callback subscriber.""" if not self._subscriptions: @@ -179,7 +180,7 @@ class ProtectData: @callback def async_unsubscribe_device_id( - self, device_id: str, update_callback: CALLBACK_TYPE + self, device_id: str, update_callback: Callable[[ProtectModelWithId], None] ) -> None: """Remove a callback subscriber.""" self._subscriptions[device_id].remove(update_callback) @@ -190,14 +191,15 @@ class ProtectData: self._unsub_interval = None @callback - def async_signal_device_id_update(self, device_id: str) -> None: + def _async_signal_device_update(self, device: ProtectModelWithId) -> None: """Call the callbacks for a device_id.""" + device_id = device.id if not self._subscriptions.get(device_id): return _LOGGER.debug("Updating device: %s", device_id) for update_callback in self._subscriptions[device_id]: - update_callback() + update_callback(device) @callback diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 6de0a4c57cb..e06d297ef33 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -14,6 +14,7 @@ from pyunifiprotect.data import ( Light, ModelType, ProtectAdoptableDeviceModel, + ProtectModelWithId, Sensor, StateType, Viewer, @@ -26,7 +27,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData from .models import ProtectRequiredKeysMixin -from .utils import async_device_by_id, get_nested_attr +from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) @@ -127,7 +128,7 @@ class ProtectDeviceEntity(Entity): self._attr_attribution = DEFAULT_ATTRIBUTION self._async_set_device_info() - self._async_update_device_from_protect() + self._async_update_device_from_protect(device) async def async_update(self) -> None: """Update the entity. @@ -149,14 +150,10 @@ class ProtectDeviceEntity(Entity): ) @callback - def _async_update_device_from_protect(self) -> None: + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: """Update Entity object from Protect device.""" if self.data.last_update_success: - assert self.device.model - device = async_device_by_id( - self.data.api.bootstrap, self.device.id, device_type=self.device.model - ) - assert device is not None + assert isinstance(device, ProtectAdoptableDeviceModel) self.device = device is_connected = ( @@ -174,9 +171,9 @@ class ProtectDeviceEntity(Entity): self._attr_available = is_connected @callback - def _async_updated_event(self) -> None: + def _async_updated_event(self, device: ProtectModelWithId) -> None: """Call back for incoming data.""" - self._async_update_device_from_protect() + self._async_update_device_from_protect(device) self.async_write_ha_state() async def async_added_to_hass(self) -> None: @@ -217,7 +214,7 @@ class ProtectNVREntity(ProtectDeviceEntity): ) @callback - def _async_update_device_from_protect(self) -> None: + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: if self.data.last_update_success: self.device = self.data.api.bootstrap.nvr @@ -254,8 +251,8 @@ class EventThumbnailMixin(ProtectDeviceEntity): return attrs @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._event = self._async_get_event() attrs = self.extra_state_attributes or {} diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index 8a6f2f5a371..d84c8406acf 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Light +from pyunifiprotect.data import Light, ProtectModelWithId from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry @@ -59,8 +59,8 @@ class ProtectLight(ProtectDeviceEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_is_on = self.device.is_light_on self._attr_brightness = unifi_brightness_to_hass( self.device.light_device_settings.led_level diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index c4d56dd1e71..9cef3e19e36 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Doorlock, LockStatusType +from pyunifiprotect.data import Doorlock, LockStatusType, ProtectModelWithId from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry @@ -56,8 +56,8 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): self._attr_name = f"{self.device.name} Lock" @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_is_locked = False self._attr_is_locking = False diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 1acd14be130..c4fb1dbe15b 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Camera +from pyunifiprotect.data import Camera, ProtectModelWithId from pyunifiprotect.exceptions import StreamError from homeassistant.components import media_source @@ -83,8 +83,8 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): self._attr_media_content_type = MEDIA_TYPE_MUSIC @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_volume_level = float(self.device.speaker_settings.volume / 100) if ( @@ -110,7 +110,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): ): _LOGGER.debug("Stopping playback for %s Speaker", self.device.name) await self.device.stop_audio() - self._async_updated_event() + self._async_updated_event(self.device) async def async_play_media( self, media_type: str, media_id: str, **kwargs: Any @@ -134,11 +134,11 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): raise HomeAssistantError(err) from err else: # update state after starting player - self._async_updated_event() + self._async_updated_event(self.device) # wait until player finishes to update state again await self.device.wait_until_audio_completes() - self._async_updated_event() + self._async_updated_event(self.device) async def async_browse_media( self, media_content_type: str | None = None, media_content_id: str | None = None diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 307020caa5c..3273bd80408 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -147,12 +147,12 @@ async def async_migrate_device_ids( try: registry.async_update_entity(entity.entity_id, new_unique_id=new_unique_id) except ValueError as err: - print(err) _LOGGER.warning( - "Could not migrate entity %s (old unique_id: %s, new unique_id: %s)", + "Could not migrate entity %s (old unique_id: %s, new unique_id: %s): %s", entity.entity_id, entity.unique_id, new_unique_id, + err, ) else: count += 1 diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 1cfb4477673..5a3b048e623 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -4,7 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from pyunifiprotect.data import Camera, Doorlock, Light +from pyunifiprotect.data import Camera, Doorlock, Light, ProtectModelWithId from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry @@ -203,8 +203,8 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity): self._attr_native_step = self.entity_description.ufp_step @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) async def async_set_native_value(self, value: float) -> None: diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 2c6c5fa4cc6..6f5c2cfd0d7 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -19,6 +19,7 @@ from pyunifiprotect.data import ( LightModeEnableType, LightModeType, MountType, + ProtectModelWithId, RecordingMode, Sensor, Viewer, @@ -355,8 +356,8 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): self._async_set_options() @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) # entities with categories are not exposed for voice and safe to update dynamically if ( diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 48337dc416b..7b14a80ed43 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -12,6 +12,7 @@ from pyunifiprotect.data import ( Event, ProtectAdoptableDeviceModel, ProtectDeviceModel, + ProtectModelWithId, Sensor, ) @@ -540,8 +541,8 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): super().__init__(data, device, description) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) @@ -560,8 +561,8 @@ class ProtectNVRSensor(ProtectNVREntity, SensorEntity): super().__init__(data, device, description) @callback - def _async_update_device_from_protect(self) -> None: - super()._async_update_device_from_protect() + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) @@ -585,9 +586,9 @@ class ProtectEventSensor(ProtectDeviceSensor, EventThumbnailMixin): return event @callback - def _async_update_device_from_protect(self) -> None: + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: # do not call ProtectDeviceSensor method since we want event to get value here - EventThumbnailMixin._async_update_device_from_protect(self) + EventThumbnailMixin._async_update_device_from_protect(self, device) if self._event is None: self._attr_native_value = OBJECT_TYPE_NONE else: From 4813e6e42068b01173b015331aef8da1bcfcc784 Mon Sep 17 00:00:00 2001 From: rappenze Date: Tue, 21 Jun 2022 09:55:08 +0200 Subject: [PATCH 546/947] Code cleanup fibaro lock (#73389) * Code cleanup fibaro lock * Adjust typings as suggested in code review --- homeassistant/components/fibaro/lock.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index ac4ce658b65..e8fc4ca7180 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -3,6 +3,8 @@ from __future__ import annotations from typing import Any +from fiblary3.client.v4.models import DeviceModel, SceneModel + from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform @@ -33,27 +35,21 @@ async def async_setup_entry( class FibaroLock(FibaroDevice, LockEntity): """Representation of a Fibaro Lock.""" - def __init__(self, fibaro_device): + def __init__(self, fibaro_device: DeviceModel | SceneModel) -> None: """Initialize the Fibaro device.""" - self._state = False super().__init__(fibaro_device) self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) def lock(self, **kwargs: Any) -> None: """Lock the device.""" self.action("secure") - self._state = True + self._attr_is_locked = True def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self.action("unsecure") - self._state = False + self._attr_is_locked = False - @property - def is_locked(self) -> bool: - """Return true if device is locked.""" - return self._state - - def update(self): + def update(self) -> None: """Update device state.""" - self._state = self.current_binary_state + self._attr_is_locked = self.current_binary_state From ad2a41f774186aeae51934b22bf3c6da71c7c652 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 21 Jun 2022 10:07:37 +0200 Subject: [PATCH 547/947] Second run for eliminiate bluepy wheels (#73772) --- .github/workflows/wheels.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 54c2f2594ff..9bfcb48e09a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -249,13 +249,9 @@ jobs: sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} sed -i "s|# evdev|evdev|g" ${requirement_file} - sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} sed -i "s|# pycups|pycups|g" ${requirement_file} sed -i "s|# homekit|homekit|g" ${requirement_file} sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} - sed -i "s|# avion|avion|g" ${requirement_file} - sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} - sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} done From c674af3ba1b4a87682427003a86febc95f69c094 Mon Sep 17 00:00:00 2001 From: Thibaut Date: Tue, 21 Jun 2022 10:22:06 +0200 Subject: [PATCH 548/947] Remove hvac_action for Somfy Thermostat (#73776) --- .../overkiz/climate_entities/somfy_thermostat.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py index cfea49881f4..80859d7561b 100644 --- a/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py +++ b/homeassistant/components/overkiz/climate_entities/somfy_thermostat.py @@ -11,7 +11,6 @@ from homeassistant.components.climate.const import ( PRESET_HOME, PRESET_NONE, ClimateEntityFeature, - HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS @@ -83,15 +82,6 @@ class SomfyThermostat(OverkizEntity, ClimateEntity): ) ] - @property - def hvac_action(self) -> str: - """Return the current running hvac operation if supported.""" - if not self.current_temperature or not self.target_temperature: - return HVACAction.IDLE - if self.current_temperature < self.target_temperature: - return HVACAction.HEATING - return HVACAction.IDLE - @property def preset_mode(self) -> str: """Return the current preset mode, e.g., home, away, temp.""" From 1b8dd3368a50e4b1d8130cb2e673eaf4c6976df7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Jun 2022 14:36:22 +0200 Subject: [PATCH 549/947] Add checks for lock properties in type-hint plugin (#73729) * Add checks for lock properties in type-hint plugin * Adjust comment * Simplify return-type * Only check properties when ignore_missing_annotations is disabled * Adjust tests * Add comment * Adjust docstring --- pylint/plugins/hass_enforce_type_hints.py | 63 ++++++++++++++++++---- tests/pylint/test_enforce_type_hints.py | 66 +++++++++++++++++++++++ 2 files changed, 118 insertions(+), 11 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index fc7be8ba8e8..62dc6feffc6 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -20,8 +20,8 @@ class TypeHintMatch: """Class for pattern matching.""" function_name: str - arg_types: dict[int, str] return_type: list[str] | str | None | object + arg_types: dict[int, str] | None = None check_return_type_inheritance: bool = False @@ -440,7 +440,42 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), ], ), - ] + ], +} +# Properties are normally checked by mypy, and will only be checked +# by pylint when --ignore-missing-annotations is False +_PROPERTY_MATCH: dict[str, list[ClassTypeHintMatch]] = { + "lock": [ + ClassTypeHintMatch( + base_class="LockEntity", + matches=[ + TypeHintMatch( + function_name="changed_by", + return_type=["str", None], + ), + TypeHintMatch( + function_name="code_format", + return_type=["str", None], + ), + TypeHintMatch( + function_name="is_locked", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_locking", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_unlocking", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_jammed", + return_type=["bool", None], + ), + ], + ), + ], } @@ -621,7 +656,12 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] self._function_matchers.extend(function_matches) if class_matches := _CLASS_MATCH.get(module_platform): - self._class_matchers = class_matches + self._class_matchers.extend(class_matches) + + if not self.linter.config.ignore_missing_annotations and ( + property_matches := _PROPERTY_MATCH.get(module_platform) + ): + self._class_matchers.extend(property_matches) def visit_classdef(self, node: nodes.ClassDef) -> None: """Called when a ClassDef node is visited.""" @@ -659,14 +699,15 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] ): return - # Check that all arguments are correctly annotated. - for key, expected_type in match.arg_types.items(): - if not _is_valid_type(expected_type, annotations[key]): - self.add_message( - "hass-argument-type", - node=node.args.args[key], - args=(key + 1, expected_type), - ) + # Check that all positional arguments are correctly annotated. + if match.arg_types: + for key, expected_type in match.arg_types.items(): + if not _is_valid_type(expected_type, annotations[key]): + self.add_message( + "hass-argument-type", + node=node.args.args[key], + args=(key + 1, expected_type), + ) # Check the return type. if not _is_valid_return_type(match, node.returns): diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index c07014add7f..1f601a881a6 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -469,3 +469,69 @@ def test_valid_config_flow_async_get_options_flow( with assert_no_messages(linter): type_hint_checker.visit_classdef(class_node) + + +def test_invalid_entity_properties( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Check missing entity properties when ignore_missing_annotations is False.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node, prop_node = astroid.extract_node( + """ + class LockEntity(): + pass + + class DoorLock( #@ + LockEntity + ): + @property + def changed_by( #@ + self + ): + pass + """, + "homeassistant.components.pylint_test.lock", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=prop_node, + args=["str", None], + line=9, + col_offset=4, + end_line=9, + end_col_offset=18, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +def test_ignore_invalid_entity_properties( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Check invalid entity properties are ignored by default.""" + class_node = astroid.extract_node( + """ + class LockEntity(): + pass + + class DoorLock( #@ + LockEntity + ): + @property + def changed_by( + self + ): + pass + """, + "homeassistant.components.pylint_test.lock", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) From d399815bea6b05ba85f6beb99a8107320078c9da Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Jun 2022 06:42:41 -0700 Subject: [PATCH 550/947] Allow multiple google calendar config entries (#73715) * Support multiple config entries at once * Add test coverage for multiple config entries * Add support for multiple config entries to google config flow * Clear hass.data when unloading config entry * Make google config flow defensive against reuse of the same account * Assign existing google config entries a unique id * Migrate entities to new unique id format * Support muliple accounts per oauth client id * Fix mypy typing errors * Hard fail to keep state consistent, removing graceful degredation * Remove invalid entity regsitry entries --- homeassistant/components/google/__init__.py | 19 ++- homeassistant/components/google/calendar.py | 60 ++++++-- .../components/google/config_flow.py | 31 ++--- homeassistant/components/google/strings.json | 1 + tests/components/google/conftest.py | 25 +++- tests/components/google/test_calendar.py | 124 ++++++++++++++++- tests/components/google/test_config_flow.py | 130 +++++++++++++++--- tests/components/google/test_init.py | 128 ++++++++++++++++- 8 files changed, 459 insertions(+), 59 deletions(-) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index e86f1c43ebf..5553350aa23 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -8,6 +8,7 @@ from typing import Any import aiohttp from gcal_sync.api import GoogleCalendarService +from gcal_sync.exceptions import ApiException, AuthException from gcal_sync.model import DateOrDatetime, Event from oauth2client.file import Storage import voluptuous as vol @@ -220,6 +221,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Google from a config entry.""" hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = {} + implementation = ( await config_entry_oauth2_flow.async_get_config_entry_implementation( hass, entry @@ -249,7 +252,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: calendar_service = GoogleCalendarService( ApiAuthImpl(async_get_clientsession(hass), session) ) - hass.data[DOMAIN][DATA_SERVICE] = calendar_service + hass.data[DOMAIN][entry.entry_id][DATA_SERVICE] = calendar_service + + if entry.unique_id is None: + try: + primary_calendar = await calendar_service.async_get_calendar("primary") + except AuthException as err: + raise ConfigEntryAuthFailed from err + except ApiException as err: + raise ConfigEntryNotReady from err + else: + hass.config_entries.async_update_entry(entry, unique_id=primary_calendar.id) # Only expose the add event service if we have the correct permissions if get_feature_access(hass, entry) is FeatureAccess.read_write: @@ -271,7 +284,9 @@ def async_entry_has_scopes(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py index e27eb0b1336..3c271a2c3c3 100644 --- a/homeassistant/components/google/calendar.py +++ b/homeassistant/components/google/calendar.py @@ -23,7 +23,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_ID, CONF_ENTITIES, CONF_NAME, CONF_OFFSET from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers import ( + config_validation as cv, + entity_platform, + entity_registry as er, +) from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import Throttle @@ -102,15 +106,25 @@ CREATE_EVENT_SCHEMA = vol.All( async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Set up the google calendar platform.""" - calendar_service = hass.data[DOMAIN][DATA_SERVICE] + calendar_service = hass.data[DOMAIN][config_entry.entry_id][DATA_SERVICE] try: result = await calendar_service.async_list_calendars() except ApiException as err: raise PlatformNotReady(str(err)) from err + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + entity_entry_map = { + entity_entry.unique_id: entity_entry for entity_entry in registry_entries + } + # Yaml configuration may override objects from the API calendars = await hass.async_add_executor_job( load_config, hass.config.path(YAML_DEVICES) @@ -126,7 +140,6 @@ async def async_setup_entry( hass, calendar_item.dict(exclude_unset=True) ) new_calendars.append(calendar_info) - # Yaml calendar config may map one calendar to multiple entities with extra options like # offsets or search criteria. num_entities = len(calendar_info[CONF_ENTITIES]) @@ -138,15 +151,44 @@ async def async_setup_entry( "has been imported to the UI, and should now be removed from google_calendars.yaml" ) entity_name = data[CONF_DEVICE_ID] + # The unique id is based on the config entry and calendar id since multiple accounts + # can have a common calendar id (e.g. `en.usa#holiday@group.v.calendar.google.com`). + # When using google_calendars.yaml with multiple entities for a single calendar, we + # have no way to set a unique id. + if num_entities > 1: + unique_id = None + else: + unique_id = f"{config_entry.unique_id}-{calendar_id}" + # Migrate to new unique_id format which supports multiple config entries as of 2022.7 + for old_unique_id in (calendar_id, f"{calendar_id}-{entity_name}"): + if not (entity_entry := entity_entry_map.get(old_unique_id)): + continue + if unique_id: + _LOGGER.debug( + "Migrating unique_id for %s from %s to %s", + entity_entry.entity_id, + old_unique_id, + unique_id, + ) + entity_registry.async_update_entity( + entity_entry.entity_id, new_unique_id=unique_id + ) + else: + _LOGGER.debug( + "Removing entity registry entry for %s from %s", + entity_entry.entity_id, + old_unique_id, + ) + entity_registry.async_remove( + entity_entry.entity_id, + ) entities.append( GoogleCalendarEntity( calendar_service, calendar_id, data, generate_entity_id(ENTITY_ID_FORMAT, entity_name, hass=hass), - # The google_calendars.yaml file lets users add multiple entities for - # the same calendar id and needs additional disambiguation - f"{calendar_id}-{entity_name}" if num_entities > 1 else calendar_id, + unique_id, entity_enabled, ) ) @@ -163,7 +205,7 @@ async def async_setup_entry( await hass.async_add_executor_job(append_calendars_to_config) platform = entity_platform.async_get_current_platform() - if get_feature_access(hass, entry) is FeatureAccess.read_write: + if get_feature_access(hass, config_entry) is FeatureAccess.read_write: platform.async_register_entity_service( SERVICE_CREATE_EVENT, CREATE_EVENT_SCHEMA, @@ -180,7 +222,7 @@ class GoogleCalendarEntity(CalendarEntity): calendar_id: str, data: dict[str, Any], entity_id: str, - unique_id: str, + unique_id: str | None, entity_enabled: bool, ) -> None: """Create the Calendar event device.""" diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index be516230d2b..046840075ff 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -59,14 +59,6 @@ class OAuth2FlowHandler( self.external_data = info return await super().async_step_creation(info) - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle external yaml configuration.""" - if not self._reauth_config_entry and self._async_current_entries(): - return self.async_abort(reason="already_configured") - return await super().async_step_user(user_input) - async def async_step_auth( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -135,14 +127,14 @@ class OAuth2FlowHandler( async def async_oauth_create_entry(self, data: dict) -> FlowResult: """Create an entry for the flow, or update existing entry.""" - existing_entries = self._async_current_entries() - if existing_entries: - assert len(existing_entries) == 1 - entry = existing_entries[0] - self.hass.config_entries.async_update_entry(entry, data=data) - await self.hass.config_entries.async_reload(entry.entry_id) + if self._reauth_config_entry: + self.hass.config_entries.async_update_entry( + self._reauth_config_entry, data=data + ) + await self.hass.config_entries.async_reload( + self._reauth_config_entry.entry_id + ) return self.async_abort(reason="reauth_successful") - calendar_service = GoogleCalendarService( AccessTokenAuthImpl( async_get_clientsession(self.hass), data["token"]["access_token"] @@ -151,11 +143,12 @@ class OAuth2FlowHandler( try: primary_calendar = await calendar_service.async_get_calendar("primary") except ApiException as err: - _LOGGER.debug("Error reading calendar primary calendar: %s", err) - primary_calendar = None - title = primary_calendar.id if primary_calendar else self.flow_impl.name + _LOGGER.error("Error reading primary calendar: %s", err) + return self.async_abort(reason="cannot_connect") + await self.async_set_unique_id(primary_calendar.id) + self._abort_if_unique_id_configured() return self.async_create_entry( - title=title, + title=primary_calendar.id, data=data, options={ CONF_CALENDAR_ACCESS: get_feature_access(self.hass).name, diff --git a/homeassistant/components/google/strings.json b/homeassistant/components/google/strings.json index 6652806cd0f..3ff75047f70 100644 --- a/homeassistant/components/google/strings.json +++ b/homeassistant/components/google/strings.json @@ -15,6 +15,7 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "code_expired": "Authentication code expired or credential setup is invalid, please try again.", diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py index 27fb6c993ff..4e251b4b006 100644 --- a/tests/components/google/conftest.py +++ b/tests/components/google/conftest.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Awaitable, Callable import datetime +import http from typing import Any, Generator, TypeVar from unittest.mock import Mock, mock_open, patch @@ -27,6 +28,7 @@ YieldFixture = Generator[_T, None, None] CALENDAR_ID = "qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com" +EMAIL_ADDRESS = "user@gmail.com" # Entities can either be created based on data directly from the API, or from # the yaml config that overrides the entity name and other settings. A test @@ -53,6 +55,9 @@ TEST_API_CALENDAR = { "defaultReminders": [], } +CLIENT_ID = "client-id" +CLIENT_SECRET = "client-secret" + @pytest.fixture def test_api_calendar(): @@ -148,8 +153,8 @@ def creds( """Fixture that defines creds used in the test.""" return OAuth2Credentials( access_token="ACCESS_TOKEN", - client_id="client-id", - client_secret="client-secret", + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, refresh_token="REFRESH_TOKEN", token_expiry=token_expiry, token_uri="http://example.com", @@ -178,8 +183,15 @@ def config_entry_options() -> dict[str, Any] | None: return None +@pytest.fixture +def config_entry_unique_id() -> str: + """Fixture that returns the default config entry unique id.""" + return EMAIL_ADDRESS + + @pytest.fixture def config_entry( + config_entry_unique_id: str, token_scopes: list[str], config_entry_token_expiry: float, config_entry_options: dict[str, Any] | None, @@ -187,6 +199,7 @@ def config_entry( """Fixture to create a config entry for the integration.""" return MockConfigEntry( domain=DOMAIN, + unique_id=config_entry_unique_id, data={ "auth_implementation": "device_auth", "token": { @@ -271,12 +284,16 @@ def mock_calendar_get( """Fixture for returning a calendar get response.""" def _result( - calendar_id: str, response: dict[str, Any], exc: ClientError | None = None + calendar_id: str, + response: dict[str, Any], + exc: ClientError | None = None, + status: http.HTTPStatus = http.HTTPStatus.OK, ) -> None: aioclient_mock.get( f"{API_BASE_URL}/calendars/{calendar_id}", json=response, exc=exc, + status=status, ) return @@ -315,7 +332,7 @@ def google_config_track_new() -> None: @pytest.fixture def google_config(google_config_track_new: bool | None) -> dict[str, Any]: """Fixture for overriding component config.""" - google_config = {CONF_CLIENT_ID: "client-id", CONF_CLIENT_SECRET: "client-secret"} + google_config = {CONF_CLIENT_ID: CLIENT_ID, CONF_CLIENT_SECRET: CLIENT_SECRET} if google_config_track_new is not None: google_config[CONF_TRACK_NEW] = google_config_track_new return google_config diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 85711014e72..9a0cc2e47fa 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -13,7 +13,9 @@ from aiohttp.client_exceptions import ClientError from gcal_sync.auth import API_BASE_URL import pytest -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.google.const import DOMAIN +from homeassistant.const import STATE_OFF, STATE_ON, Platform +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.template import DATE_STR_FORMAT import homeassistant.util.dt as dt_util @@ -665,3 +667,123 @@ async def test_future_event_offset_update_behavior( state = hass.states.get(TEST_ENTITY) assert state.state == STATE_OFF assert state.attributes["offset_reached"] + + +async def test_unique_id( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, +): + """Test entity is created with a unique id based on the config entry.""" + mock_events_list_items([]) + assert await component_setup() + + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{config_entry.unique_id}-{CALENDAR_ID}" + } + + +@pytest.mark.parametrize( + "old_unique_id", [CALENDAR_ID, f"{CALENDAR_ID}-we_are_we_are_a_test_calendar"] +) +async def test_unique_id_migration( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, + old_unique_id, +): + """Test that old unique id format is migrated to the new format that supports multiple accounts.""" + entity_registry = er.async_get(hass) + + # Create an entity using the old unique id format + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=old_unique_id, + config_entry=config_entry, + ) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == {old_unique_id} + + mock_events_list_items([]) + assert await component_setup() + + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{config_entry.unique_id}-{CALENDAR_ID}" + } + + +@pytest.mark.parametrize( + "calendars_config", + [ + [ + { + "cal_id": CALENDAR_ID, + "entities": [ + { + "device_id": "backyard_light", + "name": "Backyard Light", + "search": "#Backyard", + }, + { + "device_id": "front_light", + "name": "Front Light", + "search": "#Front", + }, + ], + } + ], + ], +) +async def test_invalid_unique_id_cleanup( + hass, + mock_events_list_items, + mock_token_read, + component_setup, + config_entry, + mock_calendars_yaml, +): + """Test that old unique id format that is not actually unique is removed.""" + entity_registry = er.async_get(hass) + + # Create an entity using the old unique id format + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=f"{CALENDAR_ID}-backyard_light", + config_entry=config_entry, + ) + entity_registry.async_get_or_create( + DOMAIN, + Platform.CALENDAR, + unique_id=f"{CALENDAR_ID}-front_light", + config_entry=config_entry, + ) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert {entry.unique_id for entry in registry_entries} == { + f"{CALENDAR_ID}-backyard_light", + f"{CALENDAR_ID}-front_light", + } + + mock_events_list_items([]) + assert await component_setup() + + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert not registry_entries diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index a346b02e6c2..00f50e129e4 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -27,13 +27,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.util.dt import utcnow -from .conftest import ComponentSetup, YieldFixture +from .conftest import ( + CLIENT_ID, + CLIENT_SECRET, + EMAIL_ADDRESS, + ComponentSetup, + YieldFixture, +) from tests.common import MockConfigEntry, async_fire_time_changed CODE_CHECK_INTERVAL = 1 CODE_CHECK_ALARM_TIMEDELTA = datetime.timedelta(seconds=CODE_CHECK_INTERVAL * 2) -EMAIL_ADDRESS = "user@gmail.com" @pytest.fixture(autouse=True) @@ -70,6 +75,12 @@ async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: yield mock +@pytest.fixture +async def primary_calendar_email() -> str: + """Fixture to override the google calendar primary email address.""" + return EMAIL_ADDRESS + + @pytest.fixture async def primary_calendar_error() -> ClientError | None: """Fixture for tests to inject an error during calendar lookup.""" @@ -78,12 +89,14 @@ async def primary_calendar_error() -> ClientError | None: @pytest.fixture(autouse=True) async def primary_calendar( - mock_calendar_get: Callable[[...], None], primary_calendar_error: ClientError | None + mock_calendar_get: Callable[[...], None], + primary_calendar_error: ClientError | None, + primary_calendar_email: str, ) -> None: """Fixture to return the primary calendar.""" mock_calendar_get( "primary", - {"id": EMAIL_ADDRESS, "summary": "Personal"}, + {"id": primary_calendar_email, "summary": "Personal"}, exc=primary_calendar_error, ) @@ -165,7 +178,7 @@ async def test_full_flow_application_creds( assert await component_setup() await async_import_client_credential( - hass, DOMAIN, ClientCredential("client-id", "client-secret"), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" ) result = await hass.config_entries.flow.async_init( @@ -327,26 +340,107 @@ async def test_exchange_error( assert len(entries) == 1 -async def test_existing_config_entry( +@pytest.mark.parametrize("google_config", [None]) +async def test_duplicate_config_entries( hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + config: dict[str, Any], config_entry: MockConfigEntry, component_setup: ComponentSetup, ) -> None: - """Test can't configure when config entry already exists.""" + """Test that the same account cannot be setup twice.""" + assert await component_setup() + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + ) + + # Load a config entry config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 - assert await component_setup() - + # Start a new config flow using the same credential result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) assert result.get("type") == "abort" assert result.get("reason") == "already_configured" +@pytest.mark.parametrize( + "google_config,primary_calendar_email", [(None, "another-email@example.com")] +) +async def test_multiple_config_entries( + hass: HomeAssistant, + mock_code_flow: Mock, + mock_exchange: Mock, + config: dict[str, Any], + config_entry: MockConfigEntry, + component_setup: ComponentSetup, +) -> None: + """Test that multiple config entries can be set at once.""" + assert await component_setup() + await async_import_client_credential( + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + ) + + # Load a config entry + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + # Start a new config flow + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "progress" + assert result.get("step_id") == "auth" + assert "description_placeholders" in result + assert "url" in result["description_placeholders"] + + with patch( + "homeassistant.components.google.async_setup_entry", return_value=True + ) as mock_setup: + # Run one tick to invoke the credential exchange check + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) + await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + flow_id=result["flow_id"] + ) + assert result.get("type") == "create_entry" + assert result.get("title") == "another-email@example.com" + assert len(mock_setup.mock_calls) == 1 + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 + + async def test_missing_configuration( hass: HomeAssistant, ) -> None: @@ -385,8 +479,8 @@ async def test_wrong_configuration( config_entry_oauth2_flow.LocalOAuth2Implementation( hass, DOMAIN, - "client-id", - "client-secret", + CLIENT_ID, + CLIENT_SECRET, "http://example/authorize", "http://example/token", ), @@ -499,7 +593,7 @@ async def test_reauth_flow( @pytest.mark.parametrize("primary_calendar_error", [ClientError()]) -async def test_title_lookup_failure( +async def test_calendar_lookup_failure( hass: HomeAssistant, mock_code_flow: Mock, mock_exchange: Mock, @@ -516,9 +610,7 @@ async def test_title_lookup_failure( assert "description_placeholders" in result assert "url" in result["description_placeholders"] - with patch( - "homeassistant.components.google.async_setup_entry", return_value=True - ) as mock_setup: + with patch("homeassistant.components.google.async_setup_entry", return_value=True): # Run one tick to invoke the credential exchange check now = utcnow() await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) @@ -527,12 +619,8 @@ async def test_title_lookup_failure( flow_id=result["flow_id"] ) - assert result.get("type") == "create_entry" - assert result.get("title") == "Import from configuration.yaml" - - assert len(mock_setup.mock_calls) == 1 - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 + assert result.get("type") == "abort" + assert result.get("reason") == "cannot_connect" async def test_options_flow_triggers_reauth( diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index f9391a82b6a..d9b9ec8ed03 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -26,6 +26,7 @@ from homeassistant.util.dt import utcnow from .conftest import ( CALENDAR_ID, + EMAIL_ADDRESS, TEST_API_ENTITY, TEST_API_ENTITY_NAME, TEST_YAML_ENTITY, @@ -62,6 +63,7 @@ def setup_config_entry( ) -> MockConfigEntry: """Fixture to initialize the config entry.""" config_entry.add_to_hass(hass) + return config_entry @pytest.fixture( @@ -219,11 +221,9 @@ async def test_calendar_yaml_error( assert hass.states.get(TEST_API_ENTITY) -@pytest.mark.parametrize("calendars_config", [[]]) -async def test_found_calendar_from_api( +async def test_init_calendar( hass: HomeAssistant, component_setup: ComponentSetup, - mock_calendars_yaml: None, mock_calendars_list: ApiResult, test_api_calendar: dict[str, Any], mock_events_list: ApiResult, @@ -275,6 +275,59 @@ async def test_load_application_credentials( assert not hass.states.get(TEST_YAML_ENTITY) +async def test_multiple_config_entries( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test finding a calendar from the API.""" + + assert await component_setup() + + config_entry1 = MockConfigEntry( + domain=DOMAIN, data=config_entry.data, unique_id=EMAIL_ADDRESS + ) + calendar1 = { + **test_api_calendar, + "id": "calendar-id1", + "summary": "Example Calendar 1", + } + + mock_calendars_list({"items": [calendar1]}) + mock_events_list({}, calendar_id="calendar-id1") + config_entry1.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry1.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.example_calendar_1") + assert state + assert state.name == "Example Calendar 1" + assert state.state == STATE_OFF + + config_entry2 = MockConfigEntry( + domain=DOMAIN, data=config_entry.data, unique_id="other-address@example.com" + ) + calendar2 = { + **test_api_calendar, + "id": "calendar-id2", + "summary": "Example Calendar 2", + } + aioclient_mock.clear_requests() + mock_calendars_list({"items": [calendar2]}) + mock_events_list({}, calendar_id="calendar-id2") + config_entry2.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry2.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("calendar.example_calendar_2") + assert state + assert state.name == "Example Calendar 2" + + @pytest.mark.parametrize( "calendars_config_track,expected_state,google_config_track_new", [ @@ -795,3 +848,72 @@ async def test_update_will_reload( ) await hass.async_block_till_done() mock_reload.assert_called_once() + + +@pytest.mark.parametrize("config_entry_unique_id", [None]) +async def test_assign_unique_id( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + mock_calendar_get: Callable[[...], None], + setup_config_entry: MockConfigEntry, +) -> None: + """Test an existing config is updated to have unique id if it does not exist.""" + + assert setup_config_entry.state is ConfigEntryState.NOT_LOADED + assert setup_config_entry.unique_id is None + + mock_calendar_get( + "primary", + {"id": EMAIL_ADDRESS, "summary": "Personal"}, + ) + + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + assert setup_config_entry.state is ConfigEntryState.LOADED + assert setup_config_entry.unique_id == EMAIL_ADDRESS + + +@pytest.mark.parametrize( + "config_entry_unique_id,request_status,config_entry_status", + [ + (None, http.HTTPStatus.BAD_REQUEST, ConfigEntryState.SETUP_RETRY), + ( + None, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ], +) +async def test_assign_unique_id_failure( + hass: HomeAssistant, + component_setup: ComponentSetup, + mock_calendars_list: ApiResult, + test_api_calendar: dict[str, Any], + mock_events_list: ApiResult, + mock_calendar_get: Callable[[...], None], + setup_config_entry: MockConfigEntry, + request_status: http.HTTPStatus, + config_entry_status: ConfigEntryState, +) -> None: + """Test lookup failures during unique id assignment are handled gracefully.""" + + assert setup_config_entry.state is ConfigEntryState.NOT_LOADED + assert setup_config_entry.unique_id is None + + mock_calendar_get( + "primary", + {}, + status=request_status, + ) + + mock_calendars_list({"items": [test_api_calendar]}) + mock_events_list({}) + assert await component_setup() + + assert setup_config_entry.state is config_entry_status + assert setup_config_entry.unique_id is None From a96aa64dd1dc9b1f47e14f3f4bb85b39560a37bd Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 21 Jun 2022 16:35:22 +0200 Subject: [PATCH 551/947] Add Somfy to supported brands of Overkiz integration (#73786) Add Somfy to supported brands --- homeassistant/components/overkiz/manifest.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index 432988a6dc4..fa89be5d19e 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -24,6 +24,7 @@ "flexom": "Bouygues Flexom", "hi_kumo": "Hitachi Hi Kumo", "nexity": "Nexity Eugénie", - "rexel": "Rexel Energeasy Connect" + "rexel": "Rexel Energeasy Connect", + "somfy": "Somfy" } } From cf9cab900e3555ea3cee1f09a03b45ed76750105 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 21 Jun 2022 07:36:13 -0700 Subject: [PATCH 552/947] Allow multiple configuration entries for nest integration (#73720) * Add multiple config entry support for Nest * Set a config entry unique id based on nest project id * Add missing translations and remove untested committed * Remove unnecessary translation * Remove dead code * Remove old handling to avoid duplicate error logs --- homeassistant/components/nest/__init__.py | 40 ++++++------- homeassistant/components/nest/camera_sdm.py | 4 +- homeassistant/components/nest/climate_sdm.py | 4 +- homeassistant/components/nest/config_flow.py | 17 +++--- homeassistant/components/nest/device_info.py | 30 +++++++++- .../components/nest/device_trigger.py | 46 ++++---------- homeassistant/components/nest/diagnostics.py | 9 ++- homeassistant/components/nest/media_source.py | 27 +++------ homeassistant/components/nest/sensor_sdm.py | 4 +- homeassistant/components/nest/strings.json | 2 +- tests/components/nest/conftest.py | 10 +++- tests/components/nest/test_config_flow_sdm.py | 60 +++++++++++++------ tests/components/nest/test_init_sdm.py | 24 ++++++-- 13 files changed, 161 insertions(+), 116 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 29c2d817acd..0e0128136ad 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -77,9 +77,6 @@ from .media_source import ( _LOGGER = logging.getLogger(__name__) -DATA_NEST_UNAVAILABLE = "nest_unavailable" - -NEST_SETUP_NOTIFICATION = "nest_setup" SENSOR_SCHEMA = vol.Schema( {vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list)} @@ -179,13 +176,16 @@ class SignalUpdateCallback: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" - config_mode = config_flow.get_config_mode(hass) if config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) if config_mode == config_flow.ConfigMode.SDM: await async_import_config(hass, entry) + elif entry.unique_id != entry.data[CONF_PROJECT_ID]: + hass.config_entries.async_update_entry( + entry, unique_id=entry.data[CONF_PROJECT_ID] + ) subscriber = await api.new_subscriber(hass, entry) if not subscriber: @@ -205,31 +205,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await subscriber.start_async() except AuthException as err: - _LOGGER.debug("Subscriber authentication error: %s", err) - raise ConfigEntryAuthFailed from err + raise ConfigEntryAuthFailed( + f"Subscriber authentication error: {str(err)}" + ) from err except ConfigurationException as err: _LOGGER.error("Configuration error: %s", err) subscriber.stop_async() return False except SubscriberException as err: - if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]: - _LOGGER.error("Subscriber error: %s", err) - hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True subscriber.stop_async() - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Subscriber error: {str(err)}") from err try: device_manager = await subscriber.async_get_device_manager() except ApiException as err: - if DATA_NEST_UNAVAILABLE not in hass.data[DOMAIN]: - _LOGGER.error("Device manager error: %s", err) - hass.data[DOMAIN][DATA_NEST_UNAVAILABLE] = True subscriber.stop_async() - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Device manager error: {str(err)}") from err - hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None) - hass.data[DOMAIN][DATA_SUBSCRIBER] = subscriber - hass.data[DOMAIN][DATA_DEVICE_MANAGER] = device_manager + hass.data[DOMAIN][entry.entry_id] = { + DATA_SUBSCRIBER: subscriber, + DATA_DEVICE_MANAGER: device_manager, + } hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -252,7 +248,9 @@ async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None: CONF_SUBSCRIBER_ID_IMPORTED: True, # Don't delete user managed subscriber } ) - hass.config_entries.async_update_entry(entry, data=new_data) + hass.config_entries.async_update_entry( + entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID] + ) if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: # App Auth credentials have been deprecated and must be re-created @@ -288,13 +286,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Legacy API return True _LOGGER.debug("Stopping nest subscriber") - subscriber = hass.data[DOMAIN][DATA_SUBSCRIBER] + subscriber = hass.data[DOMAIN][entry.entry_id][DATA_SUBSCRIBER] subscriber.stop_async() unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - hass.data[DOMAIN].pop(DATA_SUBSCRIBER) - hass.data[DOMAIN].pop(DATA_DEVICE_MANAGER) - hass.data[DOMAIN].pop(DATA_NEST_UNAVAILABLE, None) + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/nest/camera_sdm.py b/homeassistant/components/nest/camera_sdm.py index a089163a826..4e38338aee8 100644 --- a/homeassistant/components/nest/camera_sdm.py +++ b/homeassistant/components/nest/camera_sdm.py @@ -45,7 +45,9 @@ async def async_setup_sdm_entry( ) -> None: """Set up the cameras.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] entities = [] for device in device_manager.devices.values(): if ( diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py index 6ee988b714f..452c30073da 100644 --- a/homeassistant/components/nest/climate_sdm.py +++ b/homeassistant/components/nest/climate_sdm.py @@ -82,7 +82,9 @@ async def async_setup_sdm_entry( ) -> None: """Set up the client entities.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] entities = [] for device in device_manager.devices.values(): if ThermostatHvacTrait.NAME in device.traits: diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index bacd61447f5..479a54edbc7 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -276,10 +276,6 @@ class NestFlowHandler( if self.config_mode == ConfigMode.LEGACY: return await self.async_step_init(user_input) self._data[DATA_SDM] = {} - # Reauth will update an existing entry - entries = self._async_current_entries() - if entries and self.source != SOURCE_REAUTH: - return self.async_abort(reason="single_instance_allowed") if self.source == SOURCE_REAUTH: return await super().async_step_user(user_input) # Application Credentials setup needs information from the user @@ -339,13 +335,16 @@ class NestFlowHandler( """Collect device access project from user input.""" errors = {} if user_input is not None: - if user_input[CONF_PROJECT_ID] == self._data[CONF_CLOUD_PROJECT_ID]: + project_id = user_input[CONF_PROJECT_ID] + if project_id == self._data[CONF_CLOUD_PROJECT_ID]: _LOGGER.error( "Device Access Project ID and Cloud Project ID must not be the same, see documentation" ) errors[CONF_PROJECT_ID] = "wrong_project_id" else: self._data.update(user_input) + await self.async_set_unique_id(project_id) + self._abort_if_unique_id_configured() return await super().async_step_user() return self.async_show_form( @@ -465,13 +464,11 @@ class NestFlowHandler( async def async_step_finish(self, data: dict[str, Any] | None = None) -> FlowResult: """Create an entry for the SDM flow.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - await self.async_set_unique_id(DOMAIN) - # Update existing config entry when in the reauth flow. This - # integration only supports one config entry so remove any prior entries - # added before the "single_instance_allowed" check was added + # Update existing config entry when in the reauth flow. if entry := self._async_reauth_entry(): self.hass.config_entries.async_update_entry( - entry, data=self._data, unique_id=DOMAIN + entry, + data=self._data, ) await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="reauth_successful") diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index b9aa52aa2c6..2d2b01d3849 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -2,12 +2,16 @@ from __future__ import annotations +from collections.abc import Mapping + from google_nest_sdm.device import Device from google_nest_sdm.device_traits import InfoTrait +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import DeviceInfo -from .const import DOMAIN +from .const import DATA_DEVICE_MANAGER, DOMAIN DEVICE_TYPE_MAP: dict[str, str] = { "sdm.devices.types.CAMERA": "Camera", @@ -66,3 +70,27 @@ class NestDeviceInfo: names = [name for id, name in items] return " ".join(names) return None + + +@callback +def async_nest_devices(hass: HomeAssistant) -> Mapping[str, Device]: + """Return a mapping of all nest devices for all config entries.""" + devices = {} + for entry_id in hass.data[DOMAIN]: + if not (device_manager := hass.data[DOMAIN][entry_id].get(DATA_DEVICE_MANAGER)): + continue + devices.update( + {device.name: device for device in device_manager.devices.values()} + ) + return devices + + +@callback +def async_nest_devices_by_device_id(hass: HomeAssistant) -> Mapping[str, Device]: + """Return a mapping of all nest devices by home assistant device id, for all config entries.""" + device_registry = dr.async_get(hass) + devices = {} + for nest_device_id, device in async_nest_devices(hass).items(): + if device_entry := device_registry.async_get_device({(DOMAIN, nest_device_id)}): + devices[device_entry.id] = device + return devices diff --git a/homeassistant/components/nest/device_trigger.py b/homeassistant/components/nest/device_trigger.py index 05769a407f2..cb546c87ee4 100644 --- a/homeassistant/components/nest/device_trigger.py +++ b/homeassistant/components/nest/device_trigger.py @@ -1,7 +1,6 @@ """Provides device automations for Nest.""" from __future__ import annotations -from google_nest_sdm.device_manager import DeviceManager import voluptuous as vol from homeassistant.components.automation import ( @@ -14,11 +13,11 @@ from homeassistant.components.device_automation.exceptions import ( ) from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import device_registry as dr +from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.typing import ConfigType -from .const import DATA_DEVICE_MANAGER, DOMAIN +from .const import DOMAIN +from .device_info import async_nest_devices_by_device_id from .events import DEVICE_TRAIT_TRIGGER_MAP, NEST_EVENT DEVICE = "device" @@ -32,43 +31,18 @@ TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( ) -@callback -def async_get_nest_device_id(hass: HomeAssistant, device_id: str) -> str | None: - """Get the nest API device_id from the HomeAssistant device_id.""" - device_registry = dr.async_get(hass) - if device := device_registry.async_get(device_id): - for (domain, unique_id) in device.identifiers: - if domain == DOMAIN: - return unique_id - return None - - -@callback -def async_get_device_trigger_types( - hass: HomeAssistant, nest_device_id: str -) -> list[str]: - """List event triggers supported for a Nest device.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] - if not (nest_device := device_manager.devices.get(nest_device_id)): - raise InvalidDeviceAutomationConfig(f"Nest device not found {nest_device_id}") - - # Determine the set of event types based on the supported device traits - trigger_types = [ - trigger_type - for trait in nest_device.traits - if (trigger_type := DEVICE_TRAIT_TRIGGER_MAP.get(trait)) - ] - return trigger_types - - async def async_get_triggers( hass: HomeAssistant, device_id: str ) -> list[dict[str, str]]: """List device triggers for a Nest device.""" - nest_device_id = async_get_nest_device_id(hass, device_id) - if not nest_device_id: + devices = async_nest_devices_by_device_id(hass) + if not (device := devices.get(device_id)): raise InvalidDeviceAutomationConfig(f"Device not found {device_id}") - trigger_types = async_get_device_trigger_types(hass, nest_device_id) + trigger_types = [ + trigger_type + for trait in device.traits + if (trigger_type := DEVICE_TRAIT_TRIGGER_MAP.get(trait)) + ] return [ { CONF_PLATFORM: DEVICE, diff --git a/homeassistant/components/nest/diagnostics.py b/homeassistant/components/nest/diagnostics.py index c21842d5939..d350b719608 100644 --- a/homeassistant/components/nest/diagnostics.py +++ b/homeassistant/components/nest/diagnostics.py @@ -27,10 +27,15 @@ def _async_get_nest_devices( if DATA_SDM not in config_entry.data: return {} - if DATA_DEVICE_MANAGER not in hass.data[DOMAIN]: + if ( + config_entry.entry_id not in hass.data[DOMAIN] + or DATA_DEVICE_MANAGER not in hass.data[DOMAIN][config_entry.entry_id] + ): return {} - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][config_entry.entry_id][ + DATA_DEVICE_MANAGER + ] return device_manager.devices diff --git a/homeassistant/components/nest/media_source.py b/homeassistant/components/nest/media_source.py index e4e26153b3a..4614d4b1ed4 100644 --- a/homeassistant/components/nest/media_source.py +++ b/homeassistant/components/nest/media_source.py @@ -25,7 +25,6 @@ import os from google_nest_sdm.camera_traits import CameraClipPreviewTrait, CameraEventImageTrait from google_nest_sdm.device import Device -from google_nest_sdm.device_manager import DeviceManager from google_nest_sdm.event import EventImageType, ImageEventBase from google_nest_sdm.event_media import ( ClipPreviewSession, @@ -57,8 +56,8 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util import dt as dt_util -from .const import DATA_DEVICE_MANAGER, DOMAIN -from .device_info import NestDeviceInfo +from .const import DOMAIN +from .device_info import NestDeviceInfo, async_nest_devices_by_device_id from .events import EVENT_NAME_MAP, MEDIA_SOURCE_EVENT_TITLE_MAP _LOGGER = logging.getLogger(__name__) @@ -271,21 +270,13 @@ async def async_get_media_source(hass: HomeAssistant) -> MediaSource: @callback def async_get_media_source_devices(hass: HomeAssistant) -> Mapping[str, Device]: """Return a mapping of device id to eligible Nest event media devices.""" - if DATA_DEVICE_MANAGER not in hass.data[DOMAIN]: - # Integration unloaded, or is legacy nest integration - return {} - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] - device_registry = dr.async_get(hass) - devices = {} - for device in device_manager.devices.values(): - if not ( - CameraEventImageTrait.NAME in device.traits - or CameraClipPreviewTrait.NAME in device.traits - ): - continue - if device_entry := device_registry.async_get_device({(DOMAIN, device.name)}): - devices[device_entry.id] = device - return devices + devices = async_nest_devices_by_device_id(hass) + return { + device_id: device + for device_id, device in devices.items() + if CameraEventImageTrait.NAME in device.traits + or CameraClipPreviewTrait.NAME in device.traits + } @dataclass diff --git a/homeassistant/components/nest/sensor_sdm.py b/homeassistant/components/nest/sensor_sdm.py index d33aa3eff8b..c6d1c8b2b30 100644 --- a/homeassistant/components/nest/sensor_sdm.py +++ b/homeassistant/components/nest/sensor_sdm.py @@ -36,7 +36,9 @@ async def async_setup_sdm_entry( ) -> None: """Set up the sensors.""" - device_manager: DeviceManager = hass.data[DOMAIN][DATA_DEVICE_MANAGER] + device_manager: DeviceManager = hass.data[DOMAIN][entry.entry_id][ + DATA_DEVICE_MANAGER + ] entities: list[SensorEntity] = [] for device in device_manager.devices.values(): if TemperatureTrait.NAME in device.traits: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 212903179b7..0a13de41511 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -69,7 +69,7 @@ "subscriber_error": "Unknown subscriber error, see logs" }, "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", "unknown_authorize_url_generation": "[%key:common::config_flow::abort::unknown_authorize_url_generation%]", diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index bacb3924bcd..458685cde70 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -24,6 +24,7 @@ from homeassistant.setup import async_setup_component from .common import ( DEVICE_ID, + PROJECT_ID, SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, TEST_CONFIG_YAML_ONLY, @@ -213,11 +214,18 @@ def config( return config +@pytest.fixture +def config_entry_unique_id() -> str: + """Fixture to set ConfigEntry unique id.""" + return PROJECT_ID + + @pytest.fixture def config_entry( subscriber_id: str | None, auth_implementation: str | None, nest_test_config: NestTestConfig, + config_entry_unique_id: str, ) -> MockConfigEntry | None: """Fixture that sets up the ConfigEntry for the test.""" if nest_test_config.config_entry_data is None: @@ -229,7 +237,7 @@ def config_entry( else: del data[CONF_SUBSCRIBER_ID] data["auth_implementation"] = auth_implementation - return MockConfigEntry(domain=DOMAIN, data=data) + return MockConfigEntry(domain=DOMAIN, data=data, unique_id=config_entry_unique_id) @pytest.fixture(autouse=True) diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index f4299808bf0..53a2d9cf2b6 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -76,8 +76,8 @@ class OAuthFixture: assert result.get("type") == "form" assert result.get("step_id") == "device_project" - result = await self.async_configure(result, {"project_id": PROJECT_ID}) - await self.async_oauth_web_flow(result) + result = await self.async_configure(result, {"project_id": project_id}) + await self.async_oauth_web_flow(result, project_id=project_id) async def async_oauth_web_flow(self, result: dict, project_id=PROJECT_ID) -> None: """Invoke the oauth flow for Web Auth with fake responses.""" @@ -404,7 +404,7 @@ async def test_web_reauth(hass, oauth, setup_platform, config_entry): entry = await oauth.async_finish_setup(result) # Verify existing tokens are replaced entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN + assert entry.unique_id == PROJECT_ID assert entry.data["token"] == { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -415,25 +415,51 @@ async def test_web_reauth(hass, oauth, setup_platform, config_entry): assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated -async def test_single_config_entry(hass, setup_platform): - """Test that only a single config entry is allowed.""" +async def test_multiple_config_entries(hass, oauth, setup_platform): + """Verify config flow can be started when existing config entry exists.""" await setup_platform() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "abort" - assert result["reason"] == "single_instance_allowed" + await oauth.async_app_creds_flow(result, project_id="project-id-2") + entry = await oauth.async_finish_setup(result) + assert entry.title == "Mock Title" + assert "token" in entry.data + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 2 -async def test_unexpected_existing_config_entries( +async def test_duplicate_config_entries(hass, oauth, setup_platform): + """Verify that config entries must be for unique projects.""" + await setup_platform() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == "form" + assert result.get("step_id") == "cloud_project" + + result = await oauth.async_configure(result, {"cloud_project_id": CLOUD_PROJECT_ID}) + assert result.get("type") == "form" + assert result.get("step_id") == "device_project" + + result = await oauth.async_configure(result, {"project_id": PROJECT_ID}) + assert result.get("type") == "abort" + assert result.get("reason") == "already_configured" + + +async def test_reauth_multiple_config_entries( hass, oauth, setup_platform, config_entry ): """Test Nest reauthentication with multiple existing config entries.""" - # Note that this case will not happen in the future since only a single - # instance is now allowed, but this may have been allowed in the past. - # On reauth, only one entry is kept and the others are deleted. - await setup_platform() old_entry = MockConfigEntry( @@ -461,7 +487,7 @@ async def test_unexpected_existing_config_entries( entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 2 entry = entries[0] - assert entry.unique_id == DOMAIN + assert entry.unique_id == PROJECT_ID entry.data["token"].pop("expires_at") assert entry.data["token"] == { "refresh_token": "mock-refresh-token", @@ -540,7 +566,7 @@ async def test_app_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): # Verify existing tokens are replaced entry = await oauth.async_finish_setup(result, {"code": "1234"}) entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN + assert entry.unique_id == PROJECT_ID assert entry.data["token"] == { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -570,7 +596,7 @@ async def test_web_auth_yaml_reauth(hass, oauth, setup_platform, config_entry): # Verify existing tokens are replaced entry = await oauth.async_finish_setup(result, {"code": "1234"}) entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN + assert entry.unique_id == PROJECT_ID assert entry.data["token"] == { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -599,7 +625,7 @@ async def test_pubsub_subscription_strip_whitespace( assert entry.title == "Import from configuration.yaml" assert "token" in entry.data entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN + assert entry.unique_id == PROJECT_ID assert entry.data["token"] == { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", @@ -643,7 +669,7 @@ async def test_pubsub_subscriber_config_entry_reauth( # Entering an updated access token refreshs the config entry. entry = await oauth.async_finish_setup(result, {"code": "1234"}) entry.data["token"].pop("expires_at") - assert entry.unique_id == DOMAIN + assert entry.unique_id == PROJECT_ID assert entry.data["token"] == { "refresh_token": "mock-refresh-token", "access_token": "mock-access-token", diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index 1b473ccd62f..d7c82609c60 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -103,18 +103,18 @@ async def test_setup_configuration_failure( @pytest.mark.parametrize("subscriber_side_effect", [SubscriberException()]) async def test_setup_susbcriber_failure( - hass, error_caplog, failing_subscriber, setup_base_platform + hass, warning_caplog, failing_subscriber, setup_base_platform ): """Test configuration error.""" await setup_base_platform() - assert "Subscriber error:" in error_caplog.text + assert "Subscriber error:" in warning_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 assert entries[0].state is ConfigEntryState.SETUP_RETRY -async def test_setup_device_manager_failure(hass, error_caplog, setup_base_platform): +async def test_setup_device_manager_failure(hass, warning_caplog, setup_base_platform): """Test device manager api failure.""" with patch( "homeassistant.components.nest.api.GoogleNestSubscriber.start_async" @@ -124,8 +124,7 @@ async def test_setup_device_manager_failure(hass, error_caplog, setup_base_platf ): await setup_base_platform() - assert len(error_caplog.messages) == 1 - assert "Device manager error:" in error_caplog.text + assert "Device manager error:" in warning_caplog.text entries = hass.config_entries.async_entries(DOMAIN) assert len(entries) == 1 @@ -273,3 +272,18 @@ async def test_remove_entry_delete_subscriber_failure( entries = hass.config_entries.async_entries(DOMAIN) assert not entries + + +@pytest.mark.parametrize("config_entry_unique_id", [DOMAIN, None]) +async def test_migrate_unique_id( + hass, error_caplog, setup_platform, config_entry, config_entry_unique_id +): + """Test successful setup.""" + + assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry.unique_id == config_entry_unique_id + + await setup_platform() + + assert config_entry.state is ConfigEntryState.LOADED + assert config_entry.unique_id == PROJECT_ID From 27209574d256d3205cc2bbb63c41d0cb4b116a85 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 21 Jun 2022 16:50:44 +0200 Subject: [PATCH 553/947] Use pydeconz interface controls for lock, scene, siren and switch platforms (#73748) --- homeassistant/components/deconz/lock.py | 22 ++++++++++++++++++++-- homeassistant/components/deconz/scene.py | 5 ++++- homeassistant/components/deconz/siren.py | 14 ++++++++++---- homeassistant/components/deconz/switch.py | 10 ++++++++-- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/deconz/lock.py b/homeassistant/components/deconz/lock.py index 78ccae30441..cf4bd7f14f5 100644 --- a/homeassistant/components/deconz/lock.py +++ b/homeassistant/components/deconz/lock.py @@ -62,8 +62,26 @@ class DeconzLock(DeconzDevice, LockEntity): async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - await self._device.lock() + if isinstance(self._device, DoorLock): + await self.gateway.api.sensors.door_lock.set_config( + id=self._device.resource_id, + lock=True, + ) + else: + await self.gateway.api.lights.locks.set_state( + id=self._device.resource_id, + lock=True, + ) async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - await self._device.unlock() + if isinstance(self._device, DoorLock): + await self.gateway.api.sensors.door_lock.set_config( + id=self._device.resource_id, + lock=False, + ) + else: + await self.gateway.api.lights.locks.set_state( + id=self._device.resource_id, + lock=False, + ) diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index dfbb6ae828b..236389cc100 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -43,4 +43,7 @@ class DeconzScene(DeconzSceneMixin, Scene): async def async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" - await self._device.recall() + await self.gateway.api.scenes.recall( + self._device.group_id, + self._device.id, + ) diff --git a/homeassistant/components/deconz/siren.py b/homeassistant/components/deconz/siren.py index 8427b6ce75d..d44bce01aad 100644 --- a/homeassistant/components/deconz/siren.py +++ b/homeassistant/components/deconz/siren.py @@ -59,11 +59,17 @@ class DeconzSiren(DeconzDevice, SirenEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on siren.""" - data = {} if (duration := kwargs.get(ATTR_DURATION)) is not None: - data["duration"] = duration * 10 - await self._device.turn_on(**data) + duration *= 10 + await self.gateway.api.lights.sirens.set_state( + id=self._device.resource_id, + on=True, + duration=duration, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off siren.""" - await self._device.turn_off() + await self.gateway.api.lights.sirens.set_state( + id=self._device.resource_id, + on=False, + ) diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index d54ff1f36ba..b21ec929909 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -56,8 +56,14 @@ class DeconzPowerPlug(DeconzDevice, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on switch.""" - await self._device.set_state(on=True) + await self.gateway.api.lights.lights.set_state( + id=self._device.resource_id, + on=True, + ) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off switch.""" - await self._device.set_state(on=False) + await self.gateway.api.lights.lights.set_state( + id=self._device.resource_id, + on=False, + ) From eac7c5f177bf5702fb42a4eab49de348c9e32f40 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 21 Jun 2022 17:11:20 +0200 Subject: [PATCH 554/947] Remove deprecated X-Hassio-Key usage (#73783) * Remove deprecated X-Hassio-Key usage * ... * Update const.py * Update ingress.py * Update test_ingress.py Co-authored-by: Ludeeus --- homeassistant/components/hassio/const.py | 3 +-- homeassistant/components/hassio/handler.py | 6 +++--- homeassistant/components/hassio/http.py | 14 ++++++++------ homeassistant/components/hassio/ingress.py | 4 ++-- tests/components/hassio/test_ingress.py | 16 +++++++++------- tests/components/hassio/test_init.py | 2 +- 6 files changed, 24 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 2d99b1f5605..e4991e5fc03 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -25,8 +25,7 @@ ATTR_METHOD = "method" ATTR_RESULT = "result" ATTR_TIMEOUT = "timeout" - -X_HASSIO = "X-Hassio-Key" +X_AUTH_TOKEN = "X-Supervisor-Token" X_INGRESS_PATH = "X-Ingress-Path" X_HASS_USER_ID = "X-Hass-User-ID" X_HASS_IS_ADMIN = "X-Hass-Is-Admin" diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index ba1b3bfaf35..7b3ed697227 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -13,8 +13,6 @@ from homeassistant.components.http import ( ) from homeassistant.const import SERVER_PORT -from .const import X_HASSIO - _LOGGER = logging.getLogger(__name__) @@ -246,7 +244,9 @@ class HassIO: method, f"http://{self._ip}{command}", json=payload, - headers={X_HASSIO: os.environ.get("SUPERVISOR_TOKEN", "")}, + headers={ + aiohttp.hdrs.AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}" + }, timeout=aiohttp.ClientTimeout(total=timeout), ) diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 3f492114545..7d2e79956cc 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -11,6 +11,7 @@ import aiohttp from aiohttp import web from aiohttp.client import ClientTimeout from aiohttp.hdrs import ( + AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, @@ -18,11 +19,12 @@ from aiohttp.hdrs import ( TRANSFER_ENCODING, ) from aiohttp.web_exceptions import HTTPBadGateway +from multidict import istr from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.onboarding import async_is_onboarded -from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID, X_HASSIO +from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID _LOGGER = logging.getLogger(__name__) @@ -89,7 +91,7 @@ class HassIOView(HomeAssistantView): if path == "backups/new/upload": # We need to reuse the full content type that includes the boundary headers[ - "Content-Type" + CONTENT_TYPE ] = request._stored_content_type # pylint: disable=protected-access try: @@ -123,17 +125,17 @@ class HassIOView(HomeAssistantView): raise HTTPBadGateway() -def _init_header(request: web.Request) -> dict[str, str]: +def _init_header(request: web.Request) -> dict[istr, str]: """Create initial header.""" headers = { - X_HASSIO: os.environ.get("SUPERVISOR_TOKEN", ""), + AUTHORIZATION: f"Bearer {os.environ.get('SUPERVISOR_TOKEN', '')}", CONTENT_TYPE: request.content_type, } # Add user data if request.get("hass_user") is not None: - headers[X_HASS_USER_ID] = request["hass_user"].id - headers[X_HASS_IS_ADMIN] = str(int(request["hass_user"].is_admin)) + headers[istr(X_HASS_USER_ID)] = request["hass_user"].id + headers[istr(X_HASS_IS_ADMIN)] = str(int(request["hass_user"].is_admin)) return headers diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index c8b56e6f1bb..6caa97b788f 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -15,7 +15,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import X_HASSIO, X_INGRESS_PATH +from .const import X_AUTH_TOKEN, X_INGRESS_PATH _LOGGER = logging.getLogger(__name__) @@ -183,7 +183,7 @@ def _init_header(request: web.Request, token: str) -> CIMultiDict | dict[str, st headers[name] = value # Inject token / cleanup later on Supervisor - headers[X_HASSIO] = os.environ.get("SUPERVISOR_TOKEN", "") + headers[X_AUTH_TOKEN] = os.environ.get("SUPERVISOR_TOKEN", "") # Ingress information headers[X_INGRESS_PATH] = f"/api/hassio_ingress/{token}" diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py index 60fea96d4ea..34016fa9052 100644 --- a/tests/components/hassio/test_ingress.py +++ b/tests/components/hassio/test_ingress.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO import pytest +from homeassistant.components.hassio.const import X_AUTH_TOKEN + @pytest.mark.parametrize( "build_type", @@ -35,7 +37,7 @@ async def test_ingress_request_get(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -75,7 +77,7 @@ async def test_ingress_request_post(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -115,7 +117,7 @@ async def test_ingress_request_put(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -155,7 +157,7 @@ async def test_ingress_request_delete(hassio_client, build_type, aioclient_mock) # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -195,7 +197,7 @@ async def test_ingress_request_patch(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -235,7 +237,7 @@ async def test_ingress_request_options(hassio_client, build_type, aioclient_mock # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" @@ -268,7 +270,7 @@ async def test_ingress_websocket(hassio_client, build_type, aioclient_mock): # Check we forwarded command assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3][X_AUTH_TOKEN] == "123456" assert ( aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == f"/api/hassio_ingress/{build_type[0]}" diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 6ac3debe3d8..60fec517aa9 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -348,7 +348,7 @@ async def test_setup_hassio_no_additional_data(hass, aioclient_mock): assert result assert aioclient_mock.call_count == 15 - assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456" + assert aioclient_mock.mock_calls[-1][3]["Authorization"] == "Bearer 123456" async def test_fail_setup_without_environ_var(hass): From 28cc0b9fb2d3a28860bad702c9038a65e39f468b Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 21 Jun 2022 17:31:26 +0200 Subject: [PATCH 555/947] Expose ThreeWayWindowHandle direction as sensor in Overkiz integration (#73784) * Expose ThreeWayWindowHandle direction as sensor * Compile translations --- homeassistant/components/overkiz/entity.py | 1 + homeassistant/components/overkiz/manifest.json | 2 +- homeassistant/components/overkiz/sensor.py | 6 ++++++ homeassistant/components/overkiz/strings.sensor.json | 5 +++++ .../components/overkiz/translations/sensor.en.json | 5 +++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/overkiz/entity.py b/homeassistant/components/overkiz/entity.py index a177766c292..5728349c5d0 100644 --- a/homeassistant/components/overkiz/entity.py +++ b/homeassistant/components/overkiz/entity.py @@ -118,3 +118,4 @@ class OverkizDeviceClass(StrEnum): PRIORITY_LOCK_ORIGINATOR = "overkiz__priority_lock_originator" SENSOR_DEFECT = "overkiz__sensor_defect" SENSOR_ROOM = "overkiz__sensor_room" + THREE_WAY_HANDLE_DIRECTION = "overkiz__three_way_handle_direction" diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index fa89be5d19e..a7595065224 100644 --- a/homeassistant/components/overkiz/manifest.json +++ b/homeassistant/components/overkiz/manifest.json @@ -3,7 +3,7 @@ "name": "Overkiz (by Somfy)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/overkiz", - "requirements": ["pyoverkiz==1.4.1"], + "requirements": ["pyoverkiz==1.4.2"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 10de6f699dd..ac32c76c459 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -366,6 +366,12 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ native_unit_of_measurement=PERCENTAGE, entity_registry_enabled_default=False, ), + # ThreeWayWindowHandle/WindowHandle + OverkizSensorDescription( + key=OverkizState.CORE_THREE_WAY_HANDLE_DIRECTION, + name="Three Way Handle Direction", + device_class=OverkizDeviceClass.THREE_WAY_HANDLE_DIRECTION, + ), ] SUPPORTED_STATES = {description.key: description for description in SENSOR_DESCRIPTIONS} diff --git a/homeassistant/components/overkiz/strings.sensor.json b/homeassistant/components/overkiz/strings.sensor.json index 4df83bcad77..fdeaa5b911b 100644 --- a/homeassistant/components/overkiz/strings.sensor.json +++ b/homeassistant/components/overkiz/strings.sensor.json @@ -36,6 +36,11 @@ "low_battery": "Low battery", "maintenance_required": "Maintenance required", "no_defect": "No defect" + }, + "overkiz__three_way_handle_direction": { + "closed": "Closed", + "open": "Open", + "tilt": "Tilt" } } } diff --git a/homeassistant/components/overkiz/translations/sensor.en.json b/homeassistant/components/overkiz/translations/sensor.en.json index c0eef6b3ef6..13a10f9a072 100644 --- a/homeassistant/components/overkiz/translations/sensor.en.json +++ b/homeassistant/components/overkiz/translations/sensor.en.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Clean", "dirty": "Dirty" + }, + "overkiz__three_way_handle_direction": { + "closed": "Closed", + "open": "Open", + "tilt": "Tilt" } } } \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index ece350a33c6..2daa37f0808 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1720,7 +1720,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.4.1 +pyoverkiz==1.4.2 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7c81558ac5..fd02dc2ce5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1169,7 +1169,7 @@ pyotgw==1.1b1 pyotp==2.6.0 # homeassistant.components.overkiz -pyoverkiz==1.4.1 +pyoverkiz==1.4.2 # homeassistant.components.openweathermap pyowm==3.2.0 From efb4b10629c643473f050049c8f04fad8548bae8 Mon Sep 17 00:00:00 2001 From: AdmiralStipe <64564398+AdmiralStipe@users.noreply.github.com> Date: Tue, 21 Jun 2022 18:05:16 +0200 Subject: [PATCH 556/947] Change Microsoft TTS default and not configurable audio settings from poor 16kHz/128kbit/s to better quality 24kHz/96kbit/s (#73609) * Update tts.py * Update tts.py * Update tts.py --- homeassistant/components/microsoft/tts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index 59902335d47..840b35c2f85 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -96,7 +96,7 @@ GENDERS = ["Female", "Male"] DEFAULT_LANG = "en-us" DEFAULT_GENDER = "Female" DEFAULT_TYPE = "JennyNeural" -DEFAULT_OUTPUT = "audio-16khz-128kbitrate-mono-mp3" +DEFAULT_OUTPUT = "audio-24khz-96kbitrate-mono-mp3" DEFAULT_RATE = 0 DEFAULT_VOLUME = 0 DEFAULT_PITCH = "default" From f285b6099a7b0b2ed61dc6738c0000537ee2b3c0 Mon Sep 17 00:00:00 2001 From: rappenze Date: Tue, 21 Jun 2022 18:08:47 +0200 Subject: [PATCH 557/947] Code cleanup fibaro sensor (#73388) * Code cleanup fibaro sensor * Adjustments based on code review * Changes from code review, use dict instead of tuple * Remove unneeded deafult in dict get * Another variant to create dict --- homeassistant/components/fibaro/sensor.py | 232 ++++++++++------------ 1 file changed, 108 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index acaa97ee2a2..88d6113ebb9 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -2,11 +2,13 @@ from __future__ import annotations from contextlib import suppress +from typing import Any from homeassistant.components.sensor import ( ENTITY_ID_FORMAT, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -27,49 +29,73 @@ from homeassistant.util import convert from . import FIBARO_DEVICES, FibaroDevice from .const import DOMAIN -SENSOR_TYPES = { - "com.fibaro.temperatureSensor": [ - "Temperature", - None, - None, - SensorDeviceClass.TEMPERATURE, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.smokeSensor": [ - "Smoke", - CONCENTRATION_PARTS_PER_MILLION, - "mdi:fire", - None, - None, - ], - "CO2": [ - "CO2", - CONCENTRATION_PARTS_PER_MILLION, - None, - SensorDeviceClass.CO2, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.humiditySensor": [ - "Humidity", - PERCENTAGE, - None, - SensorDeviceClass.HUMIDITY, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.lightSensor": [ - "Light", - LIGHT_LUX, - None, - SensorDeviceClass.ILLUMINANCE, - SensorStateClass.MEASUREMENT, - ], - "com.fibaro.energyMeter": [ - "Energy", - ENERGY_KILO_WATT_HOUR, - None, - SensorDeviceClass.ENERGY, - SensorStateClass.TOTAL_INCREASING, - ], +# List of known sensors which represents a fibaro device +MAIN_SENSOR_TYPES: dict[str, SensorEntityDescription] = { + "com.fibaro.temperatureSensor": SensorEntityDescription( + key="com.fibaro.temperatureSensor", + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.smokeSensor": SensorEntityDescription( + key="com.fibaro.smokeSensor", + name="Smoke", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + icon="mdi:fire", + ), + "CO2": SensorEntityDescription( + key="CO2", + name="CO2", + native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + device_class=SensorDeviceClass.CO2, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.humiditySensor": SensorEntityDescription( + key="com.fibaro.humiditySensor", + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.lightSensor": SensorEntityDescription( + key="com.fibaro.lightSensor", + name="Light", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + "com.fibaro.energyMeter": SensorEntityDescription( + key="com.fibaro.energyMeter", + name="Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), +} + +# List of additional sensors which are created based on a property +# The key is the property name +ADDITIONAL_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="energy", + name="Energy", + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + ), + SensorEntityDescription( + key="power", + name="Power", + native_unit_of_measurement=POWER_WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +FIBARO_TO_HASS_UNIT: dict[str, str] = { + "lux": LIGHT_LUX, + "C": TEMP_CELSIUS, + "F": TEMP_FAHRENHEIT, } @@ -80,14 +106,18 @@ async def async_setup_entry( ) -> None: """Set up the Fibaro controller devices.""" entities: list[SensorEntity] = [] + for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][Platform.SENSOR]: - entities.append(FibaroSensor(device)) + entity_description = MAIN_SENSOR_TYPES.get(device.type) + + # main sensors are created even if the entity type is not known + entities.append(FibaroSensor(device, entity_description)) + for platform in (Platform.COVER, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH): for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][platform]: - if "energy" in device.interfaces: - entities.append(FibaroEnergySensor(device)) - if "power" in device.interfaces: - entities.append(FibaroPowerSensor(device)) + for entity_description in ADDITIONAL_SENSOR_TYPES: + if entity_description.key in device.properties: + entities.append(FibaroAdditionalSensor(device, entity_description)) async_add_entities(entities, True) @@ -95,97 +125,51 @@ async def async_setup_entry( class FibaroSensor(FibaroDevice, SensorEntity): """Representation of a Fibaro Sensor.""" - def __init__(self, fibaro_device): + def __init__( + self, fibaro_device: Any, entity_description: SensorEntityDescription | None + ) -> None: """Initialize the sensor.""" - self.current_value = None - self.last_changed_time = None super().__init__(fibaro_device) + if entity_description is not None: + self.entity_description = entity_description self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) - if fibaro_device.type in SENSOR_TYPES: - self._unit = SENSOR_TYPES[fibaro_device.type][1] - self._icon = SENSOR_TYPES[fibaro_device.type][2] - self._device_class = SENSOR_TYPES[fibaro_device.type][3] - self._attr_state_class = SENSOR_TYPES[fibaro_device.type][4] - else: - self._unit = None - self._icon = None - self._device_class = None + + # Map unit if it was not defined in the entity description + # or there is no entity description at all with suppress(KeyError, ValueError): - if not self._unit: - if self.fibaro_device.properties.unit == "lux": - self._unit = LIGHT_LUX - elif self.fibaro_device.properties.unit == "C": - self._unit = TEMP_CELSIUS - elif self.fibaro_device.properties.unit == "F": - self._unit = TEMP_FAHRENHEIT - else: - self._unit = self.fibaro_device.properties.unit - - @property - def native_value(self): - """Return the state of the sensor.""" - return self.current_value - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return self._unit - - @property - def icon(self): - """Icon to use in the frontend, if any.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class + if not self.native_unit_of_measurement: + self._attr_native_unit_of_measurement = FIBARO_TO_HASS_UNIT.get( + fibaro_device.properties.unit, fibaro_device.properties.unit + ) def update(self): """Update the state.""" with suppress(KeyError, ValueError): - self.current_value = float(self.fibaro_device.properties.value) + self._attr_native_value = float(self.fibaro_device.properties.value) -class FibaroEnergySensor(FibaroDevice, SensorEntity): - """Representation of a Fibaro Energy Sensor.""" +class FibaroAdditionalSensor(FibaroDevice, SensorEntity): + """Representation of a Fibaro Additional Sensor.""" - _attr_device_class = SensorDeviceClass.ENERGY - _attr_state_class = SensorStateClass.TOTAL_INCREASING - _attr_native_unit_of_measurement = ENERGY_KILO_WATT_HOUR - - def __init__(self, fibaro_device): + def __init__( + self, fibaro_device: Any, entity_description: SensorEntityDescription + ) -> None: """Initialize the sensor.""" super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(f"{self.ha_id}_energy") - self._attr_name = f"{fibaro_device.friendly_name} Energy" - self._attr_unique_id = f"{fibaro_device.unique_id_str}_energy" + self.entity_description = entity_description - def update(self): + # To differentiate additional sensors from main sensors they need + # to get different names and ids + self.entity_id = ENTITY_ID_FORMAT.format( + f"{self.ha_id}_{entity_description.key}" + ) + self._attr_name = f"{fibaro_device.friendly_name} {entity_description.name}" + self._attr_unique_id = f"{fibaro_device.unique_id_str}_{entity_description.key}" + + def update(self) -> None: """Update the state.""" with suppress(KeyError, ValueError): self._attr_native_value = convert( - self.fibaro_device.properties.energy, float - ) - - -class FibaroPowerSensor(FibaroDevice, SensorEntity): - """Representation of a Fibaro Power Sensor.""" - - _attr_device_class = SensorDeviceClass.POWER - _attr_state_class = SensorStateClass.MEASUREMENT - _attr_native_unit_of_measurement = POWER_WATT - - def __init__(self, fibaro_device): - """Initialize the sensor.""" - super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(f"{self.ha_id}_power") - self._attr_name = f"{fibaro_device.friendly_name} Power" - self._attr_unique_id = f"{fibaro_device.unique_id_str}_power" - - def update(self): - """Update the state.""" - with suppress(KeyError, ValueError): - self._attr_native_value = convert( - self.fibaro_device.properties.power, float + self.fibaro_device.properties[self.entity_description.key], + float, ) From a816348616aaed030c5c90e5a1de816e53ba16e8 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Wed, 22 Jun 2022 02:12:11 +1000 Subject: [PATCH 558/947] Powerview dataclass (#73746) Co-authored-by: J. Nick Koston --- .coveragerc | 1 + .../hunterdouglas_powerview/__init__.py | 56 +++++++------------ .../hunterdouglas_powerview/button.py | 31 +++------- .../hunterdouglas_powerview/config_flow.py | 8 +-- .../hunterdouglas_powerview/const.py | 19 +------ .../hunterdouglas_powerview/cover.py | 39 +++++-------- .../hunterdouglas_powerview/diagnostics.py | 28 ++++------ .../hunterdouglas_powerview/entity.py | 32 ++++------- .../hunterdouglas_powerview/model.py | 33 +++++++++++ .../hunterdouglas_powerview/scene.py | 29 +++------- .../hunterdouglas_powerview/sensor.py | 27 ++++----- 11 files changed, 129 insertions(+), 174 deletions(-) create mode 100644 homeassistant/components/hunterdouglas_powerview/model.py diff --git a/.coveragerc b/.coveragerc index eba89e5f238..0e7a7324064 100644 --- a/.coveragerc +++ b/.coveragerc @@ -512,6 +512,7 @@ omit = homeassistant/components/hunterdouglas_powerview/cover.py homeassistant/components/hunterdouglas_powerview/diagnostics.py homeassistant/components/hunterdouglas_powerview/entity.py + homeassistant/components/hunterdouglas_powerview/model.py homeassistant/components/hunterdouglas_powerview/scene.py homeassistant/components/hunterdouglas_powerview/sensor.py homeassistant/components/hunterdouglas_powerview/shade_data.py diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py index fe039964e5d..4a22bc4ed81 100644 --- a/homeassistant/components/hunterdouglas_powerview/__init__.py +++ b/homeassistant/components/hunterdouglas_powerview/__init__.py @@ -19,29 +19,14 @@ import homeassistant.helpers.config_validation as cv from .const import ( API_PATH_FWVERSION, - COORDINATOR, DEFAULT_LEGACY_MAINPROCESSOR, - DEVICE_FIRMWARE, - DEVICE_INFO, - DEVICE_MAC_ADDRESS, - DEVICE_MODEL, - DEVICE_NAME, - DEVICE_REVISION, - DEVICE_SERIAL_NUMBER, DOMAIN, FIRMWARE, FIRMWARE_MAINPROCESSOR, FIRMWARE_NAME, - FIRMWARE_REVISION, HUB_EXCEPTIONS, HUB_NAME, MAC_ADDRESS_IN_USERDATA, - PV_API, - PV_HUB_ADDRESS, - PV_ROOM_DATA, - PV_SCENE_DATA, - PV_SHADE_DATA, - PV_SHADES, ROOM_DATA, SCENE_DATA, SERIAL_NUMBER_IN_USERDATA, @@ -49,6 +34,7 @@ from .const import ( USER_DATA, ) from .coordinator import PowerviewShadeUpdateCoordinator +from .model import PowerviewDeviceInfo, PowerviewEntryData from .shade_data import PowerviewShadeData from .util import async_map_data_by_id @@ -72,8 +58,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: async with async_timeout.timeout(10): - device_info = await async_get_device_info(pv_request) - device_info[PV_HUB_ADDRESS] = hub_address + device_info = await async_get_device_info(pv_request, hub_address) async with async_timeout.timeout(10): rooms = Rooms(pv_request) @@ -102,22 +87,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # populate raw shade data into the coordinator for diagnostics coordinator.data.store_group_data(shade_entries[SHADE_DATA]) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - PV_API: pv_request, - PV_ROOM_DATA: room_data, - PV_SCENE_DATA: scene_data, - PV_SHADES: shades, - PV_SHADE_DATA: shade_data, - COORDINATOR: coordinator, - DEVICE_INFO: device_info, - } + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = PowerviewEntryData( + api=pv_request, + room_data=room_data, + scene_data=scene_data, + shade_data=shade_data, + coordinator=coordinator, + device_info=device_info, + ) hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True -async def async_get_device_info(pv_request): +async def async_get_device_info( + pv_request: AioRequest, hub_address: str +) -> PowerviewDeviceInfo: """Determine device info.""" userdata = UserData(pv_request) resources = await userdata.get_resources() @@ -135,14 +121,14 @@ async def async_get_device_info(pv_request): else: main_processor_info = DEFAULT_LEGACY_MAINPROCESSOR - return { - DEVICE_NAME: base64_to_unicode(userdata_data[HUB_NAME]), - DEVICE_MAC_ADDRESS: userdata_data[MAC_ADDRESS_IN_USERDATA], - DEVICE_SERIAL_NUMBER: userdata_data[SERIAL_NUMBER_IN_USERDATA], - DEVICE_REVISION: main_processor_info[FIRMWARE_REVISION], - DEVICE_FIRMWARE: main_processor_info, - DEVICE_MODEL: main_processor_info[FIRMWARE_NAME], - } + return PowerviewDeviceInfo( + name=base64_to_unicode(userdata_data[HUB_NAME]), + mac_address=userdata_data[MAC_ADDRESS_IN_USERDATA], + serial_number=userdata_data[SERIAL_NUMBER_IN_USERDATA], + firmware=main_processor_info, + model=main_processor_info[FIRMWARE_NAME], + hub_address=hub_address, + ) async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/hunterdouglas_powerview/button.py b/homeassistant/components/hunterdouglas_powerview/button.py index b13d0217a20..483e2ca2784 100644 --- a/homeassistant/components/hunterdouglas_powerview/button.py +++ b/homeassistant/components/hunterdouglas_powerview/button.py @@ -13,18 +13,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - COORDINATOR, - DEVICE_INFO, - DOMAIN, - PV_API, - PV_ROOM_DATA, - PV_SHADE_DATA, - ROOM_ID_IN_SHADE, - ROOM_NAME_UNICODE, -) +from .const import DOMAIN, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity +from .model import PowerviewDeviceInfo, PowerviewEntryData @dataclass @@ -66,25 +58,20 @@ async def async_setup_entry( ) -> None: """Set up the hunter douglas advanced feature buttons.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - room_data: dict[str | int, Any] = pv_data[PV_ROOM_DATA] - shade_data = pv_data[PV_SHADE_DATA] - pv_request = pv_data[PV_API] - coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR] - device_info: dict[str, Any] = pv_data[DEVICE_INFO] + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities: list[ButtonEntity] = [] - for raw_shade in shade_data.values(): - shade: BaseShade = PvShade(raw_shade, pv_request) + for raw_shade in pv_entry.shade_data.values(): + shade: BaseShade = PvShade(raw_shade, pv_entry.api) name_before_refresh = shade.name room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") for description in BUTTONS: entities.append( PowerviewButton( - coordinator, - device_info, + pv_entry.coordinator, + pv_entry.device_info, room_name, shade, name_before_refresh, @@ -101,7 +88,7 @@ class PowerviewButton(ShadeEntity, ButtonEntity): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, name: str, diff --git a/homeassistant/components/hunterdouglas_powerview/config_flow.py b/homeassistant/components/hunterdouglas_powerview/config_flow.py index e0ebef7abbb..1666db27d86 100644 --- a/homeassistant/components/hunterdouglas_powerview/config_flow.py +++ b/homeassistant/components/hunterdouglas_powerview/config_flow.py @@ -14,7 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import async_get_device_info -from .const import DEVICE_NAME, DEVICE_SERIAL_NUMBER, DOMAIN, HUB_EXCEPTIONS +from .const import DOMAIN, HUB_EXCEPTIONS _LOGGER = logging.getLogger(__name__) @@ -35,14 +35,14 @@ async def validate_input(hass: core.HomeAssistant, hub_address: str) -> dict[str try: async with async_timeout.timeout(10): - device_info = await async_get_device_info(pv_request) + device_info = await async_get_device_info(pv_request, hub_address) except HUB_EXCEPTIONS as err: raise CannotConnect from err # Return info that you want to store in the config entry. return { - "title": device_info[DEVICE_NAME], - "unique_id": device_info[DEVICE_SERIAL_NUMBER], + "title": device_info.name, + "unique_id": device_info.serial_number, } diff --git a/homeassistant/components/hunterdouglas_powerview/const.py b/homeassistant/components/hunterdouglas_powerview/const.py index 65c461b6f2f..9d99710f36d 100644 --- a/homeassistant/components/hunterdouglas_powerview/const.py +++ b/homeassistant/components/hunterdouglas_powerview/const.py @@ -27,13 +27,9 @@ FIRMWARE_REVISION = "revision" FIRMWARE_SUB_REVISION = "subRevision" FIRMWARE_BUILD = "build" -DEVICE_NAME = "device_name" -DEVICE_MAC_ADDRESS = "device_mac_address" -DEVICE_SERIAL_NUMBER = "device_serial_number" -DEVICE_REVISION = "device_revision" -DEVICE_INFO = "device_info" -DEVICE_MODEL = "device_model" -DEVICE_FIRMWARE = "device_firmware" +REDACT_MAC_ADDRESS = "mac_address" +REDACT_SERIAL_NUMBER = "serial_number" +REDACT_HUB_ADDRESS = "hub_address" SCENE_NAME = "name" SCENE_ID = "id" @@ -52,15 +48,6 @@ SHADE_BATTERY_LEVEL_MAX = 200 STATE_ATTRIBUTE_ROOM_NAME = "roomName" -PV_API = "pv_api" -PV_HUB = "pv_hub" -PV_HUB_ADDRESS = "pv_hub_address" -PV_SHADES = "pv_shades" -PV_SCENE_DATA = "pv_scene_data" -PV_SHADE_DATA = "pv_shade_data" -PV_ROOM_DATA = "pv_room_data" -COORDINATOR = "coordinator" - HUB_EXCEPTIONS = ( ServerDisconnectedError, asyncio.TimeoutError, diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index e0a01f9c381..797dded4f76 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -38,23 +38,18 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from .const import ( - COORDINATOR, - DEVICE_INFO, - DEVICE_MODEL, DOMAIN, LEGACY_DEVICE_MODEL, POS_KIND_PRIMARY, POS_KIND_SECONDARY, POS_KIND_VANE, - PV_API, - PV_ROOM_DATA, - PV_SHADE_DATA, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME, ) from .coordinator import PowerviewShadeUpdateCoordinator from .entity import ShadeEntity +from .model import PowerviewDeviceInfo, PowerviewEntryData from .shade_data import PowerviewShadeMove _LOGGER = logging.getLogger(__name__) @@ -83,18 +78,14 @@ async def async_setup_entry( ) -> None: """Set up the hunter douglas shades.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - room_data: dict[str | int, Any] = pv_data[PV_ROOM_DATA] - shade_data = pv_data[PV_SHADE_DATA] - pv_request = pv_data[PV_API] - coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR] - device_info: dict[str, Any] = pv_data[DEVICE_INFO] + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] + coordinator: PowerviewShadeUpdateCoordinator = pv_entry.coordinator entities: list[ShadeEntity] = [] - for raw_shade in shade_data.values(): + for raw_shade in pv_entry.shade_data.values(): # The shade may be out of sync with the hub # so we force a refresh when we add it if possible - shade: BaseShade = PvShade(raw_shade, pv_request) + shade: BaseShade = PvShade(raw_shade, pv_entry.api) name_before_refresh = shade.name with suppress(asyncio.TimeoutError): async with async_timeout.timeout(1): @@ -108,10 +99,10 @@ async def async_setup_entry( continue coordinator.data.update_shade_positions(shade.raw_data) room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") entities.extend( create_powerview_shade_entity( - coordinator, device_info, room_name, shade, name_before_refresh + coordinator, pv_entry.device_info, room_name, shade, name_before_refresh ) ) async_add_entities(entities) @@ -119,7 +110,7 @@ async def async_setup_entry( def create_powerview_shade_entity( coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, name_before_refresh: str, @@ -161,7 +152,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, name: str, @@ -171,7 +162,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): self._shade: BaseShade = shade self._attr_name = self._shade_name self._scheduled_transition_update: CALLBACK_TYPE | None = None - if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: + if self._device_info.model != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP self._forced_resync = None @@ -387,7 +378,7 @@ class PowerViewShade(PowerViewShadeBase): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, name: str, @@ -418,7 +409,7 @@ class PowerViewShadeTDBUBottom(PowerViewShadeTDBU): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, name: str, @@ -455,7 +446,7 @@ class PowerViewShadeTDBUTop(PowerViewShadeTDBU): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, name: str, @@ -514,7 +505,7 @@ class PowerViewShadeWithTilt(PowerViewShade): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, name: str, @@ -526,7 +517,7 @@ class PowerViewShadeWithTilt(PowerViewShade): | CoverEntityFeature.CLOSE_TILT | CoverEntityFeature.SET_TILT_POSITION ) - if self._device_info[DEVICE_MODEL] != LEGACY_DEVICE_MODEL: + if self._device_info.model != LEGACY_DEVICE_MODEL: self._attr_supported_features |= CoverEntityFeature.STOP_TILT @property diff --git a/homeassistant/components/hunterdouglas_powerview/diagnostics.py b/homeassistant/components/hunterdouglas_powerview/diagnostics.py index ca6131b2761..12f424ea501 100644 --- a/homeassistant/components/hunterdouglas_powerview/diagnostics.py +++ b/homeassistant/components/hunterdouglas_powerview/diagnostics.py @@ -1,6 +1,8 @@ """Diagnostics support for Powerview Hunter Douglas.""" from __future__ import annotations +from dataclasses import asdict +import logging from typing import Any import attr @@ -12,24 +14,19 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.device_registry import DeviceEntry -from .const import ( - COORDINATOR, - DEVICE_INFO, - DEVICE_MAC_ADDRESS, - DEVICE_SERIAL_NUMBER, - DOMAIN, - PV_HUB_ADDRESS, -) -from .coordinator import PowerviewShadeUpdateCoordinator +from .const import DOMAIN, REDACT_HUB_ADDRESS, REDACT_MAC_ADDRESS, REDACT_SERIAL_NUMBER +from .model import PowerviewEntryData REDACT_CONFIG = { CONF_HOST, - DEVICE_MAC_ADDRESS, - DEVICE_SERIAL_NUMBER, - PV_HUB_ADDRESS, + REDACT_HUB_ADDRESS, + REDACT_MAC_ADDRESS, + REDACT_SERIAL_NUMBER, ATTR_CONFIGURATION_URL, } +_LOGGER = logging.getLogger(__name__) + async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry @@ -70,10 +67,9 @@ def _async_get_diagnostics( entry: ConfigEntry, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - coordinator: PowerviewShadeUpdateCoordinator = pv_data[COORDINATOR] - shade_data = coordinator.data.get_all_raw_data() - hub_info = async_redact_data(pv_data[DEVICE_INFO], REDACT_CONFIG) + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] + shade_data = pv_entry.coordinator.data.get_all_raw_data() + hub_info = async_redact_data(asdict(pv_entry.device_info), REDACT_CONFIG) return {"hub_info": hub_info, "shade_data": shade_data} diff --git a/homeassistant/components/hunterdouglas_powerview/entity.py b/homeassistant/components/hunterdouglas_powerview/entity.py index 222324eb55a..a2bbf39fb96 100644 --- a/homeassistant/components/hunterdouglas_powerview/entity.py +++ b/homeassistant/components/hunterdouglas_powerview/entity.py @@ -1,7 +1,5 @@ """The powerview integration base entity.""" -from typing import Any - from aiopvapi.resources.shade import ATTR_TYPE, BaseShade from homeassistant.const import ATTR_MODEL, ATTR_SW_VERSION @@ -12,20 +10,15 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_BATTERY_KIND, BATTERY_KIND_HARDWIRED, - DEVICE_FIRMWARE, - DEVICE_MAC_ADDRESS, - DEVICE_MODEL, - DEVICE_NAME, - DEVICE_SERIAL_NUMBER, DOMAIN, FIRMWARE, FIRMWARE_BUILD, FIRMWARE_REVISION, FIRMWARE_SUB_REVISION, MANUFACTURER, - PV_HUB_ADDRESS, ) from .coordinator import PowerviewShadeUpdateCoordinator +from .model import PowerviewDeviceInfo from .shade_data import PowerviewShadeData, PowerviewShadePositions @@ -35,7 +28,7 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, unique_id: str, ) -> None: @@ -43,7 +36,6 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): super().__init__(coordinator) self._room_name = room_name self._attr_unique_id = unique_id - self._hub_address = device_info[PV_HUB_ADDRESS] self._device_info = device_info @property @@ -54,19 +46,17 @@ class HDEntity(CoordinatorEntity[PowerviewShadeUpdateCoordinator]): @property def device_info(self) -> DeviceInfo: """Return the device_info of the device.""" - firmware = self._device_info[DEVICE_FIRMWARE] + firmware = self._device_info.firmware sw_version = f"{firmware[FIRMWARE_REVISION]}.{firmware[FIRMWARE_SUB_REVISION]}.{firmware[FIRMWARE_BUILD]}" return DeviceInfo( - connections={ - (dr.CONNECTION_NETWORK_MAC, self._device_info[DEVICE_MAC_ADDRESS]) - }, - identifiers={(DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER])}, + connections={(dr.CONNECTION_NETWORK_MAC, self._device_info.mac_address)}, + identifiers={(DOMAIN, self._device_info.serial_number)}, manufacturer=MANUFACTURER, - model=self._device_info[DEVICE_MODEL], - name=self._device_info[DEVICE_NAME], + model=self._device_info.model, + name=self._device_info.name, suggested_area=self._room_name, sw_version=sw_version, - configuration_url=f"http://{self._hub_address}/api/shades", + configuration_url=f"http://{self._device_info.hub_address}/api/shades", ) @@ -76,7 +66,7 @@ class ShadeEntity(HDEntity): def __init__( self, coordinator: PowerviewShadeUpdateCoordinator, - device_info: dict[str, Any], + device_info: PowerviewDeviceInfo, room_name: str, shade: BaseShade, shade_name: str, @@ -104,8 +94,8 @@ class ShadeEntity(HDEntity): suggested_area=self._room_name, manufacturer=MANUFACTURER, model=str(self._shade.raw_data[ATTR_TYPE]), - via_device=(DOMAIN, self._device_info[DEVICE_SERIAL_NUMBER]), - configuration_url=f"http://{self._hub_address}/api/shades/{self._shade.id}", + via_device=(DOMAIN, self._device_info.serial_number), + configuration_url=f"http://{self._device_info.hub_address}/api/shades/{self._shade.id}", ) for shade in self._shade.shade_types: diff --git a/homeassistant/components/hunterdouglas_powerview/model.py b/homeassistant/components/hunterdouglas_powerview/model.py new file mode 100644 index 00000000000..b7ad4a7439c --- /dev/null +++ b/homeassistant/components/hunterdouglas_powerview/model.py @@ -0,0 +1,33 @@ +"""Define Hunter Douglas data models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from aiopvapi.helpers.aiorequest import AioRequest + +from .coordinator import PowerviewShadeUpdateCoordinator + + +@dataclass +class PowerviewEntryData: + """Define class for main domain information.""" + + api: AioRequest + room_data: dict[str, Any] + scene_data: dict[str, Any] + shade_data: dict[str, Any] + coordinator: PowerviewShadeUpdateCoordinator + device_info: PowerviewDeviceInfo + + +@dataclass +class PowerviewDeviceInfo: + """Define class for device information.""" + + name: str + mac_address: str + serial_number: str + firmware: dict[str, Any] + model: str + hub_address: str diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index 3476db4949c..ba1221a25ac 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -10,17 +10,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - COORDINATOR, - DEVICE_INFO, - DOMAIN, - PV_API, - PV_ROOM_DATA, - PV_SCENE_DATA, - ROOM_NAME_UNICODE, - STATE_ATTRIBUTE_ROOM_NAME, -) +from .const import DOMAIN, ROOM_NAME_UNICODE, STATE_ATTRIBUTE_ROOM_NAME from .entity import HDEntity +from .model import PowerviewEntryData async def async_setup_entry( @@ -28,18 +20,15 @@ async def async_setup_entry( ) -> None: """Set up powerview scene entries.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - room_data = pv_data[PV_ROOM_DATA] - scene_data = pv_data[PV_SCENE_DATA] - pv_request = pv_data[PV_API] - coordinator = pv_data[COORDINATOR] - device_info = pv_data[DEVICE_INFO] + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] pvscenes = [] - for raw_scene in scene_data.values(): - scene = PvScene(raw_scene, pv_request) - room_name = room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") - pvscenes.append(PowerViewScene(coordinator, device_info, room_name, scene)) + for raw_scene in pv_entry.scene_data.values(): + scene = PvScene(raw_scene, pv_entry.api) + room_name = pv_entry.room_data.get(scene.room_id, {}).get(ROOM_NAME_UNICODE, "") + pvscenes.append( + PowerViewScene(pv_entry.coordinator, pv_entry.device_info, room_name, scene) + ) async_add_entities(pvscenes) diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 3fc8942eb78..6328ad63bc2 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -1,5 +1,5 @@ """Support for hunterdouglass_powerview sensors.""" -from aiopvapi.resources.shade import factory as PvShade +from aiopvapi.resources.shade import BaseShade, factory as PvShade from homeassistant.components.sensor import ( SensorDeviceClass, @@ -13,18 +13,14 @@ from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( - COORDINATOR, - DEVICE_INFO, DOMAIN, - PV_API, - PV_ROOM_DATA, - PV_SHADE_DATA, ROOM_ID_IN_SHADE, ROOM_NAME_UNICODE, SHADE_BATTERY_LEVEL, SHADE_BATTERY_LEVEL_MAX, ) from .entity import ShadeEntity +from .model import PowerviewEntryData async def async_setup_entry( @@ -32,24 +28,23 @@ async def async_setup_entry( ) -> None: """Set up the hunter douglas shades sensors.""" - pv_data = hass.data[DOMAIN][entry.entry_id] - room_data = pv_data[PV_ROOM_DATA] - shade_data = pv_data[PV_SHADE_DATA] - pv_request = pv_data[PV_API] - coordinator = pv_data[COORDINATOR] - device_info = pv_data[DEVICE_INFO] + pv_entry: PowerviewEntryData = hass.data[DOMAIN][entry.entry_id] entities = [] - for raw_shade in shade_data.values(): - shade = PvShade(raw_shade, pv_request) + for raw_shade in pv_entry.shade_data.values(): + shade: BaseShade = PvShade(raw_shade, pv_entry.api) if SHADE_BATTERY_LEVEL not in shade.raw_data: continue name_before_refresh = shade.name room_id = shade.raw_data.get(ROOM_ID_IN_SHADE) - room_name = room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") + room_name = pv_entry.room_data.get(room_id, {}).get(ROOM_NAME_UNICODE, "") entities.append( PowerViewShadeBatterySensor( - coordinator, device_info, room_name, shade, name_before_refresh + pv_entry.coordinator, + pv_entry.device_info, + room_name, + shade, + name_before_refresh, ) ) async_add_entities(entities) From 3823edda32589a083e3fe7d7edb75c7d7ce667a7 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 21 Jun 2022 12:17:29 -0400 Subject: [PATCH 559/947] Add Permission checking for UniFi Protect (#73765) Co-authored-by: J. Nick Koston --- .../components/unifiprotect/button.py | 4 ++- .../components/unifiprotect/entity.py | 7 +++- .../components/unifiprotect/light.py | 11 +++--- .../components/unifiprotect/models.py | 9 +++++ .../components/unifiprotect/number.py | 17 +++++++-- .../components/unifiprotect/select.py | 12 ++++++- .../components/unifiprotect/switch.py | 27 +++++++++++++- tests/components/unifiprotect/test_switch.py | 35 +++++++++++++++++++ 8 files changed, 109 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 9ed5ecc4967..01714868261 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T @dataclass @@ -40,6 +40,7 @@ ALL_DEVICE_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( device_class=ButtonDeviceClass.RESTART, name="Reboot Device", ufp_press="reboot", + ufp_perm=PermRequired.WRITE, ), ) @@ -49,6 +50,7 @@ SENSOR_BUTTONS: tuple[ProtectButtonEntityDescription, ...] = ( name="Clear Tamper", icon="mdi:notification-clear-all", ufp_press="clear_tamper", + ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index e06d297ef33..155fa49f078 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription from .const import ATTR_EVENT_SCORE, DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN from .data import ProtectData -from .models import ProtectRequiredKeysMixin +from .models import PermRequired, ProtectRequiredKeysMixin from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) @@ -46,6 +46,11 @@ def _async_device_entities( for device in data.get_by_types({model_type}): assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime)) for description in descs: + if description.ufp_perm is not None: + can_write = device.can_write(data.api.bootstrap.auth_user) + if description.ufp_perm == PermRequired.WRITE and not can_write: + continue + if description.ufp_required_field: required_field = get_nested_attr(device, description.ufp_required_field) if not required_field: diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index d84c8406acf..b200fb85e03 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -25,13 +25,10 @@ async def async_setup_entry( ) -> None: """Set up lights for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - entities = [ - ProtectLight( - data, - device, - ) - for device in data.api.bootstrap.lights.values() - ] + entities = [] + for device in data.api.bootstrap.lights.values(): + if device.can_write(data.api.bootstrap.auth_user): + entities.append(ProtectLight(data, device)) if not entities: return diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index c28e1757722..81ad8438dd7 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +from enum import Enum import logging from typing import Any, Generic, TypeVar @@ -17,6 +18,13 @@ _LOGGER = logging.getLogger(__name__) T = TypeVar("T", bound=ProtectDeviceModel) +class PermRequired(int, Enum): + """Type of permission level required for entity.""" + + NO_WRITE = 1 + WRITE = 2 + + @dataclass class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): """Mixin for required keys.""" @@ -25,6 +33,7 @@ class ProtectRequiredKeysMixin(EntityDescription, Generic[T]): ufp_value: str | None = None ufp_value_fn: Callable[[T], Any] | None = None ufp_enabled: str | None = None + ufp_perm: PermRequired | None = None def get_ufp_value(self, obj: T) -> Any: """Return value from UniFi Protect device.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 5a3b048e623..7bd6ce5b3d8 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -8,7 +8,7 @@ from pyunifiprotect.data import Camera, Doorlock, Light, ProtectModelWithId from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TIME_SECONDS +from homeassistant.const import PERCENTAGE, TIME_SECONDS from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T @dataclass @@ -63,30 +63,35 @@ CAMERA_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_required_field="feature_flags.has_wdr", ufp_value="isp_settings.wdr", ufp_set_method="set_wdr_level", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( key="mic_level", name="Microphone Level", icon="mdi:microphone", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field="feature_flags.has_mic", ufp_value="mic_volume", ufp_set_method="set_mic_volume", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription( key="zoom_position", name="Zoom Level", icon="mdi:magnify-plus-outline", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field="feature_flags.can_optical_zoom", ufp_value="isp_settings.zoom_position", ufp_set_method="set_camera_zoom", + ufp_perm=PermRequired.WRITE, ), ) @@ -96,12 +101,14 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Motion Sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field=None, ufp_value="light_device_settings.pir_sensitivity", ufp_set_method="set_sensitivity", + ufp_perm=PermRequired.WRITE, ), ProtectNumberEntityDescription[Light]( key="duration", @@ -115,6 +122,7 @@ LIGHT_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_required_field=None, ufp_value_fn=_get_pir_duration, ufp_set_method_fn=_set_pir_duration, + ufp_perm=PermRequired.WRITE, ), ) @@ -124,12 +132,14 @@ SENSE_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Motion Sensitivity", icon="mdi:walk", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_required_field=None, ufp_value="motion_settings.sensitivity", ufp_set_method="set_motion_sensitivity", + ufp_perm=PermRequired.WRITE, ), ) @@ -146,6 +156,7 @@ DOORLOCK_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( ufp_required_field=None, ufp_value_fn=_get_auto_close, ufp_set_method_fn=_set_auto_close, + ufp_perm=PermRequired.WRITE, ), ) @@ -155,11 +166,13 @@ CHIME_NUMBERS: tuple[ProtectNumberEntityDescription, ...] = ( name="Volume", icon="mdi:speaker", entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=PERCENTAGE, ufp_min=0, ufp_max=100, ufp_step=1, ufp_value="volume", ufp_set_method="set_volume", + ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 6f5c2cfd0d7..4432e77ac26 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -38,7 +38,7 @@ from homeassistant.util.dt import utcnow from .const import ATTR_DURATION, ATTR_MESSAGE, DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -208,6 +208,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=RecordingMode, ufp_value="recording_settings.mode", ufp_set_method="set_recording_mode", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription( key="infrared", @@ -219,6 +220,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=IRLEDMode, ufp_value="isp_settings.ir_led_mode", ufp_set_method="set_ir_led_model", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Camera]( key="doorbell_text", @@ -230,6 +232,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value_fn=_get_doorbell_current, ufp_options_fn=_get_doorbell_options, ufp_set_method_fn=_set_doorbell_message, + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription( key="chime_type", @@ -241,6 +244,7 @@ CAMERA_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=ChimeType, ufp_value="chime_type", ufp_set_method="set_chime_type", + ufp_perm=PermRequired.WRITE, ), ) @@ -253,6 +257,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_options=MOTION_MODE_TO_LIGHT_MODE, ufp_value_fn=_get_light_motion_current, ufp_set_method_fn=_set_light_mode, + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Light]( key="paired_camera", @@ -262,6 +267,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -275,6 +281,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_enum_type=MountType, ufp_value="mount_type", ufp_set_method="set_mount_type", + ufp_perm=PermRequired.WRITE, ), ProtectSelectEntityDescription[Sensor]( key="paired_camera", @@ -284,6 +291,7 @@ SENSE_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -296,6 +304,7 @@ DOORLOCK_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_value="camera_id", ufp_options_fn=_get_paired_camera_options, ufp_set_method_fn=_set_paired_camera, + ufp_perm=PermRequired.WRITE, ), ) @@ -308,6 +317,7 @@ VIEWER_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( ufp_options_fn=_get_viewer_options, ufp_value_fn=_get_viewer_current, ufp_set_method_fn=_set_liveview, + ufp_perm=PermRequired.WRITE, ), ) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index d8542da2f7f..06bf9f7251b 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities -from .models import ProtectSetableKeysMixin, T +from .models import PermRequired, ProtectSetableKeysMixin, T _LOGGER = logging.getLogger(__name__) @@ -56,6 +56,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="status_light", @@ -65,6 +66,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_led_status", ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="hdr_mode", @@ -74,6 +76,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_hdr", ufp_value="hdr_mode", ufp_set_method="set_hdr", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription[Camera]( key="high_fps", @@ -83,6 +86,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_highfps", ufp_value_fn=_get_is_highfps, ufp_set_method_fn=_set_highfps, + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key=_KEY_PRIVACY_MODE, @@ -91,6 +95,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_privacy_mask", ufp_value="is_privacy_on", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="system_sounds", @@ -100,6 +105,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="feature_flags.has_speaker", ufp_value="speaker_settings.are_system_sounds_enabled", ufp_set_method="set_system_sounds", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_name", @@ -108,6 +114,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_name_enabled", ufp_set_method="set_osd_name", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_date", @@ -116,6 +123,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_date_enabled", ufp_set_method="set_osd_date", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_logo", @@ -124,6 +132,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_logo_enabled", ufp_set_method="set_osd_logo", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="osd_bitrate", @@ -132,6 +141,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="osd_settings.is_debug_enabled", ufp_set_method="set_osd_bitrate", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="motion", @@ -140,6 +150,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="recording_settings.enable_motion_detection", ufp_set_method="set_motion_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_person", @@ -149,6 +160,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_person", ufp_value="is_person_detection_on", ufp_set_method="set_person_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_vehicle", @@ -158,6 +170,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_vehicle", ufp_value="is_vehicle_detection_on", ufp_set_method="set_vehicle_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_face", @@ -167,6 +180,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_face", ufp_value="is_face_detection_on", ufp_set_method="set_face_detection", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="smart_package", @@ -176,6 +190,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( ufp_required_field="can_detect_package", ufp_value="is_package_detection_on", ufp_set_method="set_package_detection", + ufp_perm=PermRequired.WRITE, ), ) @@ -187,6 +202,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="motion", @@ -195,6 +211,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="motion_settings.is_enabled", ufp_set_method="set_motion_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="temperature", @@ -203,6 +220,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="temperature_settings.is_enabled", ufp_set_method="set_temperature_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="humidity", @@ -211,6 +229,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="humidity_settings.is_enabled", ufp_set_method="set_humidity_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="light", @@ -219,6 +238,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="light_settings.is_enabled", ufp_set_method="set_light_status", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="alarm", @@ -226,6 +246,7 @@ SENSE_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="alarm_settings.is_enabled", ufp_set_method="set_alarm_status", + ufp_perm=PermRequired.WRITE, ), ) @@ -239,6 +260,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ProtectSwitchEntityDescription( key="status_light", @@ -247,6 +269,7 @@ LIGHT_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="light_device_settings.is_indicator_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ) @@ -258,6 +281,7 @@ DOORLOCK_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="led_settings.is_enabled", ufp_set_method="set_status_light", + ufp_perm=PermRequired.WRITE, ), ) @@ -270,6 +294,7 @@ VIEWER_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( entity_category=EntityCategory.CONFIG, ufp_value="is_ssh_enabled", ufp_set_method="set_ssh", + ufp_perm=PermRequired.WRITE, ), ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 1bd6dbeb349..6c9340af5d5 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -8,6 +8,7 @@ import pytest from pyunifiprotect.data import ( Camera, Light, + Permission, RecordingMode, SmartDetectObjectType, VideoMode, @@ -214,6 +215,40 @@ async def camera_privacy_fixture( Camera.__config__.validate_assignment = True +async def test_switch_setup_no_perm( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + mock_light: Light, + mock_camera: Camera, +): + """Test switch entity setup for light devices.""" + + light_obj = mock_light.copy() + light_obj._api = mock_entry.api + + camera_obj = mock_camera.copy() + camera_obj._api = mock_entry.api + camera_obj.channels[0]._api = mock_entry.api + camera_obj.channels[1]._api = mock_entry.api + camera_obj.channels[2]._api = mock_entry.api + + reset_objects(mock_entry.api.bootstrap) + mock_entry.api.bootstrap.lights = { + light_obj.id: light_obj, + } + mock_entry.api.bootstrap.cameras = { + camera_obj.id: camera_obj, + } + mock_entry.api.bootstrap.auth_user.all_permissions = [ + Permission.unifi_dict_to_dict({"rawPermission": "light:read:*"}) + ] + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + + async def test_switch_setup_light( hass: HomeAssistant, mock_entry: MockEntityFixture, From 9fd48da132c33cd998b8d0868128dc677c9fa3c9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 21 Jun 2022 18:53:31 +0200 Subject: [PATCH 560/947] Add lock checks to pylint type-hint plugin (#73521) * Add ability to check kwargs type annotation * Add checks for lock * Add tests * Fix components * Fix spelling * Revert "Fix components" This reverts commit 121ff6dc511d28c17b4fc13185155a2402193405. * Adjust comment * Add comments to TypeHintMatch --- pylint/plugins/hass_enforce_type_hints.py | 53 ++++++++++++++++++++--- tests/pylint/test_enforce_type_hints.py | 32 +++++++++++++- 2 files changed, 79 insertions(+), 6 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 62dc6feffc6..a1bf260e968 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -21,7 +21,10 @@ class TypeHintMatch: function_name: str return_type: list[str] | str | None | object + # arg_types is for positional arguments arg_types: dict[int, str] | None = None + # kwarg_types is for the special case `**kwargs` + kwargs_type: str | None = None check_return_type_inheritance: bool = False @@ -442,9 +445,9 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), ], } -# Properties are normally checked by mypy, and will only be checked -# by pylint when --ignore-missing-annotations is False -_PROPERTY_MATCH: dict[str, list[ClassTypeHintMatch]] = { +# Overriding properties and functions are normally checked by mypy, and will only +# be checked by pylint when --ignore-missing-annotations is False +_INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { "lock": [ ClassTypeHintMatch( base_class="LockEntity", @@ -473,6 +476,36 @@ _PROPERTY_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="is_jammed", return_type=["bool", None], ), + TypeHintMatch( + function_name="lock", + kwargs_type="Any", + return_type=None, + ), + TypeHintMatch( + function_name="async_lock", + kwargs_type="Any", + return_type=None, + ), + TypeHintMatch( + function_name="unlock", + kwargs_type="Any", + return_type=None, + ), + TypeHintMatch( + function_name="async_unlock", + kwargs_type="Any", + return_type=None, + ), + TypeHintMatch( + function_name="open", + kwargs_type="Any", + return_type=None, + ), + TypeHintMatch( + function_name="async_open", + kwargs_type="Any", + return_type=None, + ), ], ), ], @@ -613,7 +646,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] priority = -1 msgs = { "W7431": ( - "Argument %d should be of type %s", + "Argument %s should be of type %s", "hass-argument-type", "Used when method argument type is incorrect", ), @@ -659,7 +692,7 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] self._class_matchers.extend(class_matches) if not self.linter.config.ignore_missing_annotations and ( - property_matches := _PROPERTY_MATCH.get(module_platform) + property_matches := _INHERITANCE_MATCH.get(module_platform) ): self._class_matchers.extend(property_matches) @@ -709,6 +742,16 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] args=(key + 1, expected_type), ) + # Check that kwargs is correctly annotated. + if match.kwargs_type and not _is_valid_type( + match.kwargs_type, node.args.kwargannotation + ): + self.add_message( + "hass-argument-type", + node=node, + args=(node.args.kwarg, match.kwargs_type), + ) + # Check the return type. if not _is_valid_return_type(match, node.returns): self.add_message( diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 1f601a881a6..262ff93afa8 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -478,7 +478,7 @@ def test_invalid_entity_properties( # Set bypass option type_hint_checker.config.ignore_missing_annotations = False - class_node, prop_node = astroid.extract_node( + class_node, prop_node, func_node = astroid.extract_node( """ class LockEntity(): pass @@ -491,6 +491,12 @@ def test_invalid_entity_properties( self ): pass + + async def async_lock( #@ + self, + **kwargs + ) -> bool: + pass """, "homeassistant.components.pylint_test.lock", ) @@ -507,6 +513,24 @@ def test_invalid_entity_properties( end_line=9, end_col_offset=18, ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=func_node, + args=("kwargs", "Any"), + line=14, + col_offset=4, + end_line=14, + end_col_offset=24, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="None", + line=14, + col_offset=4, + end_line=14, + end_col_offset=24, + ), ): type_hint_checker.visit_classdef(class_node) @@ -528,6 +552,12 @@ def test_ignore_invalid_entity_properties( self ): pass + + async def async_lock( + self, + **kwargs + ) -> bool: + pass """, "homeassistant.components.pylint_test.lock", ) From db9c2427232931b195be039ac006f0b5ff00ce6e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jun 2022 11:56:32 -0500 Subject: [PATCH 561/947] Speed up creating group entities from YAML (#73649) * Speed up creating group entities from YAML - Pass all the entities to async_add_entities in one call to avoid multiple levels of gather * Speed up creating group entities from YAML - Pass all the entities to async_add_entities in one call to avoid multiple levels of gather * Update homeassistant/components/group/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/group/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/group/__init__.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/group/__init__.py Co-authored-by: Martin Hjelmare * typing * unbreak Co-authored-by: Martin Hjelmare --- homeassistant/components/group/__init__.py | 123 +++++++++++++-------- 1 file changed, 74 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index e6d7e91f035..1f8fba21e78 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -104,6 +104,12 @@ CONFIG_SCHEMA = vol.Schema( ) +def _async_get_component(hass: HomeAssistant) -> EntityComponent: + if (component := hass.data.get(DOMAIN)) is None: + component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) + return component + + class GroupIntegrationRegistry: """Class to hold a registry of integrations.""" @@ -274,7 +280,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_process_integration_platforms(hass, DOMAIN, _process_group_platform) - await _async_process_config(hass, config, component) + await _async_process_config(hass, config) async def reload_service_handler(service: ServiceCall) -> None: """Remove all user-defined groups and load new ones from config.""" @@ -286,7 +292,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if (conf := await component.async_prepare_reload()) is None: return - await _async_process_config(hass, conf, component) + await _async_process_config(hass, conf) await component.async_add_entities(auto) @@ -406,31 +412,33 @@ async def _process_group_platform(hass, domain, platform): platform.async_describe_on_off_states(hass, hass.data[REG_KEY]) -async def _async_process_config(hass, config, component): +async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None: """Process group configuration.""" hass.data.setdefault(GROUP_ORDER, 0) - tasks = [] + entities = [] + domain_config: dict[str, dict[str, Any]] = config.get(DOMAIN, {}) - for object_id, conf in config.get(DOMAIN, {}).items(): - name = conf.get(CONF_NAME, object_id) - entity_ids = conf.get(CONF_ENTITIES) or [] - icon = conf.get(CONF_ICON) - mode = conf.get(CONF_ALL) + for object_id, conf in domain_config.items(): + name: str = conf.get(CONF_NAME, object_id) + entity_ids: Iterable[str] = conf.get(CONF_ENTITIES) or [] + icon: str | None = conf.get(CONF_ICON) + mode = bool(conf.get(CONF_ALL)) + order: int = hass.data[GROUP_ORDER] # We keep track of the order when we are creating the tasks # in the same way that async_create_group does to make # sure we use the same ordering system. This overcomes # the problem with concurrently creating the groups - tasks.append( - Group.async_create_group( + entities.append( + Group.async_create_group_entity( hass, name, entity_ids, icon=icon, object_id=object_id, mode=mode, - order=hass.data[GROUP_ORDER], + order=order, ) ) @@ -439,7 +447,8 @@ async def _async_process_config(hass, config, component): # we setup a new group hass.data[GROUP_ORDER] += 1 - await asyncio.gather(*tasks) + # If called before the platform async_setup is called (test cases) + await _async_get_component(hass).async_add_entities(entities) class GroupEntity(Entity): @@ -478,14 +487,14 @@ class Group(Entity): def __init__( self, - hass, - name, - order=None, - icon=None, - user_defined=True, - entity_ids=None, - mode=None, - ): + hass: HomeAssistant, + name: str, + order: int | None = None, + icon: str | None = None, + user_defined: bool = True, + entity_ids: Iterable[str] | None = None, + mode: bool | None = None, + ) -> None: """Initialize a group. This Object has factory function for creation. @@ -508,15 +517,15 @@ class Group(Entity): @staticmethod def create_group( - hass, - name, - entity_ids=None, - user_defined=True, - icon=None, - object_id=None, - mode=None, - order=None, - ): + hass: HomeAssistant, + name: str, + entity_ids: Iterable[str] | None = None, + user_defined: bool = True, + icon: str | None = None, + object_id: str | None = None, + mode: bool | None = None, + order: int | None = None, + ) -> Group: """Initialize a group.""" return asyncio.run_coroutine_threadsafe( Group.async_create_group( @@ -526,20 +535,18 @@ class Group(Entity): ).result() @staticmethod - async def async_create_group( - hass, - name, - entity_ids=None, - user_defined=True, - icon=None, - object_id=None, - mode=None, - order=None, - ): - """Initialize a group. - - This method must be run in the event loop. - """ + @callback + def async_create_group_entity( + hass: HomeAssistant, + name: str, + entity_ids: Iterable[str] | None = None, + user_defined: bool = True, + icon: str | None = None, + object_id: str | None = None, + mode: bool | None = None, + order: int | None = None, + ) -> Group: + """Create a group entity.""" if order is None: hass.data.setdefault(GROUP_ORDER, 0) order = hass.data[GROUP_ORDER] @@ -562,12 +569,30 @@ class Group(Entity): ENTITY_ID_FORMAT, object_id or name, hass=hass ) + return group + + @staticmethod + @callback + async def async_create_group( + hass: HomeAssistant, + name: str, + entity_ids: Iterable[str] | None = None, + user_defined: bool = True, + icon: str | None = None, + object_id: str | None = None, + mode: bool | None = None, + order: int | None = None, + ) -> Group: + """Initialize a group. + + This method must be run in the event loop. + """ + group = Group.async_create_group_entity( + hass, name, entity_ids, user_defined, icon, object_id, mode, order + ) + # If called before the platform async_setup is called (test cases) - if (component := hass.data.get(DOMAIN)) is None: - component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass) - - await component.async_add_entities([group]) - + await _async_get_component(hass).async_add_entities([group]) return group @property From 9940a85e28cdddcef9abe1e99b9a2bdb0daadc56 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 21 Jun 2022 13:01:06 -0400 Subject: [PATCH 562/947] Add sensors for read-only devices for UniFi Protect (#73768) --- .../components/unifiprotect/binary_sensor.py | 216 +++++++++++++++++- .../components/unifiprotect/entity.py | 2 + .../components/unifiprotect/select.py | 13 +- .../components/unifiprotect/sensor.py | 128 ++++++++++- .../components/unifiprotect/switch.py | 7 +- .../components/unifiprotect/utils.py | 24 ++ .../unifiprotect/test_binary_sensor.py | 15 +- tests/components/unifiprotect/test_sensor.py | 17 +- 8 files changed, 391 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 81832de7d44..68c395faaf7 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -34,7 +34,8 @@ from .entity import ( ProtectNVREntity, async_all_device_entities, ) -from .models import ProtectRequiredKeysMixin +from .models import PermRequired, ProtectRequiredKeysMixin +from .utils import async_get_is_highfps _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" @@ -69,6 +70,126 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( icon="mdi:brightness-6", ufp_value="is_dark", ), + ProtectBinaryEntityDescription( + key="ssh", + name="SSH Enabled", + icon="mdi:lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_ssh_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_led_status", + ufp_value="led_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="hdr_mode", + name="HDR Mode", + icon="mdi:brightness-7", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_hdr", + ufp_value="hdr_mode", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="high_fps", + name="High FPS", + icon="mdi:video-high-definition", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_highfps", + ufp_value_fn=async_get_is_highfps, + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="system_sounds", + name="System Sounds", + icon="mdi:speaker", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_speaker", + ufp_value="speaker_settings.are_system_sounds_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_name", + name="Overlay: Show Name", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_name_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_date", + name="Overlay: Show Date", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_date_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_logo", + name="Overlay: Show Logo", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_logo_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="osd_bitrate", + name="Overlay: Show Bitrate", + icon="mdi:fullscreen", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="osd_settings.is_debug_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="motion", + name="Detections: Motion", + icon="mdi:run-fast", + ufp_value="recording_settings.enable_motion_detection", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_person", + name="Detections: Person", + icon="mdi:walk", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_person", + ufp_value="is_person_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_vehicle", + name="Detections: Vehicle", + icon="mdi:car", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_vehicle", + ufp_value="is_vehicle_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_face", + name="Detections: Face", + icon="mdi:human-greeting", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_face", + ufp_value="is_face_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="smart_package", + name="Detections: Package", + icon="mdi:package-variant-closed", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="can_detect_package", + ufp_value="is_package_detection_on", + ufp_perm=PermRequired.NO_WRITE, + ), ) LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -84,6 +205,31 @@ LIGHT_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.MOTION, ufp_value="is_pir_motion_detected", ), + ProtectBinaryEntityDescription( + key="light", + name="Flood Light", + icon="mdi:spotlight-beam", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_light_on", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="ssh", + name="SSH Enabled", + icon="mdi:lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_ssh_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="light_device_settings.is_indicator_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), ) SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -114,6 +260,53 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( device_class=BinarySensorDeviceClass.TAMPER, ufp_value="is_tampering_detected", ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="led_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="motion", + name="Motion Detection", + icon="mdi:walk", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="motion_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="temperature", + name="Temperature Sensor", + icon="mdi:thermometer", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="temperature_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="humidity", + name="Humidity Sensor", + icon="mdi:water-percent", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="humidity_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="light", + name="Light Sensor", + icon="mdi:brightness-5", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="light_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectBinaryEntityDescription( + key="alarm", + name="Alarm Sound Detection", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="alarm_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), ) MOTION_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( @@ -133,6 +326,26 @@ DOORLOCK_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( entity_category=EntityCategory.DIAGNOSTIC, ufp_value="battery_status.is_low", ), + ProtectBinaryEntityDescription( + key="status_light", + name="Status Light On", + icon="mdi:led-on", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="led_settings.is_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), +) + +VIEWER_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( + ProtectBinaryEntityDescription( + key="ssh", + name="SSH Enabled", + icon="mdi:lock", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="is_ssh_enabled", + ufp_perm=PermRequired.NO_WRITE, + ), ) @@ -159,6 +372,7 @@ async def async_setup_entry( light_descs=LIGHT_SENSORS, sense_descs=SENSE_SENSORS, lock_descs=DOORLOCK_SENSORS, + viewer_descs=VIEWER_SENSORS, ) entities += _async_motion_entities(data) entities += _async_nvr_entities(data) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 155fa49f078..9bf3c8de7a0 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -50,6 +50,8 @@ def _async_device_entities( can_write = device.can_write(data.api.bootstrap.auth_user) if description.ufp_perm == PermRequired.WRITE and not can_write: continue + if description.ufp_perm == PermRequired.NO_WRITE and can_write: + continue if description.ufp_required_field: required_field = get_nested_attr(device, description.ufp_required_field) diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 4432e77ac26..15377a37b27 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -39,6 +39,7 @@ from .const import ATTR_DURATION, ATTR_MESSAGE, DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_get_light_motion_current _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -151,16 +152,6 @@ def _get_viewer_current(obj: Viewer) -> str: return obj.liveview_id -def _get_light_motion_current(obj: Light) -> str: - # a bit of extra to allow On Motion Always/Dark - if ( - obj.light_mode_settings.mode == LightModeType.MOTION - and obj.light_mode_settings.enable_at == LightModeEnableType.DARK - ): - return f"{LightModeType.MOTION.value}Dark" - return obj.light_mode_settings.mode.value - - def _get_doorbell_current(obj: Camera) -> str | None: if obj.lcd_message is None: return None @@ -255,7 +246,7 @@ LIGHT_SELECTS: tuple[ProtectSelectEntityDescription, ...] = ( icon="mdi:spotlight", entity_category=EntityCategory.CONFIG, ufp_options=MOTION_MODE_TO_LIGHT_MODE, - ufp_value_fn=_get_light_motion_current, + ufp_value_fn=async_get_light_motion_current, ufp_set_method_fn=_set_light_mode, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 7b14a80ed43..57fe0d5aabd 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -10,6 +10,7 @@ from pyunifiprotect.data import ( NVR, Camera, Event, + Light, ProtectAdoptableDeviceModel, ProtectDeviceModel, ProtectModelWithId, @@ -46,7 +47,8 @@ from .entity import ( ProtectNVREntity, async_all_device_entities, ) -from .models import ProtectRequiredKeysMixin, T +from .models import PermRequired, ProtectRequiredKeysMixin, T +from .utils import async_get_light_motion_current _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -197,6 +199,51 @@ CAMERA_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_value="last_ring", entity_registry_enabled_default=False, ), + ProtectSensorEntityDescription( + key="mic_level", + name="Microphone Level", + icon="mdi:microphone", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_mic", + ufp_value="mic_volume", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="recording_mode", + name="Recording Mode", + icon="mdi:video-outline", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="recording_settings.mode", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="infrared", + name="Infrared Mode", + icon="mdi:circle-opacity", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_led_ir", + ufp_value="isp_settings.ir_led_mode", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="doorbell_text", + name="Doorbell Text", + icon="mdi:card-text", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_required_field="feature_flags.has_lcd_screen", + ufp_value="lcd_message.text", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="chime_type", + name="Chime Type", + icon="mdi:bell", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ufp_required_field="feature_flags.has_chime", + ufp_value="chime_type", + ), ) CAMERA_DISABLED_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -284,6 +331,31 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_value="tampering_detected_at", entity_registry_enabled_default=False, ), + ProtectSensorEntityDescription( + key="sensitivity", + name="Motion Sensitivity", + icon="mdi:walk", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="motion_settings.sensitivity", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="mount_type", + name="Mount Type", + icon="mdi:screwdriver", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="mount_type", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="camera.name", + ufp_perm=PermRequired.NO_WRITE, + ), ) DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -296,6 +368,14 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, ufp_value="battery_status.percentage", ), + ProtectSensorEntityDescription( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="camera.name", + ufp_perm=PermRequired.NO_WRITE, + ), ) NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -439,6 +519,31 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( ufp_value="last_motion", entity_registry_enabled_default=False, ), + ProtectSensorEntityDescription( + key="sensitivity", + name="Motion Sensitivity", + icon="mdi:walk", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="light_device_settings.pir_sensitivity", + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription[Light]( + key="light_motion", + name="Light Mode", + icon="mdi:spotlight", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value_fn=async_get_light_motion_current, + ufp_perm=PermRequired.NO_WRITE, + ), + ProtectSensorEntityDescription( + key="paired_camera", + name="Paired Camera", + icon="mdi:cctv", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="camera.name", + ufp_perm=PermRequired.NO_WRITE, + ), ) MOTION_TRIP_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( @@ -459,6 +564,26 @@ CHIME_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( icon="mdi:bell", ufp_value="last_ring", ), + ProtectSensorEntityDescription( + key="volume", + name="Volume", + icon="mdi:speaker", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="volume", + ufp_perm=PermRequired.NO_WRITE, + ), +) + +VIEWER_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( + ProtectSensorEntityDescription( + key="viewer", + name="Liveview", + icon="mdi:view-dashboard", + entity_category=EntityCategory.DIAGNOSTIC, + ufp_value="liveview.name", + ufp_perm=PermRequired.NO_WRITE, + ), ) @@ -478,6 +603,7 @@ async def async_setup_entry( light_descs=LIGHT_SENSORS, lock_descs=DOORLOCK_SENSORS, chime_descs=CHIME_SENSORS, + viewer_descs=VIEWER_SENSORS, ) entities += _async_motion_entities(data) entities += _async_nvr_entities(data) diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 06bf9f7251b..efa91b3a6ba 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -22,6 +22,7 @@ from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_get_is_highfps _LOGGER = logging.getLogger(__name__) @@ -36,10 +37,6 @@ class ProtectSwitchEntityDescription( _KEY_PRIVACY_MODE = "privacy_mode" -def _get_is_highfps(obj: Camera) -> bool: - return bool(obj.video_mode == VideoMode.HIGH_FPS) - - async def _set_highfps(obj: Camera, value: bool) -> None: if value: await obj.set_video_mode(VideoMode.HIGH_FPS) @@ -84,7 +81,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( icon="mdi:video-high-definition", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_highfps", - ufp_value_fn=_get_is_highfps, + ufp_value_fn=async_get_is_highfps, ufp_set_method_fn=_set_highfps, ufp_perm=PermRequired.WRITE, ), diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index b57753e15d4..b2eb8c1ca65 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -9,8 +9,13 @@ from typing import Any from pyunifiprotect.data import ( Bootstrap, + Camera, + Light, + LightModeEnableType, + LightModeType, ProtectAdoptableDeviceModel, ProtectDeviceModel, + VideoMode, ) from homeassistant.core import HomeAssistant, callback @@ -104,3 +109,22 @@ def async_get_devices( for device_type in model_type for device in async_get_devices_by_type(bootstrap, device_type).values() ) + + +@callback +def async_get_is_highfps(obj: Camera) -> bool: + """Return if camera has High FPS mode enabled.""" + + return bool(obj.video_mode == VideoMode.HIGH_FPS) + + +@callback +def async_get_light_motion_current(obj: Light) -> str: + """Get light motion mode for Flood Light.""" + + if ( + obj.light_mode_settings.mode == LightModeType.MOTION + and obj.light_mode_settings.enable_at == LightModeEnableType.DARK + ): + return f"{LightModeType.MOTION.value}Dark" + return obj.light_mode_settings.mode.value diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 644665cc659..8e868b4af21 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -39,6 +39,9 @@ from .conftest import ( reset_objects, ) +LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] +SENSE_SENSORS_WRITE = SENSE_SENSORS[:4] + @pytest.fixture(name="camera") async def camera_fixture( @@ -230,7 +233,7 @@ async def test_binary_sensor_setup_light( entity_registry = er.async_get(hass) - for description in LIGHT_SENSORS: + for description in LIGHT_SENSOR_WRITE: unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, light, description ) @@ -328,7 +331,7 @@ async def test_binary_sensor_setup_sensor( entity_registry = er.async_get(hass) - for description in SENSE_SENSORS: + for description in SENSE_SENSORS_WRITE: unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor, description ) @@ -356,7 +359,7 @@ async def test_binary_sensor_setup_sensor_none( STATE_UNAVAILABLE, STATE_OFF, ] - for index, description in enumerate(SENSE_SENSORS): + for index, description in enumerate(SENSE_SENSORS_WRITE): unique_id, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, sensor_none, description ) @@ -419,7 +422,7 @@ async def test_binary_sensor_update_light_motion( """Test binary_sensor motion entity.""" _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, light, LIGHT_SENSORS[1] + Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) event_metadata = EventMetadata(light_id=light.id) @@ -461,7 +464,7 @@ async def test_binary_sensor_update_mount_type_window( """Test binary_sensor motion entity.""" _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + Platform.BINARY_SENSOR, sensor, SENSE_SENSORS_WRITE[0] ) state = hass.states.get(entity_id) @@ -492,7 +495,7 @@ async def test_binary_sensor_update_mount_type_garage( """Test binary_sensor motion entity.""" _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, SENSE_SENSORS[0] + Platform.BINARY_SENSOR, sensor, SENSE_SENSORS_WRITE[0] ) state = hass.states.get(entity_id) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index eb2558aae3d..1a84c4f55ca 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -51,6 +51,9 @@ from .conftest import ( time_changed, ) +CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] +SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] + @pytest.fixture(name="sensor") async def sensor_fixture( @@ -193,7 +196,7 @@ async def test_sensor_setup_sensor( "10.0", "none", ) - for index, description in enumerate(SENSE_SENSORS): + for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue unique_id, entity_id = ids_from_device_description( @@ -241,7 +244,7 @@ async def test_sensor_setup_sensor_none( STATE_UNAVAILABLE, STATE_UNAVAILABLE, ) - for index, description in enumerate(SENSE_SENSORS): + for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue unique_id, entity_id = ids_from_device_description( @@ -411,7 +414,7 @@ async def test_sensor_setup_camera( ): """Test sensor entity setup for camera devices.""" # 3 from all, 7 from camera, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 24, 13) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) entity_registry = er.async_get(hass) @@ -421,7 +424,7 @@ async def test_sensor_setup_camera( "100.0", "20.0", ) - for index, description in enumerate(CAMERA_SENSORS): + for index, description in enumerate(CAMERA_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue unique_id, entity_id = ids_from_device_description( @@ -536,7 +539,7 @@ async def test_sensor_update_motion( ): """Test sensor motion entity.""" # 3 from all, 7 from camera, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 24, 13) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) _, entity_id = ids_from_device_description( Platform.SENSOR, camera, MOTION_SENSORS[0] @@ -584,7 +587,7 @@ async def test_sensor_update_alarm( assert_entity_counts(hass, Platform.SENSOR, 22, 14) _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, SENSE_SENSORS[4] + Platform.SENSOR, sensor, SENSE_SENSORS_WRITE[4] ) event_metadata = EventMetadata(sensor_id=sensor.id, alarm_type="smoke") @@ -632,7 +635,7 @@ async def test_sensor_update_alarm_with_last_trip_time( # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, SENSE_SENSORS[-3] + Platform.SENSOR, sensor, SENSE_SENSORS_WRITE[-3] ) entity_registry = er.async_get(hass) From adf0f629634d5a5d83174bc850b509996acfc490 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jun 2022 13:09:22 -0500 Subject: [PATCH 563/947] Add websocket api to fetch config entries (#73570) * Add websocket api to fetch config entries * add coverage for failure case --- .../components/config/config_entries.py | 118 +++++++---- .../components/config/test_config_entries.py | 192 +++++++++++++++++- 2 files changed, 265 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index b1756b58c3e..ac452666103 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -3,22 +3,29 @@ from __future__ import annotations import asyncio from http import HTTPStatus +from typing import Any from aiohttp import web import aiohttp.web_exceptions import voluptuous as vol -from homeassistant import config_entries, data_entry_flow, loader +from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, ) -from homeassistant.loader import Integration, async_get_config_flows +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_config_flows, + async_get_integration, +) async def async_setup(hass): @@ -33,6 +40,7 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_progress) @@ -50,49 +58,13 @@ class ConfigManagerEntryIndexView(HomeAssistantView): async def get(self, request): """List available config entries.""" hass: HomeAssistant = request.app["hass"] - - kwargs = {} + domain = None if "domain" in request.query: - kwargs["domain"] = request.query["domain"] - - entries = hass.config_entries.async_entries(**kwargs) - - if "type" not in request.query: - return self.json([entry_json(entry) for entry in entries]) - - integrations = {} - type_filter = request.query["type"] - - async def load_integration( - hass: HomeAssistant, domain: str - ) -> Integration | None: - """Load integration.""" - try: - return await loader.async_get_integration(hass, domain) - except loader.IntegrationNotFound: - return None - - # Fetch all the integrations so we can check their type - for integration in await asyncio.gather( - *( - load_integration(hass, domain) - for domain in {entry.domain for entry in entries} - ) - ): - if integration: - integrations[integration.domain] = integration - - entries = [ - entry - for entry in entries - if (type_filter != "helper" and entry.domain not in integrations) - or ( - entry.domain in integrations - and integrations[entry.domain].integration_type == type_filter - ) - ] - - return self.json([entry_json(entry) for entry in entries]) + domain = request.query["domain"] + type_filter = None + if "type" in request.query: + type_filter = request.query["type"] + return self.json(await async_matching_config_entries(hass, type_filter, domain)) class ConfigManagerEntryResourceView(HomeAssistantView): @@ -415,6 +387,64 @@ async def ignore_config_flow(hass, connection, msg): connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + vol.Required("type"): "config_entries/get", + vol.Optional("type_filter"): str, + vol.Optional("domain"): str, + } +) +@websocket_api.async_response +async def config_entries_get( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return matching config entries by type and/or domain.""" + connection.send_result( + msg["id"], + await async_matching_config_entries( + hass, msg.get("type_filter"), msg.get("domain") + ), + ) + + +async def async_matching_config_entries( + hass: HomeAssistant, type_filter: str | None, domain: str | None +) -> list[dict[str, Any]]: + """Return matching config entries by type and/or domain.""" + kwargs = {} + if domain: + kwargs["domain"] = domain + entries = hass.config_entries.async_entries(**kwargs) + + if type_filter is None: + return [entry_json(entry) for entry in entries] + + integrations = {} + # Fetch all the integrations so we can check their type + tasks = ( + async_get_integration(hass, domain) + for domain in {entry.domain for entry in entries} + ) + results = await asyncio.gather(*tasks, return_exceptions=True) + for integration_or_exc in results: + if isinstance(integration_or_exc, Integration): + integrations[integration_or_exc.domain] = integration_or_exc + elif not isinstance(integration_or_exc, IntegrationNotFound): + raise integration_or_exc + + entries = [ + entry + for entry in entries + if (type_filter != "helper" and entry.domain not in integrations) + or ( + entry.domain in integrations + and integrations[entry.domain].integration_type == type_filter + ) + ] + + return [entry_json(entry) for entry in entries] + + @callback def entry_json(entry: config_entries.ConfigEntry) -> dict: """Return JSON value of a config entry.""" diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bb0d67fc306..611a7f75939 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -2,7 +2,7 @@ from collections import OrderedDict from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import ANY, AsyncMock, patch import pytest import voluptuous as vol @@ -13,6 +13,7 @@ from homeassistant.config_entries import HANDLERS, ConfigFlow from homeassistant.core import callback from homeassistant.generated import config_flows from homeassistant.helpers import config_entry_flow, config_validation as cv +from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from tests.common import ( @@ -1113,3 +1114,192 @@ async def test_ignore_flow_nonexisting(hass, hass_ws_client): assert not response["success"] assert response["error"]["code"] == "not_found" + + +async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): + """Test get entries with the websocket api.""" + assert await async_setup_component(hass, "config", {}) + mock_integration(hass, MockModule("comp1")) + mock_integration( + hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) + ) + mock_integration(hass, MockModule("comp3")) + entry = MockConfigEntry( + domain="comp1", + title="Test 1", + source="bla", + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain="comp2", + title="Test 2", + source="bla2", + state=core_ce.ConfigEntryState.SETUP_ERROR, + reason="Unsupported API", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp3", + title="Test 3", + source="bla3", + disabled_by=core_ce.ConfigEntryDisabler.USER, + ).add_to_hass(hass) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/get", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + { + "disabled_by": None, + "domain": "comp2", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", + "source": "bla2", + "state": "setup_error", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 2", + }, + { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + ] + + await ws_client.send_json( + { + "id": 6, + "type": "config_entries/get", + "domain": "comp1", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 6 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + } + ] + # Verify we skip broken integrations + + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=IntegrationNotFound("any"), + ): + await ws_client.send_json( + { + "id": 7, + "type": "config_entries/get", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 7 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + { + "disabled_by": None, + "domain": "comp2", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", + "source": "bla2", + "state": "setup_error", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 2", + }, + { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + ] + + # Verify we raise if something really goes wrong + + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=Exception, + ): + await ws_client.send_json( + { + "id": 8, + "type": "config_entries/get", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 8 + assert response["success"] is False From 67618311fa0a400daeef10f8e7256c24f21f9dd0 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 21 Jun 2022 15:21:47 -0400 Subject: [PATCH 564/947] Fix auth_sign_path with query params (#73240) Co-authored-by: J. Nick Koston --- homeassistant/components/http/auth.py | 17 ++++-- tests/components/http/test_auth.py | 78 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index dab6abede4c..d89a36cdb86 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -7,11 +7,11 @@ from ipaddress import ip_address import logging import secrets from typing import Final -from urllib.parse import unquote from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from yarl import URL from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User @@ -57,18 +57,24 @@ def async_sign_path( else: refresh_token_id = hass.data[STORAGE_KEY] + url = URL(path) now = dt_util.utcnow() + params = dict(sorted(url.query.items())) encoded = jwt.encode( { "iss": refresh_token_id, - "path": unquote(path), + "path": url.path, + "params": params, "iat": now, "exp": now + expiration, }, secret, algorithm="HS256", ) - return f"{path}?{SIGN_QUERY_PARAM}={encoded}" + + params[SIGN_QUERY_PARAM] = encoded + url = url.with_query(params) + return f"{url.path}?{url.query_string}" @callback @@ -176,6 +182,11 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False + params = dict(sorted(request.query.items())) + del params[SIGN_QUERY_PARAM] + if claims["params"] != params: + return False + refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) if refresh_token is None: diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 4a2e1e8aed3..d4995383297 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -17,6 +17,7 @@ from homeassistant.components import websocket_api from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, + SIGN_QUERY_PARAM, STORAGE_KEY, async_setup_auth, async_sign_path, @@ -294,6 +295,83 @@ async def test_auth_access_signed_path_with_refresh_token( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_with_query_param( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and query params.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, "/?test=test", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +async def test_auth_access_signed_path_with_query_param_order( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and query params different order.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, + "/?test=test&foo=bar", + timedelta(seconds=5), + refresh_token_id=refresh_token.id, + ) + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +@pytest.mark.parametrize( + "base_url,test_url", + [ + ("/?test=test", "/?test=test&foo=bar"), + ("/", "/?test=test"), + ("/?test=test&foo=bar", "/?test=test&foo=baz"), + ("/?test=test&foo=bar", "/?test=test"), + ], +) +async def test_auth_access_signed_path_with_query_param_tamper( + hass, app, aiohttp_client, hass_access_token, base_url: str, test_url: str +): + """Test access with signed url and query params that have been tampered with.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, base_url, timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + url = yarl.URL(signed_path) + token = url.query.get(SIGN_QUERY_PARAM) + + req = await client.get(f"{test_url}&{SIGN_QUERY_PARAM}={token}") + assert req.status == HTTPStatus.UNAUTHORIZED + + async def test_auth_access_signed_path_via_websocket( hass, app, hass_ws_client, hass_read_only_access_token ): From 274f585646f93b6f96aae7b693fda1fd1136d07f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 21 Jun 2022 22:21:31 +0200 Subject: [PATCH 565/947] Tweak title of zha config entry created by yellow hw (#73797) --- .../homeassistant_yellow/__init__.py | 3 +- homeassistant/components/zha/config_flow.py | 2 +- .../homeassistant_yellow/test_init.py | 35 +++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homeassistant_yellow/__init__.py b/homeassistant/components/homeassistant_yellow/__init__.py index 89a73ab769a..e6eaa2f7fce 100644 --- a/homeassistant/components/homeassistant_yellow/__init__.py +++ b/homeassistant/components/homeassistant_yellow/__init__.py @@ -23,12 +23,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "zha", context={"source": "hardware"}, data={ - "radio_type": "efr32", + "name": "Yellow", "port": { "path": "/dev/ttyAMA1", "baudrate": 115200, "flow_control": "hardware", }, + "radio_type": "efr32", }, ) diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 03be28b9d8c..69da95e8528 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -269,7 +269,7 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except vol.Invalid: return self.async_abort(reason="invalid_hardware_data") - self._title = data["port"]["path"] + self._title = data.get("name", data["port"]["path"]) self._set_confirm_only() return await self.async_step_confirm_hardware() diff --git a/tests/components/homeassistant_yellow/test_init.py b/tests/components/homeassistant_yellow/test_init.py index 308c392ea26..f534c7cd587 100644 --- a/tests/components/homeassistant_yellow/test_init.py +++ b/tests/components/homeassistant_yellow/test_init.py @@ -41,6 +41,41 @@ async def test_setup_entry( assert len(hass.config_entries.flow.async_progress_by_handler("zha")) == num_flows +async def test_setup_zha(hass: HomeAssistant) -> None: + """Test zha gets the right config.""" + mock_integration(hass, MockModule("hassio")) + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={}, + title="Home Assistant Yellow", + ) + config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.homeassistant_yellow.get_os_info", + return_value={"board": "yellow"}, + ) as mock_get_os_info, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert len(mock_get_os_info.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries("zha")[0] + assert config_entry.data == { + "device": { + "baudrate": 115200, + "flow_control": "hardware", + "path": "/dev/ttyAMA1", + }, + "radio_type": "ezsp", + } + assert config_entry.options == {} + assert config_entry.title == "Yellow" + + async def test_setup_entry_wrong_board(hass: HomeAssistant) -> None: """Test setup of a config entry with wrong board type.""" mock_integration(hass, MockModule("hassio")) From 562ad18fb45860f8d15261fe35b1e4519d62d9f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 22 Jun 2022 00:45:47 +0200 Subject: [PATCH 566/947] Bump pychromecast to 12.1.4 (#73792) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 644a517c666..d7cddaaa293 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==12.1.3"], + "requirements": ["pychromecast==12.1.4"], "after_dependencies": [ "cloud", "http", diff --git a/requirements_all.txt b/requirements_all.txt index 2daa37f0808..859de73843f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1408,7 +1408,7 @@ pycfdns==1.2.2 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==12.1.3 +pychromecast==12.1.4 # homeassistant.components.pocketcasts pycketcasts==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd02dc2ce5c..e576babe4b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -953,7 +953,7 @@ pybotvac==0.0.23 pycfdns==1.2.2 # homeassistant.components.cast -pychromecast==12.1.3 +pychromecast==12.1.4 # homeassistant.components.climacell pyclimacell==0.18.2 From 78dd522ccda18c436a827e036d5a0f1dd8a7673e Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 22 Jun 2022 00:26:36 +0000 Subject: [PATCH 567/947] [ci skip] Translation update --- .../eight_sleep/translations/tr.json | 19 +++++ .../components/google/translations/ca.json | 1 + .../components/google/translations/en.json | 1 + .../components/google/translations/et.json | 1 + .../components/google/translations/fr.json | 1 + .../components/google/translations/pt-BR.json | 1 + .../components/google/translations/tr.json | 4 + .../google/translations/zh-Hant.json | 1 + .../components/nest/translations/ca.json | 1 + .../components/nest/translations/en.json | 1 + .../components/nest/translations/et.json | 1 + .../components/nest/translations/fr.json | 1 + .../components/nest/translations/pt-BR.json | 1 + .../components/nest/translations/tr.json | 30 ++++++++ .../components/nest/translations/zh-Hant.json | 1 + .../components/plugwise/translations/tr.json | 4 +- .../radiotherm/translations/tr.json | 31 ++++++++ .../components/scrape/translations/tr.json | 73 +++++++++++++++++++ .../sensibo/translations/sensor.tr.json | 8 ++ .../components/skybell/translations/tr.json | 21 ++++++ .../tankerkoenig/translations/tr.json | 11 ++- .../transmission/translations/de.json | 10 ++- .../transmission/translations/pt-BR.json | 10 ++- .../transmission/translations/tr.json | 10 ++- .../transmission/translations/zh-Hant.json | 10 ++- 25 files changed, 246 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/eight_sleep/translations/tr.json create mode 100644 homeassistant/components/radiotherm/translations/tr.json create mode 100644 homeassistant/components/scrape/translations/tr.json create mode 100644 homeassistant/components/sensibo/translations/sensor.tr.json create mode 100644 homeassistant/components/skybell/translations/tr.json diff --git a/homeassistant/components/eight_sleep/translations/tr.json b/homeassistant/components/eight_sleep/translations/tr.json new file mode 100644 index 00000000000..19e958601cb --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/tr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "cannot_connect": "Sekiz Uyku bulutuna ba\u011flan\u0131lam\u0131yor: {error}" + }, + "error": { + "cannot_connect": "Sekiz Uyku bulutuna ba\u011flan\u0131lam\u0131yor: {error}" + }, + "step": { + "user": { + "data": { + "password": "Parola", + "username": "Kullan\u0131c\u0131 Ad\u0131" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/ca.json b/homeassistant/components/google/translations/ca.json index 27c9a11f92b..004fcb67f46 100644 --- a/homeassistant/components/google/translations/ca.json +++ b/homeassistant/components/google/translations/ca.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "El compte ja est\u00e0 configurat", "already_in_progress": "El flux de configuraci\u00f3 ja est\u00e0 en curs", + "cannot_connect": "Ha fallat la connexi\u00f3", "code_expired": "El codi d'autenticaci\u00f3 ha caducat o la configuraci\u00f3 de credencials no \u00e9s v\u00e0lida. Torna-ho a provar.", "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", diff --git a/homeassistant/components/google/translations/en.json b/homeassistant/components/google/translations/en.json index 1bebde4f63a..2ef34ccc84b 100644 --- a/homeassistant/components/google/translations/en.json +++ b/homeassistant/components/google/translations/en.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "Account is already configured", "already_in_progress": "Configuration flow is already in progress", + "cannot_connect": "Failed to connect", "code_expired": "Authentication code expired or credential setup is invalid, please try again.", "invalid_access_token": "Invalid access token", "missing_configuration": "The component is not configured. Please follow the documentation.", diff --git a/homeassistant/components/google/translations/et.json b/homeassistant/components/google/translations/et.json index 11447b1669f..1b5aff5774b 100644 --- a/homeassistant/components/google/translations/et.json +++ b/homeassistant/components/google/translations/et.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "Kasutaja on juba seadistatud", "already_in_progress": "Seadistamine on juba k\u00e4imas", + "cannot_connect": "\u00dchendamine nurjus", "code_expired": "Tuvastuskood on aegunud v\u00f5i mandaadi seadistus on vale, proovi uuesti.", "invalid_access_token": "Vigane juurdep\u00e4\u00e4sut\u00f5end", "missing_configuration": "Komponent pole seadistatud. Palun loe dokumentatsiooni.", diff --git a/homeassistant/components/google/translations/fr.json b/homeassistant/components/google/translations/fr.json index ed60ccb1c1b..7c5e4927787 100644 --- a/homeassistant/components/google/translations/fr.json +++ b/homeassistant/components/google/translations/fr.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "already_in_progress": "La configuration est d\u00e9j\u00e0 en cours", + "cannot_connect": "\u00c9chec de connexion", "code_expired": "Le code d'authentification a expir\u00e9 ou la configuration des informations d'identification n'est pas valide, veuillez r\u00e9essayer.", "invalid_access_token": "Jeton d'acc\u00e8s non valide", "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", diff --git a/homeassistant/components/google/translations/pt-BR.json b/homeassistant/components/google/translations/pt-BR.json index 381976e0284..e934155c9fa 100644 --- a/homeassistant/components/google/translations/pt-BR.json +++ b/homeassistant/components/google/translations/pt-BR.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "A conta j\u00e1 foi configurada", "already_in_progress": "O fluxo de configura\u00e7\u00e3o j\u00e1 est\u00e1 em andamento", + "cannot_connect": "Falha ao se conectar", "code_expired": "O c\u00f3digo de autentica\u00e7\u00e3o expirou ou a configura\u00e7\u00e3o da credencial \u00e9 inv\u00e1lida. Tente novamente.", "invalid_access_token": "Token de acesso inv\u00e1lido", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", diff --git a/homeassistant/components/google/translations/tr.json b/homeassistant/components/google/translations/tr.json index f7b5b6d79ff..ec265926695 100644 --- a/homeassistant/components/google/translations/tr.json +++ b/homeassistant/components/google/translations/tr.json @@ -1,8 +1,12 @@ { + "application_credentials": { + "description": "Home Assistant'\u0131n Google Takviminize eri\u015fmesine izin vermek i\u00e7in [OAuth izin ekran\u0131]( {oauth_consent_url} ) i\u00e7in [talimatlar\u0131]( {more_info_url} ) uygulay\u0131n. Ayr\u0131ca, Takviminize ba\u011fl\u0131 Uygulama Kimlik Bilgileri olu\u015fturman\u0131z gerekir:\n 1. [Kimlik Bilgileri]( {oauth_creds_url} ) \u00f6\u011fesine gidin ve **Kimlik Bilgileri Olu\u015ftur**'u t\u0131klay\u0131n.\n 1. A\u00e7\u0131l\u0131r listeden **OAuth istemci kimli\u011fi**'ni se\u00e7in.\n 1. Uygulama T\u00fcr\u00fc i\u00e7in **TV ve S\u0131n\u0131rl\u0131 Giri\u015f cihazlar\u0131**'n\u0131 se\u00e7in. \n\n" + }, "config": { "abort": { "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "already_in_progress": "Yap\u0131land\u0131rma ak\u0131\u015f\u0131 zaten devam ediyor", + "cannot_connect": "Ba\u011flanma hatas\u0131", "code_expired": "Kimlik do\u011frulama kodunun s\u00fcresi doldu veya kimlik bilgisi kurulumu ge\u00e7ersiz, l\u00fctfen tekrar deneyin.", "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", diff --git a/homeassistant/components/google/translations/zh-Hant.json b/homeassistant/components/google/translations/zh-Hant.json index 7b83154db1a..bee271ea8e7 100644 --- a/homeassistant/components/google/translations/zh-Hant.json +++ b/homeassistant/components/google/translations/zh-Hant.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "already_in_progress": "\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d", + "cannot_connect": "\u9023\u7dda\u5931\u6557", "code_expired": "\u8a8d\u8b49\u78bc\u5df2\u904e\u671f\u6216\u6191\u8b49\u8a2d\u5b9a\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", diff --git a/homeassistant/components/nest/translations/ca.json b/homeassistant/components/nest/translations/ca.json index 0767d9c1cf1..791be9975eb 100644 --- a/homeassistant/components/nest/translations/ca.json +++ b/homeassistant/components/nest/translations/ca.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "El compte ja est\u00e0 configurat", "authorize_url_timeout": "Temps d'espera esgotat durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.", "invalid_access_token": "Token d'acc\u00e9s inv\u00e0lid", "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.", diff --git a/homeassistant/components/nest/translations/en.json b/homeassistant/components/nest/translations/en.json index 2f3324ea956..5f026e55f31 100644 --- a/homeassistant/components/nest/translations/en.json +++ b/homeassistant/components/nest/translations/en.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "Account is already configured", "authorize_url_timeout": "Timeout generating authorize URL.", "invalid_access_token": "Invalid access token", "missing_configuration": "The component is not configured. Please follow the documentation.", diff --git a/homeassistant/components/nest/translations/et.json b/homeassistant/components/nest/translations/et.json index 3d8cd25f47c..b07845f7dab 100644 --- a/homeassistant/components/nest/translations/et.json +++ b/homeassistant/components/nest/translations/et.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "Konto on juba h\u00e4\u00e4lestatud", "authorize_url_timeout": "Tuvastamise URL-i loomise ajal\u00f5pp.", "invalid_access_token": "Vigane juurdep\u00e4\u00e4sut\u00f5end", "missing_configuration": "Osis pole seadistatud. Vaata dokumentatsiooni.", diff --git a/homeassistant/components/nest/translations/fr.json b/homeassistant/components/nest/translations/fr.json index 30adccca440..16990b93193 100644 --- a/homeassistant/components/nest/translations/fr.json +++ b/homeassistant/components/nest/translations/fr.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Le compte est d\u00e9j\u00e0 configur\u00e9", "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification expir\u00e9.", "invalid_access_token": "Jeton d'acc\u00e8s non valide", "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.", diff --git a/homeassistant/components/nest/translations/pt-BR.json b/homeassistant/components/nest/translations/pt-BR.json index 1a4b2d8fad9..d96387aee82 100644 --- a/homeassistant/components/nest/translations/pt-BR.json +++ b/homeassistant/components/nest/translations/pt-BR.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "Conta j\u00e1 configurada", "authorize_url_timeout": "Tempo limite gerando URL de autoriza\u00e7\u00e3o.", "invalid_access_token": "Token de acesso inv\u00e1lido", "missing_configuration": "O componente n\u00e3o est\u00e1 configurado. Por favor, siga a documenta\u00e7\u00e3o.", diff --git a/homeassistant/components/nest/translations/tr.json b/homeassistant/components/nest/translations/tr.json index f39b0fc935e..1732443184e 100644 --- a/homeassistant/components/nest/translations/tr.json +++ b/homeassistant/components/nest/translations/tr.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "Bulut Konsolunu yap\u0131land\u0131rmak i\u00e7in [talimatlar\u0131]( {more_info_url} ) izleyin: \n\n 1. [OAuth izin ekran\u0131na]( {oauth_consent_url} ) gidin ve yap\u0131land\u0131r\u0131n\n 1. [Kimlik Bilgileri]( {oauth_creds_url} ) \u00f6\u011fesine gidin ve **Kimlik Bilgileri Olu\u015ftur**'u t\u0131klay\u0131n.\n 1. A\u00e7\u0131l\u0131r listeden **OAuth istemci kimli\u011fi**'ni se\u00e7in.\n 1. Uygulama T\u00fcr\u00fc i\u00e7in **Web Uygulamas\u0131**'n\u0131 se\u00e7in.\n 1. *Yetkili y\u00f6nlendirme URI's\u0131* alt\u0131na ` {redirect_url} ` ekleyin." + }, "config": { "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", "authorize_url_timeout": "Yetkilendirme URL'si olu\u015ftururken zaman a\u015f\u0131m\u0131.", "invalid_access_token": "Ge\u00e7ersiz eri\u015fim anahtar\u0131", "missing_configuration": "Bile\u015fen yap\u0131land\u0131r\u0131lmam\u0131\u015f. L\u00fctfen belgeleri takip edin.", @@ -29,6 +33,32 @@ "description": "Google hesab\u0131n\u0131z\u0131 ba\u011flamak i\u00e7in [hesab\u0131n\u0131z\u0131 yetkilendirin]( {url} ). \n\n Yetkilendirmeden sonra, sa\u011flanan Auth Token kodunu a\u015fa\u011f\u0131ya kopyalay\u0131p yap\u0131\u015ft\u0131r\u0131n.", "title": "Google Hesab\u0131n\u0131 Ba\u011fla" }, + "auth_upgrade": { + "description": "App Auth, g\u00fcenli\u011fi art\u0131rmak i\u00e7in Google taraf\u0131ndan kullan\u0131mdan kald\u0131r\u0131ld\u0131 ve yeni uygulama kimlik bilgileri olu\u015fturarak i\u015flem yapman\u0131z gerekiyor. \n\n Takip etmek i\u00e7in [belgeleri]( {more_info_url} ) a\u00e7\u0131n, \u00e7\u00fcnk\u00fc sonraki ad\u0131mlar Nest cihazlar\u0131n\u0131za eri\u015fimi geri y\u00fcklemek i\u00e7in atman\u0131z gereken ad\u0131mlar konusunda size rehberlik edecektir.", + "title": "Nest: Uygulama Yetkilendirmesinin Kullan\u0131mdan Kald\u0131r\u0131lmas\u0131" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Bulut Proje Kimli\u011fi" + }, + "description": "Bulut Projesi Kimli\u011fini a\u015fa\u011f\u0131ya girin, \u00f6rne\u011fin *example-project-12345*. [Google Cloud Console]( {cloud_console_url} ) veya [daha fazla bilgi]( {more_info_url} ) i\u00e7in belgelere bak\u0131n.", + "title": "Nest: Bulut Proje Kimli\u011fini Girin" + }, + "create_cloud_project": { + "description": "Nest entegrasyonu, Ak\u0131ll\u0131 Cihaz Y\u00f6netimi API'sini kullanarak Nest Termostatlar\u0131n\u0131z\u0131, Kameralar\u0131n\u0131z\u0131 ve Kap\u0131 Zillerinizi entegre etmenize olanak tan\u0131r. SDM API **bir kereye mahsus 5 ABD dolar\u0131** tutar\u0131nda kurulum \u00fccreti gerektirir. [daha fazla bilgi]( {more_info_url} ) i\u00e7in belgelere bak\u0131n. \n\n 1. [Google Bulut Konsoluna]( {cloud_console_url} ) gidin.\n 1. Bu ilk projenizse, **Proje Olu\u015ftur**'a ve ard\u0131ndan **Yeni Proje**'ye t\u0131klay\u0131n.\n 1. Bulut Projenize bir Ad verin ve ard\u0131ndan **Olu\u015ftur**'a t\u0131klay\u0131n.\n 1. Bulut Projesi Kimli\u011fini kaydedin, \u00f6rne\u011fin *example-project-12345* daha sonra ihtiya\u00e7 duyaca\u011f\u0131n\u0131z i\u00e7in\n 1. [Smart Device Management API]( {sdm_api_url} ) için API Kitapl\u0131\u011f\u0131na gidin ve **Etkinle\u015ftir**'i t\u0131klay\u0131n.\n 1. [Cloud Pub/Sub API]( {pubsub_api_url} ) için API Kitapl\u0131\u011f\u0131'na gidin ve **Etkinle\u015ftir**'i t\u0131klay\u0131n. \n\n Bulut projeniz kuruldu\u011funda devam edin.", + "title": "Nest: Bulut Projesi olu\u015fturma ve yap\u0131land\u0131rma" + }, + "device_project": { + "data": { + "project_id": "Cihaz Eri\u015fim Projesi Kimli\u011fi" + }, + "description": "Kurmak i\u00e7n **5 ABD dolar\u0131 \u00fccret** gerektiren bir Nest Cihaz Eri\u015fimi projesi olu\u015fturun.\n 1. [Cihaz Eri\u015fim Konsolu]( {device_access_console_url} )'e gidin ve \u00f6deme ak\u0131\u015f\u0131ndan ge\u00e7in.\n 1. **Proje olu\u015ftur**'a t\u0131klay\u0131n\n 1. Cihaz Eri\u015fimi projenize bir ad verin ve **\u0130leri**'ye t\u0131klay\u0131n.\n 1. OAuth M\u00fc\u015fteri Kimli\u011finizi girin\n 1. **Etkinle\u015ftir** ve **Proje olu\u015ftur**'a t\u0131klayarak etkinlikleri etkinle\u015ftirin. \n\n Cihaz Eri\u015fim Projesi Kimli\u011finizi a\u015fa\u011f\u0131ya girin ([daha fazla bilgi]( {more_info_url} )).\n", + "title": "Yuva: Bir Cihaz Eri\u015fim Projesi Olu\u015fturun" + }, + "device_project_upgrade": { + "description": "Nest Device Access Project'i yeni OAuth \u0130stemci Kimli\u011finizle g\u00fcncelleyin ([daha fazla bilgi]( {more_info_url} ))\n 1. [Cihaz Eri\u015fim Konsolu]'na gidin ( {device_access_console_url} ).\n 1. *OAuth \u0130stemci Kimli\u011fi*'nin yan\u0131ndaki \u00e7\u00f6p kutusu simgesini t\u0131klay\u0131n.\n 1. `...` ta\u015fma men\u00fcs\u00fcn\u00fc; ve *M\u00fc\u015fteri Kimli\u011fi Ekle*'yi t\u0131klay\u0131n.\n 1. Yeni OAuth \u0130stemci Kimli\u011finizi girin ve **Ekle**'yi t\u0131klay\u0131n. \n\n OAuth M\u00fc\u015fteri Kimli\u011finiz: ` {client_id} `", + "title": "Nest: Cihaz Eri\u015fim Projesini G\u00fcncelle" + }, "init": { "data": { "flow_impl": "Sa\u011flay\u0131c\u0131" diff --git a/homeassistant/components/nest/translations/zh-Hant.json b/homeassistant/components/nest/translations/zh-Hant.json index 8eb646217b1..11a4823d77a 100644 --- a/homeassistant/components/nest/translations/zh-Hant.json +++ b/homeassistant/components/nest/translations/zh-Hant.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002", "invalid_access_token": "\u5b58\u53d6\u6b0a\u6756\u7121\u6548", "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002", diff --git a/homeassistant/components/plugwise/translations/tr.json b/homeassistant/components/plugwise/translations/tr.json index b4a07700d6f..b4b49a5e52d 100644 --- a/homeassistant/components/plugwise/translations/tr.json +++ b/homeassistant/components/plugwise/translations/tr.json @@ -15,7 +15,9 @@ "data": { "flow_type": "Ba\u011flant\u0131 t\u00fcr\u00fc", "host": "IP Adresi", - "port": "Port" + "password": "Smile Kimli\u011fi", + "port": "Port", + "username": "Smile Kullan\u0131c\u0131 Ad\u0131" }, "description": "\u00dcr\u00fcn:", "title": "Plugwise tipi" diff --git a/homeassistant/components/radiotherm/translations/tr.json b/homeassistant/components/radiotherm/translations/tr.json new file mode 100644 index 00000000000..f8e6b4f7a6d --- /dev/null +++ b/homeassistant/components/radiotherm/translations/tr.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "unknown": "Beklenmeyen hata" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "{name} {model} ( {host} ) kurulumu yapmak istiyor musunuz?" + }, + "user": { + "data": { + "host": "Sunucu" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "hold_temp": "S\u0131cakl\u0131\u011f\u0131 ayarlarken kal\u0131c\u0131 bir bekletme ayarlay\u0131n." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/tr.json b/homeassistant/components/scrape/translations/tr.json new file mode 100644 index 00000000000..954ce1ad052 --- /dev/null +++ b/homeassistant/components/scrape/translations/tr.json @@ -0,0 +1,73 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + }, + "step": { + "user": { + "data": { + "attribute": "\u00d6znitelik", + "authentication": "Kimlik do\u011frulama", + "device_class": "Cihaz S\u0131n\u0131f\u0131", + "headers": "Ba\u015fl\u0131klar", + "index": "Dizin", + "name": "Ad", + "password": "Parola", + "resource": "Kaynak", + "select": "Se\u00e7", + "state_class": "Durum S\u0131n\u0131f\u0131", + "unit_of_measurement": "\u00d6l\u00e7\u00fc Birimi", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "value_template": "De\u011fer \u015eablonu", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "data_description": { + "attribute": "Se\u00e7ilen etikette bir \u00f6zelli\u011fin de\u011ferini al\u0131n", + "authentication": "HTTP kimlik do\u011frulamas\u0131n\u0131n t\u00fcr\u00fc. Temel veya basit", + "device_class": "\u00d6nu\u00e7taki simgeyi ayarlamak i\u00e7in sens\u00f6r\u00fcn t\u00fcr\u00fc/s\u0131n\u0131f\u0131", + "headers": "Web iste\u011fi i\u00e7in kullan\u0131lacak ba\u015fl\u0131klar", + "index": "CSS se\u00e7ici taraf\u0131ndan d\u00f6nd\u00fcr\u00fclen \u00f6\u011felerden hangisinin kullan\u0131laca\u011f\u0131n\u0131 tan\u0131mlar", + "resource": "De\u011feri i\u00e7eren web sitesinin URL'si", + "select": "Hangi etiketin aranaca\u011f\u0131n\u0131 tan\u0131mlar. Ayr\u0131nt\u0131lar i\u00e7in Beautifulsoup CSS se\u00e7icilerini kontrol edin", + "state_class": "Sens\u00f6r\u00fcn state_class", + "value_template": "Sens\u00f6r\u00fcn durumunu almak i\u00e7in bir \u015fablon tan\u0131mlar", + "verify_ssl": "\u00d6rne\u011fin, kendinden imzal\u0131ysa, SSL/TLS sertifikas\u0131n\u0131n do\u011frulanmas\u0131n\u0131 etkinle\u015ftirir/devre d\u0131\u015f\u0131 b\u0131rak\u0131r" + } + } + } + }, + "options": { + "step": { + "init": { + "data": { + "attribute": "\u00d6znitelik", + "authentication": "Kimlik do\u011frulama", + "device_class": "Cihaz S\u0131n\u0131f\u0131", + "headers": "Ba\u015fl\u0131klar", + "index": "Dizin", + "name": "Ad", + "password": "Parola", + "resource": "Kaynak", + "select": "Se\u00e7", + "state_class": "Durum S\u0131n\u0131f\u0131", + "unit_of_measurement": "\u00d6l\u00e7\u00fc Birimi", + "username": "Kullan\u0131c\u0131 Ad\u0131", + "value_template": "De\u011fer \u015eablonu", + "verify_ssl": "SSL sertifikas\u0131n\u0131 do\u011frulay\u0131n" + }, + "data_description": { + "attribute": "Se\u00e7ilen etikette bir \u00f6zelli\u011fin de\u011ferini al\u0131n", + "authentication": "HTTP kimlik do\u011frulamas\u0131n\u0131n t\u00fcr\u00fc. Temel veya basit", + "device_class": "\u00d6nu\u00e7taki simgeyi ayarlamak i\u00e7in sens\u00f6r\u00fcn t\u00fcr\u00fc/s\u0131n\u0131f\u0131", + "headers": "Web iste\u011fi i\u00e7in kullan\u0131lacak ba\u015fl\u0131klar", + "index": "CSS se\u00e7ici taraf\u0131ndan d\u00f6nd\u00fcr\u00fclen \u00f6\u011felerden hangisinin kullan\u0131laca\u011f\u0131n\u0131 tan\u0131mlar", + "resource": "De\u011feri i\u00e7eren web sitesinin URL'si", + "select": "Hangi etiketin aranaca\u011f\u0131n\u0131 tan\u0131mlar. Ayr\u0131nt\u0131lar i\u00e7in Beautifulsoup CSS se\u00e7icilerini kontrol edin", + "state_class": "Sens\u00f6r\u00fcn state_class", + "value_template": "Sens\u00f6r\u00fcn durumunu almak i\u00e7in bir \u015fablon tan\u0131mlar", + "verify_ssl": "\u00d6rne\u011fin, kendinden imzal\u0131ysa, SSL/TLS sertifikas\u0131n\u0131n do\u011frulanmas\u0131n\u0131 etkinle\u015ftirir/devre d\u0131\u015f\u0131 b\u0131rak\u0131r" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.tr.json b/homeassistant/components/sensibo/translations/sensor.tr.json new file mode 100644 index 00000000000..3364a75abe2 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.tr.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal", + "s": "Duyarl\u0131" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/tr.json b/homeassistant/components/skybell/translations/tr.json new file mode 100644 index 00000000000..68bd9029559 --- /dev/null +++ b/homeassistant/components/skybell/translations/tr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Hesap zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" + }, + "error": { + "cannot_connect": "Ba\u011flanma hatas\u0131", + "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", + "unknown": "Beklenmeyen hata" + }, + "step": { + "user": { + "data": { + "email": "E-posta", + "password": "Parola" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tankerkoenig/translations/tr.json b/homeassistant/components/tankerkoenig/translations/tr.json index 2d88d2fa670..ca0038b6dbb 100644 --- a/homeassistant/components/tankerkoenig/translations/tr.json +++ b/homeassistant/components/tankerkoenig/translations/tr.json @@ -1,13 +1,19 @@ { "config": { "abort": { - "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Konum zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "invalid_auth": "Ge\u00e7ersiz kimlik do\u011frulama", "no_stations": "Menzilde herhangi bir istasyon bulunamad\u0131." }, "step": { + "reauth_confirm": { + "data": { + "api_key": "API Anahtar\u0131" + } + }, "select_station": { "data": { "stations": "\u0130stasyonlar" @@ -32,7 +38,8 @@ "init": { "data": { "scan_interval": "G\u00fcncelle\u015ftirme aral\u0131\u011f\u0131", - "show_on_map": "\u0130stasyonlar\u0131 haritada g\u00f6ster" + "show_on_map": "\u0130stasyonlar\u0131 haritada g\u00f6ster", + "stations": "\u0130stasyonlar" }, "title": "Tankerkoenig se\u00e7enekleri" } diff --git a/homeassistant/components/transmission/translations/de.json b/homeassistant/components/transmission/translations/de.json index 2355905d1f7..04274f2c1cb 100644 --- a/homeassistant/components/transmission/translations/de.json +++ b/homeassistant/components/transmission/translations/de.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Ger\u00e4t ist bereits konfiguriert" + "already_configured": "Ger\u00e4t ist bereits konfiguriert", + "reauth_successful": "Die erneute Authentifizierung war erfolgreich" }, "error": { "cannot_connect": "Verbindung fehlgeschlagen", @@ -9,6 +10,13 @@ "name_exists": "Name existiert bereits" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passwort" + }, + "description": "Das Passwort f\u00fcr {username} ist ung\u00fcltig.", + "title": "Integration erneut authentifizieren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/pt-BR.json b/homeassistant/components/transmission/translations/pt-BR.json index 3353884ef33..781d21e2900 100644 --- a/homeassistant/components/transmission/translations/pt-BR.json +++ b/homeassistant/components/transmission/translations/pt-BR.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado" + "already_configured": "Dispositivo j\u00e1 est\u00e1 configurado", + "reauth_successful": "Re-autenticado com sucesso" }, "error": { "cannot_connect": "Falha ao conectar", @@ -9,6 +10,13 @@ "name_exists": "O Nome j\u00e1 existe" }, "step": { + "reauth_confirm": { + "data": { + "password": "Senha" + }, + "description": "A senha para o usu\u00e1rio {username} est\u00e1 inv\u00e1lida.", + "title": "Re-autenticar integra\u00e7\u00e3o" + }, "user": { "data": { "host": "Nome do host", diff --git a/homeassistant/components/transmission/translations/tr.json b/homeassistant/components/transmission/translations/tr.json index 72b3410062e..bef2fd47ff7 100644 --- a/homeassistant/components/transmission/translations/tr.json +++ b/homeassistant/components/transmission/translations/tr.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f" + "already_configured": "Cihaz zaten yap\u0131land\u0131r\u0131lm\u0131\u015f", + "reauth_successful": "Yeniden kimlik do\u011frulama ba\u015far\u0131l\u0131 oldu" }, "error": { "cannot_connect": "Ba\u011flanma hatas\u0131", @@ -9,6 +10,13 @@ "name_exists": "Ad zaten var" }, "step": { + "reauth_confirm": { + "data": { + "password": "Parola" + }, + "description": "{username} i\u00e7n \u015fifre ge\u00e7ersiz.", + "title": "Entegrasyonu Yeniden Do\u011frula" + }, "user": { "data": { "host": "Sunucu", diff --git a/homeassistant/components/transmission/translations/zh-Hant.json b/homeassistant/components/transmission/translations/zh-Hant.json index b6769274148..fd3d3a909aa 100644 --- a/homeassistant/components/transmission/translations/zh-Hant.json +++ b/homeassistant/components/transmission/translations/zh-Hant.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "reauth_successful": "\u91cd\u65b0\u8a8d\u8b49\u6210\u529f" }, "error": { "cannot_connect": "\u9023\u7dda\u5931\u6557", @@ -9,6 +10,13 @@ "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u5bc6\u78bc" + }, + "description": "{username} \u5bc6\u78bc\u7121\u6548\u3002", + "title": "\u91cd\u65b0\u8a8d\u8b49\u6574\u5408" + }, "user": { "data": { "host": "\u4e3b\u6a5f\u7aef", From 21275669d56aab1f0e76a83ab87a5e11b8166e4f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 03:00:58 +0200 Subject: [PATCH 568/947] Fix inheritance in zha general channel (#73774) Fix general channel type hints in zha --- homeassistant/components/zha/core/channels/general.py | 5 +++-- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 8886085bf47..b2870d84e15 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -408,9 +408,9 @@ class OnOffConfiguration(ZigbeeChannel): """OnOff Configuration channel.""" -@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Ota.cluster_id) -class Ota(ZigbeeChannel): +@registries.CLIENT_CHANNELS_REGISTRY.register(general.Ota.cluster_id) +class Ota(ClientChannel): """OTA Channel.""" BIND: bool = False @@ -427,6 +427,7 @@ class Ota(ZigbeeChannel): signal_id = self._ch_pool.unique_id.split("-")[0] if cmd_name == "query_next_image": + assert args self.async_send_signal(SIGNAL_UPDATE_DEVICE.format(signal_id), args[3]) diff --git a/mypy.ini b/mypy.ini index 98884d333e7..b01acbe311f 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2997,9 +2997,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.channels.base] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels.general] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.homeautomation] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index b519e7f2daf..e5bb404b715 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -148,7 +148,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.zha.button", "homeassistant.components.zha.climate", "homeassistant.components.zha.core.channels.base", - "homeassistant.components.zha.core.channels.general", "homeassistant.components.zha.core.channels.homeautomation", "homeassistant.components.zha.core.channels.hvac", "homeassistant.components.zha.core.channels.security", From 6c83ed4c9dbe0a4d272a9435a8156f68133325ec Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 03:02:08 +0200 Subject: [PATCH 569/947] Fix api, button and climate type hints in zha (#73771) * Fix zha api type hints * Fix zha button type hints * Fix zha climate type hints --- homeassistant/components/zha/api.py | 14 +++++++++---- homeassistant/components/zha/button.py | 4 ++-- homeassistant/components/zha/climate.py | 26 ++++++++++++------------- mypy.ini | 9 --------- script/hassfest/mypy_config.py | 3 --- 5 files changed, 25 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 737bef5ddff..cc4dd45689e 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -346,7 +346,7 @@ async def websocket_get_groupable_devices( groupable_devices = [] for device in devices: - entity_refs = zha_gateway.device_registry.get(device.ieee) + entity_refs = zha_gateway.device_registry[device.ieee] for ep_id in device.async_get_groupable_endpoints(): groupable_devices.append( { @@ -456,6 +456,7 @@ async def websocket_add_group( group_id: int | None = msg.get(GROUP_ID) members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) group = await zha_gateway.async_create_zigpy_group(group_name, members, group_id) + assert group connection.send_result(msg[ID], group.group_info) @@ -559,7 +560,7 @@ async def websocket_reconfigure_node( """Reconfigure a ZHA nodes entities by its ieee address.""" zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = msg[ATTR_IEEE] - device: ZHADevice = zha_gateway.get_device(ieee) + device: ZHADevice | None = zha_gateway.get_device(ieee) async def forward_messages(data): """Forward events to websocket.""" @@ -577,6 +578,7 @@ async def websocket_reconfigure_node( connection.subscriptions[msg["id"]] = async_cleanup _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + assert device hass.async_create_task(device.async_configure()) @@ -905,6 +907,7 @@ async def websocket_bind_group( group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) + assert source_device await source_device.async_bind_to_group(group_id, bindings) @@ -927,6 +930,7 @@ async def websocket_unbind_group( group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] source_device = zha_gateway.get_device(source_ieee) + assert source_device await source_device.async_unbind_from_group(group_id, bindings) @@ -941,6 +945,8 @@ async def async_binding_operation( source_device = zha_gateway.get_device(source_ieee) target_device = zha_gateway.get_device(target_ieee) + assert source_device + assert target_device clusters_to_bind = await get_matched_clusters(source_device, target_device) zdo = source_device.device.zdo @@ -997,7 +1003,7 @@ async def websocket_get_configuration( return cv.custom_serializer(schema) - data = {"schemas": {}, "data": {}} + data: dict[str, dict[str, Any]] = {"schemas": {}, "data": {}} for section, schema in ZHA_CONFIG_SCHEMAS.items(): if section == ZHA_ALARM_OPTIONS and not async_cluster_exists( hass, IasAce.cluster_id @@ -1084,7 +1090,7 @@ def async_load_api(hass: HomeAssistant) -> None: """Remove a node from the network.""" zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee: EUI64 = service.data[ATTR_IEEE] - zha_device: ZHADevice = zha_gateway.get_device(ieee) + zha_device: ZHADevice | None = zha_gateway.get_device(ieee) if zha_device is not None and ( zha_device.is_coordinator and zha_device.ieee == zha_gateway.application_controller.ieee diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index ed0836042d2..29bfb2ca248 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -59,7 +59,7 @@ async def async_setup_entry( class ZHAButton(ZhaEntity, ButtonEntity): """Defines a ZHA button.""" - _command_name: str = None + _command_name: str def __init__( self, @@ -118,7 +118,7 @@ class ZHAIdentifyButton(ZHAButton): class ZHAAttributeButton(ZhaEntity, ButtonEntity): """Defines a ZHA button, which stes value to an attribute.""" - _attribute_name: str = None + _attribute_name: str _attribute_value: Any = None def __init__( diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index 291e8413e16..d8b2f0db3af 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -73,16 +73,16 @@ MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.CLIMATE) RUNNING_MODE = {0x00: HVACMode.OFF, 0x03: HVACMode.COOL, 0x04: HVACMode.HEAT} SEQ_OF_OPERATION = { - 0x00: (HVACMode.OFF, HVACMode.COOL), # cooling only - 0x01: (HVACMode.OFF, HVACMode.COOL), # cooling with reheat - 0x02: (HVACMode.OFF, HVACMode.HEAT), # heating only - 0x03: (HVACMode.OFF, HVACMode.HEAT), # heating with reheat + 0x00: [HVACMode.OFF, HVACMode.COOL], # cooling only + 0x01: [HVACMode.OFF, HVACMode.COOL], # cooling with reheat + 0x02: [HVACMode.OFF, HVACMode.HEAT], # heating only + 0x03: [HVACMode.OFF, HVACMode.HEAT], # heating with reheat # cooling and heating 4-pipes - 0x04: (HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT), + 0x04: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], # cooling and heating 4-pipes - 0x05: (HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT), - 0x06: (HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF), # centralite specific - 0x07: (HVACMode.HEAT_COOL, HVACMode.OFF), # centralite specific + 0x05: [HVACMode.OFF, HVACMode.HEAT_COOL, HVACMode.COOL, HVACMode.HEAT], + 0x06: [HVACMode.COOL, HVACMode.HEAT, HVACMode.OFF], # centralite specific + 0x07: [HVACMode.HEAT_COOL, HVACMode.OFF], # centralite specific } HVAC_MODE_2_SYSTEM = { @@ -268,7 +268,7 @@ class Thermostat(ZhaEntity, ClimateEntity): return PRECISION_TENTHS @property - def preset_mode(self) -> str | None: + def preset_mode(self) -> str: """Return current preset mode.""" return self._preset @@ -389,7 +389,7 @@ class Thermostat(ZhaEntity, ClimateEntity): async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" - if fan_mode not in self.fan_modes: + if not self.fan_modes or fan_mode not in self.fan_modes: self.warning("Unsupported '%s' fan mode", fan_mode) return @@ -415,8 +415,8 @@ class Thermostat(ZhaEntity, ClimateEntity): async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if preset_mode not in self.preset_modes: - self.debug("preset mode '%s' is not supported", preset_mode) + if not self.preset_modes or preset_mode not in self.preset_modes: + self.debug("Preset mode '%s' is not supported", preset_mode) return if self.preset_mode not in ( @@ -505,7 +505,7 @@ class SinopeTechnologiesThermostat(Thermostat): self._manufacturer_ch = self.cluster_channels["sinope_manufacturer_specific"] @property - def _rm_rs_action(self) -> str | None: + def _rm_rs_action(self) -> HVACAction: """Return the current HVAC action based on running mode and running state.""" running_mode = self._thrm.running_mode diff --git a/mypy.ini b/mypy.ini index b01acbe311f..1cc40dd3daf 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2985,15 +2985,6 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.switch] ignore_errors = true -[mypy-homeassistant.components.zha.api] -ignore_errors = true - -[mypy-homeassistant.components.zha.button] -ignore_errors = true - -[mypy-homeassistant.components.zha.climate] -ignore_errors = true - [mypy-homeassistant.components.zha.core.channels.base] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index e5bb404b715..5a53323deb7 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -144,9 +144,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.light", "homeassistant.components.xiaomi_miio.sensor", "homeassistant.components.xiaomi_miio.switch", - "homeassistant.components.zha.api", - "homeassistant.components.zha.button", - "homeassistant.components.zha.climate", "homeassistant.components.zha.core.channels.base", "homeassistant.components.zha.core.channels.homeautomation", "homeassistant.components.zha.core.channels.hvac", From 243905ae3e10f21c9bc8cbde565532e1b7b9112f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 03:04:24 +0200 Subject: [PATCH 570/947] Fix cover, light, select, sensor, switch type hints in zha (#73770) * Fix zha sensor type hints * Fix zha entity type hints * Fix switch type hints * Fix light type hints * Fix cover type hints * Fix select type hints --- homeassistant/components/zha/cover.py | 4 ++-- homeassistant/components/zha/entity.py | 8 +++++--- homeassistant/components/zha/light.py | 6 ++++-- homeassistant/components/zha/select.py | 20 ++++++++++---------- homeassistant/components/zha/sensor.py | 5 +++-- homeassistant/components/zha/switch.py | 3 +-- mypy.ini | 18 ------------------ script/hassfest/mypy_config.py | 6 ------ 8 files changed, 25 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 2a61c5b4bc8..413e7e9ae09 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -203,8 +203,8 @@ class Shade(ZhaEntity, CoverEntity): super().__init__(unique_id, zha_device, channels, **kwargs) self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] self._level_channel = self.cluster_channels[CHANNEL_LEVEL] - self._position = None - self._is_open = None + self._position: int | None = None + self._is_open: bool | None = None @property def current_cover_position(self): diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index fb1a35ff72b..f70948eb04a 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -128,9 +128,9 @@ class BaseZhaEntity(LogMixin, entity.Entity): @callback def async_accept_signal( self, - channel: ZigbeeChannel, + channel: ZigbeeChannel | None, signal: str, - func: Callable[[], Any], + func: Callable[..., Any], signal_override=False, ): """Accept a signal from a channel.""" @@ -138,6 +138,7 @@ class BaseZhaEntity(LogMixin, entity.Entity): if signal_override: unsub = async_dispatcher_connect(self.hass, signal, func) else: + assert channel unsub = async_dispatcher_connect( self.hass, f"{channel.unique_id}_{signal}", func ) @@ -305,7 +306,7 @@ class ZhaGroupEntity(BaseZhaEntity): if self._change_listener_debouncer is None: self._change_listener_debouncer = Debouncer( self.hass, - self, + _LOGGER, cooldown=UPDATE_GROUP_FROM_CHILD_DELAY, immediate=False, function=functools.partial(self.async_update_ha_state, True), @@ -325,6 +326,7 @@ class ZhaGroupEntity(BaseZhaEntity): def async_state_changed_listener(self, event: Event): """Handle child updates.""" # Delay to ensure that we get updates from all members before updating the group + assert self._change_listener_debouncer self.hass.create_task(self._change_listener_debouncer.async_call()) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 520916d469b..7c9e8d738a4 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -340,13 +340,13 @@ class BaseLight(LogMixin, light.LightEntity): class Light(BaseLight, ZhaEntity): """Representation of a ZHA or ZLL light.""" - _attr_supported_color_modes: set(ColorMode) + _attr_supported_color_modes: set[ColorMode] _REFRESH_INTERVAL = (45, 75) def __init__(self, unique_id, zha_device: ZHADevice, channels, **kwargs): """Initialize the ZHA light.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) + self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] self._state = bool(self._on_off_channel.on_off) self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) @@ -391,6 +391,7 @@ class Light(BaseLight, ZhaEntity): if len(self._attr_supported_color_modes) == 1: self._color_mode = next(iter(self._attr_supported_color_modes)) else: # Light supports color_temp + hs, determine which mode the light is in + assert self._color_channel if self._color_channel.color_mode == Color.ColorMode.Color_temperature: self._color_mode = ColorMode.COLOR_TEMP else: @@ -440,6 +441,7 @@ class Light(BaseLight, ZhaEntity): async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" + assert self._cancel_refresh_handle self._cancel_refresh_handle() await super().async_will_remove_from_hass() diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 231120ba806..83bbcdca580 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -64,7 +64,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA select entity.""" _attr_entity_category = EntityCategory.CONFIG - _enum: Enum = None + _enum: type[Enum] def __init__( self, @@ -87,7 +87,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): return None return option.name.replace("_", " ") - async def async_select_option(self, option: str | int) -> None: + async def async_select_option(self, option: str) -> None: """Change the selected option.""" self._channel.data_cache[self._attr_name] = self._enum[option.replace(" ", "_")] self.async_write_ha_state() @@ -116,7 +116,7 @@ class ZHADefaultToneSelectEntity( ): """Representation of a ZHA default siren tone select entity.""" - _enum: Enum = IasWd.Warning.WarningMode + _enum = IasWd.Warning.WarningMode @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -125,7 +125,7 @@ class ZHADefaultSirenLevelSelectEntity( ): """Representation of a ZHA default siren level select entity.""" - _enum: Enum = IasWd.Warning.SirenLevel + _enum = IasWd.Warning.SirenLevel @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) @@ -134,14 +134,14 @@ class ZHADefaultStrobeLevelSelectEntity( ): """Representation of a ZHA default siren strobe level select entity.""" - _enum: Enum = IasWd.StrobeLevel + _enum = IasWd.StrobeLevel @CONFIG_DIAGNOSTIC_MATCH(channel_names=CHANNEL_IAS_WD) class ZHADefaultStrobeSelectEntity(ZHANonZCLSelectEntity, id_suffix=Strobe.__name__): """Representation of a ZHA default siren strobe select entity.""" - _enum: Enum = Strobe + _enum = Strobe class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): @@ -149,7 +149,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): _select_attr: str _attr_entity_category = EntityCategory.CONFIG - _enum: Enum + _enum: type[Enum] @classmethod def create_entity( @@ -198,7 +198,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity): option = self._enum(option) return option.name.replace("_", " ") - async def async_select_option(self, option: str | int) -> None: + async def async_select_option(self, option: str) -> None: """Change the selected option.""" await self._channel.cluster.write_attributes( {self._select_attr: self._enum[option.replace(" ", "_")]} @@ -213,7 +213,7 @@ class ZHAStartupOnOffSelectEntity( """Representation of a ZHA startup onoff select entity.""" _select_attr = "start_up_on_off" - _enum: Enum = OnOff.StartUpOnOff + _enum = OnOff.StartUpOnOff class AqaraMotionSensitivities(types.enum8): @@ -229,4 +229,4 @@ class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity" """Representation of a ZHA on off transition time configuration entity.""" _select_attr = "motion_sensitivity" - _enum: Enum = AqaraMotionSensitivities + _enum = AqaraMotionSensitivities diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index e579967345c..e66f1569b81 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -118,7 +118,7 @@ class Sensor(ZhaEntity, SensorEntity): SENSOR_ATTR: int | str | None = None _decimals: int = 1 _divisor: int = 1 - _multiplier: int = 1 + _multiplier: int | float = 1 _unit: str | None = None def __init__( @@ -455,7 +455,7 @@ class SmartEnergyMetering(Sensor): return self._channel.demand_formatter(value) @property - def native_unit_of_measurement(self) -> str: + def native_unit_of_measurement(self) -> str | None: """Return Unit of measurement.""" return self.unit_of_measure_map.get(self._channel.unit_of_measurement) @@ -760,6 +760,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): _attr_device_class: SensorDeviceClass = SensorDeviceClass.SIGNAL_STRENGTH _attr_entity_category = EntityCategory.DIAGNOSTIC _attr_entity_registry_enabled_default = False + unique_id_suffix: str @classmethod def create_entity( diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 1926b08fc60..fe7526586f9 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -29,7 +29,6 @@ from .entity import ZhaEntity, ZhaGroupEntity if TYPE_CHECKING: from .core.channels.base import ZigbeeChannel - from .core.channels.general import OnOffChannel from .core.device import ZHADevice STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.SWITCH) @@ -72,7 +71,7 @@ class Switch(ZhaEntity, SwitchEntity): ) -> None: """Initialize the ZHA switch.""" super().__init__(unique_id, zha_device, channels, **kwargs) - self._on_off_channel: OnOffChannel = self.cluster_channels.get(CHANNEL_ON_OFF) + self._on_off_channel = self.cluster_channels[CHANNEL_ON_OFF] @property def is_on(self) -> bool: diff --git a/mypy.ini b/mypy.ini index 1cc40dd3daf..26314c5bcad 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3020,21 +3020,3 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.store] ignore_errors = true - -[mypy-homeassistant.components.zha.cover] -ignore_errors = true - -[mypy-homeassistant.components.zha.entity] -ignore_errors = true - -[mypy-homeassistant.components.zha.light] -ignore_errors = true - -[mypy-homeassistant.components.zha.select] -ignore_errors = true - -[mypy-homeassistant.components.zha.sensor] -ignore_errors = true - -[mypy-homeassistant.components.zha.switch] -ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 5a53323deb7..6a4ff9d8cdf 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -156,12 +156,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.zha.core.helpers", "homeassistant.components.zha.core.registries", "homeassistant.components.zha.core.store", - "homeassistant.components.zha.cover", - "homeassistant.components.zha.entity", - "homeassistant.components.zha.light", - "homeassistant.components.zha.select", - "homeassistant.components.zha.sensor", - "homeassistant.components.zha.switch", ] # Component modules which should set no_implicit_reexport = true. From 1e0a3246f4892759d2e20dffac8a48554662a0fd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jun 2022 22:45:16 -0500 Subject: [PATCH 571/947] Revert "Fix auth_sign_path with query params (#73240)" (#73808) --- homeassistant/components/http/auth.py | 17 ++---- tests/components/http/test_auth.py | 78 --------------------------- 2 files changed, 3 insertions(+), 92 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index d89a36cdb86..dab6abede4c 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -7,11 +7,11 @@ from ipaddress import ip_address import logging import secrets from typing import Final +from urllib.parse import unquote from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt -from yarl import URL from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User @@ -57,24 +57,18 @@ def async_sign_path( else: refresh_token_id = hass.data[STORAGE_KEY] - url = URL(path) now = dt_util.utcnow() - params = dict(sorted(url.query.items())) encoded = jwt.encode( { "iss": refresh_token_id, - "path": url.path, - "params": params, + "path": unquote(path), "iat": now, "exp": now + expiration, }, secret, algorithm="HS256", ) - - params[SIGN_QUERY_PARAM] = encoded - url = url.with_query(params) - return f"{url.path}?{url.query_string}" + return f"{path}?{SIGN_QUERY_PARAM}={encoded}" @callback @@ -182,11 +176,6 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False - params = dict(sorted(request.query.items())) - del params[SIGN_QUERY_PARAM] - if claims["params"] != params: - return False - refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) if refresh_token is None: diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index d4995383297..4a2e1e8aed3 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -17,7 +17,6 @@ from homeassistant.components import websocket_api from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, - SIGN_QUERY_PARAM, STORAGE_KEY, async_setup_auth, async_sign_path, @@ -295,83 +294,6 @@ async def test_auth_access_signed_path_with_refresh_token( assert req.status == HTTPStatus.UNAUTHORIZED -async def test_auth_access_signed_path_with_query_param( - hass, app, aiohttp_client, hass_access_token -): - """Test access with signed url and query params.""" - app.router.add_post("/", mock_handler) - app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) - client = await aiohttp_client(app) - - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) - - signed_path = async_sign_path( - hass, "/?test=test", timedelta(seconds=5), refresh_token_id=refresh_token.id - ) - - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data["user_id"] == refresh_token.user.id - - -async def test_auth_access_signed_path_with_query_param_order( - hass, app, aiohttp_client, hass_access_token -): - """Test access with signed url and query params different order.""" - app.router.add_post("/", mock_handler) - app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) - client = await aiohttp_client(app) - - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) - - signed_path = async_sign_path( - hass, - "/?test=test&foo=bar", - timedelta(seconds=5), - refresh_token_id=refresh_token.id, - ) - url = yarl.URL(signed_path) - signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" - - req = await client.get(signed_path) - assert req.status == HTTPStatus.OK - data = await req.json() - assert data["user_id"] == refresh_token.user.id - - -@pytest.mark.parametrize( - "base_url,test_url", - [ - ("/?test=test", "/?test=test&foo=bar"), - ("/", "/?test=test"), - ("/?test=test&foo=bar", "/?test=test&foo=baz"), - ("/?test=test&foo=bar", "/?test=test"), - ], -) -async def test_auth_access_signed_path_with_query_param_tamper( - hass, app, aiohttp_client, hass_access_token, base_url: str, test_url: str -): - """Test access with signed url and query params that have been tampered with.""" - app.router.add_post("/", mock_handler) - app.router.add_get("/another_path", mock_handler) - await async_setup_auth(hass, app) - client = await aiohttp_client(app) - - refresh_token = await hass.auth.async_validate_access_token(hass_access_token) - - signed_path = async_sign_path( - hass, base_url, timedelta(seconds=5), refresh_token_id=refresh_token.id - ) - url = yarl.URL(signed_path) - token = url.query.get(SIGN_QUERY_PARAM) - - req = await client.get(f"{test_url}&{SIGN_QUERY_PARAM}={token}") - assert req.status == HTTPStatus.UNAUTHORIZED - - async def test_auth_access_signed_path_via_websocket( hass, app, hass_ws_client, hass_read_only_access_token ): From 07a46dee3946e325407d4ae66212f6899733ab95 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 22 Jun 2022 02:08:31 -0500 Subject: [PATCH 572/947] Additional surround controls for Sonos (#73805) --- homeassistant/components/sonos/number.py | 2 ++ homeassistant/components/sonos/speaker.py | 13 ++++++++++++- homeassistant/components/sonos/switch.py | 4 ++++ tests/components/sonos/conftest.py | 3 +++ tests/components/sonos/test_number.py | 10 ++++++++++ tests/components/sonos/test_switch.py | 8 ++++++++ 6 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/number.py b/homeassistant/components/sonos/number.py index 202df1cb6f1..3b034423471 100644 --- a/homeassistant/components/sonos/number.py +++ b/homeassistant/components/sonos/number.py @@ -20,6 +20,8 @@ LEVEL_TYPES = { "bass": (-10, 10), "treble": (-10, 10), "sub_gain": (-15, 15), + "surround_level": (-15, 15), + "music_surround_level": (-15, 15), } _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f9decf1c27e..93d0afbcf9c 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -146,6 +146,9 @@ class SonosSpeaker: self.sub_enabled: bool | None = None self.sub_gain: int | None = None self.surround_enabled: bool | None = None + self.surround_mode: bool | None = None + self.surround_level: int | None = None + self.music_surround_level: int | None = None # Misc features self.buttons_enabled: bool | None = None @@ -515,11 +518,19 @@ class SonosSpeaker: "night_mode", "sub_enabled", "surround_enabled", + "surround_mode", ): if bool_var in variables: setattr(self, bool_var, variables[bool_var] == "1") - for int_var in ("audio_delay", "bass", "treble", "sub_gain"): + for int_var in ( + "audio_delay", + "bass", + "treble", + "sub_gain", + "surround_level", + "music_surround_level", + ): if int_var in variables: setattr(self, int_var, variables[int_var]) diff --git a/homeassistant/components/sonos/switch.py b/homeassistant/components/sonos/switch.py index 29abb097df7..53911d85d3e 100644 --- a/homeassistant/components/sonos/switch.py +++ b/homeassistant/components/sonos/switch.py @@ -39,6 +39,7 @@ ATTR_INCLUDE_LINKED_ZONES = "include_linked_zones" ATTR_CROSSFADE = "cross_fade" ATTR_LOUDNESS = "loudness" +ATTR_MUSIC_PLAYBACK_FULL_VOLUME = "surround_mode" ATTR_NIGHT_SOUND = "night_mode" ATTR_SPEECH_ENHANCEMENT = "dialog_level" ATTR_STATUS_LIGHT = "status_light" @@ -50,6 +51,7 @@ ALL_FEATURES = ( ATTR_TOUCH_CONTROLS, ATTR_CROSSFADE, ATTR_LOUDNESS, + ATTR_MUSIC_PLAYBACK_FULL_VOLUME, ATTR_NIGHT_SOUND, ATTR_SPEECH_ENHANCEMENT, ATTR_SUB_ENABLED, @@ -67,6 +69,7 @@ POLL_REQUIRED = ( FRIENDLY_NAMES = { ATTR_CROSSFADE: "Crossfade", ATTR_LOUDNESS: "Loudness", + ATTR_MUSIC_PLAYBACK_FULL_VOLUME: "Surround Music Full Volume", ATTR_NIGHT_SOUND: "Night Sound", ATTR_SPEECH_ENHANCEMENT: "Speech Enhancement", ATTR_STATUS_LIGHT: "Status Light", @@ -77,6 +80,7 @@ FRIENDLY_NAMES = { FEATURE_ICONS = { ATTR_LOUDNESS: "mdi:bullhorn-variant", + ATTR_MUSIC_PLAYBACK_FULL_VOLUME: "mdi:music-note-plus", ATTR_NIGHT_SOUND: "mdi:chat-sleep", ATTR_SPEECH_ENHANCEMENT: "mdi:ear-hearing", ATTR_CROSSFADE: "mdi:swap-horizontal", diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index d493c9d50c9..f776fb62d58 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -117,6 +117,9 @@ def soco_fixture( mock_soco.sub_enabled = False mock_soco.sub_gain = 5 mock_soco.surround_enabled = True + mock_soco.surround_mode = True + mock_soco.surround_level = 3 + mock_soco.music_surround_level = 4 mock_soco.soundbar_audio_input_format = "Dolby 5.1" mock_soco.get_battery_info.return_value = battery_info mock_soco.all_zones = {mock_soco} diff --git a/tests/components/sonos/test_number.py b/tests/components/sonos/test_number.py index 5829a7a6724..83dcdf78ff8 100644 --- a/tests/components/sonos/test_number.py +++ b/tests/components/sonos/test_number.py @@ -22,6 +22,16 @@ async def test_number_entities(hass, async_autosetup_sonos, soco): audio_delay_state = hass.states.get(audio_delay_number.entity_id) assert audio_delay_state.state == "2" + surround_level_number = entity_registry.entities["number.zone_a_surround_level"] + surround_level_state = hass.states.get(surround_level_number.entity_id) + assert surround_level_state.state == "3" + + music_surround_level_number = entity_registry.entities[ + "number.zone_a_music_surround_level" + ] + music_surround_level_state = hass.states.get(music_surround_level_number.entity_id) + assert music_surround_level_state.state == "4" + with patch("soco.SoCo.audio_delay") as mock_audio_delay: await hass.services.async_call( NUMBER_DOMAIN, diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index f224a1e187e..2b794657565 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -52,6 +52,14 @@ async def test_switch_attributes(hass, async_autosetup_sonos, soco): assert alarm_state.attributes.get(ATTR_PLAY_MODE) == "SHUFFLE_NOREPEAT" assert not alarm_state.attributes.get(ATTR_INCLUDE_LINKED_ZONES) + surround_music_full_volume = entity_registry.entities[ + "switch.zone_a_surround_music_full_volume" + ] + surround_music_full_volume_state = hass.states.get( + surround_music_full_volume.entity_id + ) + assert surround_music_full_volume_state.state == STATE_ON + night_sound = entity_registry.entities["switch.zone_a_night_sound"] night_sound_state = hass.states.get(night_sound.entity_id) assert night_sound_state.state == STATE_ON From 39a00ffe09dbaed86943ca540f62aaf29917abb2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 09:49:54 +0200 Subject: [PATCH 573/947] Automatically onboard Cast (#73813) --- homeassistant/components/cast/config_flow.py | 4 ++-- tests/components/cast/test_config_flow.py | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index fc657fd2422..1c983d6f67a 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.components import zeroconf +from homeassistant.components import onboarding, zeroconf from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv @@ -102,7 +102,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data = self._get_data() - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self.async_create_entry(title="Google Cast", data=data) return self.async_show_form(step_id="confirm") diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index 1ad89c7a8e5..d7aa0fdeda9 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -116,6 +116,26 @@ async def test_zeroconf_setup(hass): } +async def test_zeroconf_setup_onboarding(hass): + """Test we automatically finish a config flow through zeroconf during onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + "cast", context={"source": config_entries.SOURCE_ZEROCONF} + ) + + users = await hass.auth.async_get_users() + assert len(users) == 1 + assert result["type"] == "create_entry" + assert result["result"].data == { + "ignore_cec": [], + "known_hosts": [], + "uuid": [], + "user_id": users[0].id, # Home Assistant cast user + } + + def get_suggested(schema, key): """Get suggested value for key in voluptuous schema.""" for k in schema.keys(): From 998e63df61c184d6fc7336e134e372531ec9a826 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 09:54:35 +0200 Subject: [PATCH 574/947] Fix Plugwise migration error (#73812) --- homeassistant/components/plugwise/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/gateway.py b/homeassistant/components/plugwise/gateway.py index 7eb2b1371d5..afa7451021e 100644 --- a/homeassistant/components/plugwise/gateway.py +++ b/homeassistant/components/plugwise/gateway.py @@ -113,7 +113,7 @@ def migrate_sensor_entities( # Migrating opentherm_outdoor_temperature to opentherm_outdoor_air_temperature sensor for device_id, device in coordinator.data.devices.items(): - if device["dev_class"] != "heater_central": + if device.get("dev_class") != "heater_central": continue old_unique_id = f"{device_id}-outdoor_temperature" From 504f4a7acf6d3f5f7ba887b72671883a35a1feb8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 09:55:36 +0200 Subject: [PATCH 575/947] Update Fibaro config entry on duplicate entry (#73814) --- homeassistant/components/fibaro/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fibaro/config_flow.py b/homeassistant/components/fibaro/config_flow.py index f528fd8a184..b0ea05e49e1 100644 --- a/homeassistant/components/fibaro/config_flow.py +++ b/homeassistant/components/fibaro/config_flow.py @@ -69,7 +69,7 @@ class FibaroConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "invalid_auth" else: await self.async_set_unique_id(info["serial_number"]) - self._abort_if_unique_id_configured() + self._abort_if_unique_id_configured(updates=user_input) return self.async_create_entry(title=info["name"], data=user_input) return self.async_show_form( From 4bfdc6104566cb12237b19d172e52d3a3644db11 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jun 2022 03:02:02 -0500 Subject: [PATCH 576/947] Fix rachio webhook not being unregistered on unload (#73795) --- homeassistant/components/rachio/__init__.py | 17 ++++--- .../components/rachio/binary_sensor.py | 11 +++-- homeassistant/components/rachio/const.py | 10 +++++ homeassistant/components/rachio/device.py | 45 +++++++++++-------- homeassistant/components/rachio/entity.py | 16 +++---- homeassistant/components/rachio/switch.py | 10 +++-- homeassistant/components/rachio/webhooks.py | 35 +++++++++++---- 7 files changed, 89 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py index e75d7117d73..e0ac98b7546 100644 --- a/homeassistant/components/rachio/__init__.py +++ b/homeassistant/components/rachio/__init__.py @@ -17,6 +17,7 @@ from .device import RachioPerson from .webhooks import ( async_get_or_create_registered_webhook_id_and_url, async_register_webhook, + async_unregister_webhook, ) _LOGGER = logging.getLogger(__name__) @@ -28,8 +29,8 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) 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: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + async_unregister_webhook(hass, entry) hass.data[DOMAIN].pop(entry.entry_id) return unload_ok @@ -59,10 +60,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Get the URL of this server rachio.webhook_auth = secrets.token_hex() try: - ( - webhook_id, - webhook_url, - ) = await async_get_or_create_registered_webhook_id_and_url(hass, entry) + webhook_url = await async_get_or_create_registered_webhook_id_and_url( + hass, entry + ) except cloud.CloudNotConnected as exc: # User has an active cloud subscription, but the connection to the cloud is down raise ConfigEntryNotReady from exc @@ -92,9 +92,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Enable platform - hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN][entry.entry_id] = person - async_register_webhook(hass, webhook_id, entry.entry_id) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = person + async_register_webhook(hass, entry) hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 1bfc3cc03ee..2fe99fb442e 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -21,6 +22,7 @@ from .const import ( SIGNAL_RACHIO_RAIN_SENSOR_UPDATE, STATUS_ONLINE, ) +from .device import RachioPerson from .entity import RachioDevice from .webhooks import ( SUBTYPE_COLD_REBOOT, @@ -41,12 +43,13 @@ async def async_setup_entry( """Set up the Rachio binary sensors.""" entities = await hass.async_add_executor_job(_create_entities, hass, config_entry) async_add_entities(entities) - _LOGGER.info("%d Rachio binary sensor(s) added", len(entities)) + _LOGGER.debug("%d Rachio binary sensor(s) added", len(entities)) -def _create_entities(hass, config_entry): - entities = [] - for controller in hass.data[DOMAIN_RACHIO][config_entry.entry_id].controllers: +def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: + entities: list[Entity] = [] + person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] + for controller in person.controllers: entities.append(RachioControllerOnlineBinarySensor(controller)) entities.append(RachioRainSensor(controller)) return entities diff --git a/homeassistant/components/rachio/const.py b/homeassistant/components/rachio/const.py index 9dbf14e3907..92a57505a7c 100644 --- a/homeassistant/components/rachio/const.py +++ b/homeassistant/components/rachio/const.py @@ -67,3 +67,13 @@ SIGNAL_RACHIO_SCHEDULE_UPDATE = f"{SIGNAL_RACHIO_UPDATE}_schedule" CONF_WEBHOOK_ID = "webhook_id" CONF_CLOUDHOOK_URL = "cloudhook_url" + +# Webhook callbacks +LISTEN_EVENT_TYPES = [ + "DEVICE_STATUS_EVENT", + "ZONE_STATUS_EVENT", + "RAIN_DELAY_EVENT", + "RAIN_SENSOR_DETECTION_EVENT", + "SCHEDULE_STATUS_EVENT", +] +WEBHOOK_CONST_ID = "homeassistant.rachio:" diff --git a/homeassistant/components/rachio/device.py b/homeassistant/components/rachio/device.py index 911049883d9..5053fa01495 100644 --- a/homeassistant/components/rachio/device.py +++ b/homeassistant/components/rachio/device.py @@ -3,11 +3,14 @@ from __future__ import annotations from http import HTTPStatus import logging +from typing import Any +from rachiopy import Rachio import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.core import ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -26,12 +29,13 @@ from .const import ( KEY_STATUS, KEY_USERNAME, KEY_ZONES, + LISTEN_EVENT_TYPES, MODEL_GENERATION_1, SERVICE_PAUSE_WATERING, SERVICE_RESUME_WATERING, SERVICE_STOP_WATERING, + WEBHOOK_CONST_ID, ) -from .webhooks import LISTEN_EVENT_TYPES, WEBHOOK_CONST_ID _LOGGER = logging.getLogger(__name__) @@ -54,16 +58,16 @@ STOP_SERVICE_SCHEMA = vol.Schema({vol.Optional(ATTR_DEVICES): cv.string}) class RachioPerson: """Represent a Rachio user.""" - def __init__(self, rachio, config_entry): + def __init__(self, rachio: Rachio, config_entry: ConfigEntry) -> None: """Create an object from the provided API instance.""" # Use API token to get user ID self.rachio = rachio self.config_entry = config_entry self.username = None - self._id = None - self._controllers = [] + self._id: str | None = None + self._controllers: list[RachioIro] = [] - async def async_setup(self, hass): + async def async_setup(self, hass: HomeAssistant) -> None: """Create rachio devices and services.""" await hass.async_add_executor_job(self._setup, hass) can_pause = False @@ -121,7 +125,7 @@ class RachioPerson: schema=RESUME_SERVICE_SCHEMA, ) - def _setup(self, hass): + def _setup(self, hass: HomeAssistant) -> None: """Rachio device setup.""" rachio = self.rachio @@ -139,7 +143,7 @@ class RachioPerson: if int(data[0][KEY_STATUS]) != HTTPStatus.OK: raise ConfigEntryNotReady(f"API Error: {data}") self.username = data[1][KEY_USERNAME] - devices = data[1][KEY_DEVICES] + devices: list[dict[str, Any]] = data[1][KEY_DEVICES] for controller in devices: webhooks = rachio.notification.get_device_webhook(controller[KEY_ID])[1] # The API does not provide a way to tell if a controller is shared @@ -169,12 +173,12 @@ class RachioPerson: _LOGGER.info('Using Rachio API as user "%s"', self.username) @property - def user_id(self) -> str: + def user_id(self) -> str | None: """Get the user ID as defined by the Rachio API.""" return self._id @property - def controllers(self) -> list: + def controllers(self) -> list[RachioIro]: """Get a list of controllers managed by this account.""" return self._controllers @@ -186,7 +190,13 @@ class RachioPerson: class RachioIro: """Represent a Rachio Iro.""" - def __init__(self, hass, rachio, data, webhooks): + def __init__( + self, + hass: HomeAssistant, + rachio: Rachio, + data: dict[str, Any], + webhooks: list[dict[str, Any]], + ) -> None: """Initialize a Rachio device.""" self.hass = hass self.rachio = rachio @@ -199,10 +209,10 @@ class RachioIro: self._schedules = data[KEY_SCHEDULES] self._flex_schedules = data[KEY_FLEX_SCHEDULES] self._init_data = data - self._webhooks = webhooks + self._webhooks: list[dict[str, Any]] = webhooks _LOGGER.debug('%s has ID "%s"', self, self.controller_id) - def setup(self): + def setup(self) -> None: """Rachio Iro setup for webhooks.""" # Listen for all updates self._init_webhooks() @@ -226,7 +236,7 @@ class RachioIro: or webhook[KEY_ID] == current_webhook_id ): self.rachio.notification.delete(webhook[KEY_ID]) - self._webhooks = None + self._webhooks = [] _deinit_webhooks(None) @@ -306,9 +316,6 @@ class RachioIro: _LOGGER.debug("Resuming watering on %s", self) -def is_invalid_auth_code(http_status_code): +def is_invalid_auth_code(http_status_code: int) -> bool: """HTTP status codes that mean invalid auth.""" - if http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN): - return True - - return False + return http_status_code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN) diff --git a/homeassistant/components/rachio/entity.py b/homeassistant/components/rachio/entity.py index a95b6ffb557..1bb971e3e01 100644 --- a/homeassistant/components/rachio/entity.py +++ b/homeassistant/components/rachio/entity.py @@ -4,25 +4,19 @@ from homeassistant.helpers import device_registry from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DEFAULT_NAME, DOMAIN +from .device import RachioIro class RachioDevice(Entity): """Base class for rachio devices.""" - def __init__(self, controller): + _attr_should_poll = False + + def __init__(self, controller: RachioIro) -> None: """Initialize a Rachio device.""" super().__init__() self._controller = controller - - @property - def should_poll(self) -> bool: - """Declare that this entity pushes its state to HA.""" - return False - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ ( DOMAIN, diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 55427741bf0..227e8beaec3 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -13,6 +13,7 @@ from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import as_timestamp, now, parse_datetime, utc_from_timestamp @@ -52,6 +53,7 @@ from .const import ( SLOPE_SLIGHT, SLOPE_STEEP, ) +from .device import RachioPerson from .entity import RachioDevice from .webhooks import ( SUBTYPE_RAIN_DELAY_OFF, @@ -106,7 +108,7 @@ async def async_setup_entry( has_flex_sched = True async_add_entities(entities) - _LOGGER.info("%d Rachio switch(es) added", len(entities)) + _LOGGER.debug("%d Rachio switch(es) added", len(entities)) def start_multiple(service: ServiceCall) -> None: """Service to start multiple zones in sequence.""" @@ -154,9 +156,9 @@ async def async_setup_entry( ) -def _create_entities(hass, config_entry): - entities = [] - person = hass.data[DOMAIN_RACHIO][config_entry.entry_id] +def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Entity]: + entities: list[Entity] = [] + person: RachioPerson = hass.data[DOMAIN_RACHIO][config_entry.entry_id] # Fetch the schedule once at startup # in order to avoid every zone doing it for controller in person.controllers: diff --git a/homeassistant/components/rachio/webhooks.py b/homeassistant/components/rachio/webhooks.py index 6ad396b76a1..5c2fbe5965f 100644 --- a/homeassistant/components/rachio/webhooks.py +++ b/homeassistant/components/rachio/webhooks.py @@ -1,9 +1,12 @@ """Webhooks used by rachio.""" +from __future__ import annotations + from aiohttp import web from homeassistant.components import cloud, webhook +from homeassistant.config_entries import ConfigEntry from homeassistant.const import URL_API -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from .const import ( @@ -18,6 +21,7 @@ from .const import ( SIGNAL_RACHIO_SCHEDULE_UPDATE, SIGNAL_RACHIO_ZONE_UPDATE, ) +from .device import RachioPerson # Device webhook values TYPE_CONTROLLER_STATUS = "DEVICE_STATUS" @@ -79,16 +83,22 @@ SIGNAL_MAP = { @callback -def async_register_webhook(hass, webhook_id, entry_id): +def async_register_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: """Register a webhook.""" + webhook_id: str = entry.data[CONF_WEBHOOK_ID] - async def _async_handle_rachio_webhook(hass, webhook_id, request): + async def _async_handle_rachio_webhook( + hass: HomeAssistant, webhook_id: str, request: web.Request + ) -> web.Response: """Handle webhook calls from the server.""" + person: RachioPerson = hass.data[DOMAIN][entry.entry_id] data = await request.json() try: - auth = data.get(KEY_EXTERNAL_ID, "").split(":")[1] - assert auth == hass.data[DOMAIN][entry_id].rachio.webhook_auth + assert ( + data.get(KEY_EXTERNAL_ID, "").split(":")[1] + == person.rachio.webhook_auth + ) except (AssertionError, IndexError): return web.Response(status=web.HTTPForbidden.status_code) @@ -103,8 +113,17 @@ def async_register_webhook(hass, webhook_id, entry_id): ) -async def async_get_or_create_registered_webhook_id_and_url(hass, entry): - """Generate webhook ID.""" +@callback +def async_unregister_webhook(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Unregister a webhook.""" + webhook_id: str = entry.data[CONF_WEBHOOK_ID] + webhook.async_unregister(hass, webhook_id) + + +async def async_get_or_create_registered_webhook_id_and_url( + hass: HomeAssistant, entry: ConfigEntry +) -> str: + """Generate webhook url.""" config = entry.data.copy() updated_config = False @@ -128,4 +147,4 @@ async def async_get_or_create_registered_webhook_id_and_url(hass, entry): if updated_config: hass.config_entries.async_update_entry(entry, data=config) - return webhook_id, webhook_url + return webhook_url From 08b69319ca85e42073e9fb9576db1d3ec896dc13 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Wed, 22 Jun 2022 04:04:11 -0400 Subject: [PATCH 577/947] Insteon bug fixes (#73791) --- homeassistant/components/insteon/api/properties.py | 9 ++++----- homeassistant/components/insteon/manifest.json | 4 ++-- homeassistant/components/insteon/utils.py | 5 ++--- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/insteon/test_api_properties.py | 4 ++-- 6 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 47def71c1ab..8e697a88459 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -99,8 +99,6 @@ def get_properties(device: Device, show_advanced=False): continue prop_schema = get_schema(prop, name, device.groups) - if name == "momentary_delay": - print(prop_schema) if prop_schema is None: continue schema[name] = prop_schema @@ -216,7 +214,7 @@ async def websocket_write_properties( result = await device.async_write_config() await devices.async_save(workdir=hass.config.config_dir) - if result != ResponseStatus.SUCCESS: + if result not in [ResponseStatus.SUCCESS, ResponseStatus.RUN_ON_WAKE]: connection.send_message( websocket_api.error_message( msg[ID], "write_failed", "properties not written to device" @@ -244,9 +242,10 @@ async def websocket_load_properties( notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND) return - result, _ = await device.async_read_config(read_aldb=False) + result = await device.async_read_config(read_aldb=False) await devices.async_save(workdir=hass.config.config_dir) - if result != ResponseStatus.SUCCESS: + + if result not in [ResponseStatus.SUCCESS, ResponseStatus.RUN_ON_WAKE]: connection.send_message( websocket_api.error_message( msg[ID], "load_failed", "properties not loaded from device" diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index c69f4f2cdf5..1be077a6b38 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -4,8 +4,8 @@ "documentation": "https://www.home-assistant.io/integrations/insteon", "dependencies": ["http", "websocket_api"], "requirements": [ - "pyinsteon==1.1.0", - "insteon-frontend-home-assistant==0.1.0" + "pyinsteon==1.1.1", + "insteon-frontend-home-assistant==0.1.1" ], "codeowners": ["@teharris1"], "dhcp": [ diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index e8e34ee8e62..09375e7827a 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -196,9 +196,8 @@ def async_register_services(hass): for address in devices: device = devices[address] if device != devices.modem and device.cat != 0x03: - await device.aldb.async_load( - refresh=reload, callback=async_srv_save_devices - ) + await device.aldb.async_load(refresh=reload) + await async_srv_save_devices() async def async_srv_save_devices(): """Write the Insteon device configuration to file.""" diff --git a/requirements_all.txt b/requirements_all.txt index 859de73843f..9d3d59f3b05 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -888,7 +888,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.1.0 +insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire intellifire4py==1.0.2 @@ -1556,7 +1556,7 @@ pyialarm==1.9.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.1.0 +pyinsteon==1.1.1 # homeassistant.components.intesishome pyintesishome==1.7.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e576babe4b5..0f9877ff298 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -631,7 +631,7 @@ influxdb-client==1.24.0 influxdb==5.3.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.1.0 +insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire intellifire4py==1.0.2 @@ -1047,7 +1047,7 @@ pyialarm==1.9.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.1.0 +pyinsteon==1.1.1 # homeassistant.components.ipma pyipma==2.0.5 diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index 7211402e343..9088b23f42a 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -401,7 +401,7 @@ async def test_load_properties(hass, hass_ws_client, kpl_properties_data): ) device = devices["33.33.33"] - device.async_read_config = AsyncMock(return_value=(1, 1)) + device.async_read_config = AsyncMock(return_value=1) with patch.object(insteon.api.properties, "devices", devices): await ws_client.send_json( {ID: 2, TYPE: "insteon/properties/load", DEVICE_ADDRESS: "33.33.33"} @@ -418,7 +418,7 @@ async def test_load_properties_failure(hass, hass_ws_client, kpl_properties_data ) device = devices["33.33.33"] - device.async_read_config = AsyncMock(return_value=(0, 0)) + device.async_read_config = AsyncMock(return_value=0) with patch.object(insteon.api.properties, "devices", devices): await ws_client.send_json( {ID: 2, TYPE: "insteon/properties/load", DEVICE_ADDRESS: "33.33.33"} From 95eb55dd667b456c3615ba19099f392fdaac1e8f Mon Sep 17 00:00:00 2001 From: Jonny Bergdahl Date: Wed, 22 Jun 2022 10:22:09 +0200 Subject: [PATCH 578/947] Fix thumbnail issues in Twitch integration (#72564) Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/twitch/sensor.py | 9 ++++++--- tests/components/twitch/test_twitch.py | 1 + 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 95006a4cab7..dcc2dc9bbf6 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -108,10 +108,8 @@ class TwitchSensor(SensorEntity): def update(self) -> None: """Update device state.""" - followers = self._client.get_users_follows(to_id=self.unique_id)["total"] channel = self._client.get_users(user_ids=[self.unique_id])["data"][0] - self._attr_extra_state_attributes = { ATTR_FOLLOWING: followers, ATTR_VIEWS: channel["view_count"], @@ -151,8 +149,13 @@ class TwitchSensor(SensorEntity): self._attr_extra_state_attributes[ATTR_GAME] = stream["game_name"] self._attr_extra_state_attributes[ATTR_TITLE] = stream["title"] self._attr_entity_picture = stream["thumbnail_url"] + if self._attr_entity_picture is not None: + self._attr_entity_picture = self._attr_entity_picture.format( + height=24, + width=24, + ) else: self._attr_native_value = STATE_OFFLINE self._attr_extra_state_attributes[ATTR_GAME] = None self._attr_extra_state_attributes[ATTR_TITLE] = None - self._attr_entity_picture = channel["offline_image_url"] + self._attr_entity_picture = channel["profile_image_url"] diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py index bfffeb4ae7f..0e086fe6f7a 100644 --- a/tests/components/twitch/test_twitch.py +++ b/tests/components/twitch/test_twitch.py @@ -28,6 +28,7 @@ USER_OBJECT = { "id": 123, "display_name": "channel123", "offline_image_url": "logo.png", + "profile_image_url": "logo.png", "view_count": 42, } STREAM_OBJECT_ONLINE = { From 90ad6ca540fd48c409da1558bdf30118e5efbb01 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 22 Jun 2022 04:46:38 -0400 Subject: [PATCH 579/947] Bumps version of pyunifiprotect to 4.0.5 (#73798) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index dfa748835f5..00761790474 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==4.0.4", "unifi-discovery==1.1.4"], + "requirements": ["pyunifiprotect==4.0.5", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 9d3d59f3b05..8eada81b4e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.4 +pyunifiprotect==4.0.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f9877ff298..12a89392d9e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.4 +pyunifiprotect==4.0.5 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 31af4b709ebea363b41808ac3dad5fb9034dd282 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 12:48:23 +0200 Subject: [PATCH 580/947] Add FanEntity type hint checks to pylint plugin (#73801) * Add FanEntity type hint checks to pylint plugin * Add test * Add test * Review comments * Adjust tests * Rename variable * also test keyword_only args * Use docstrings * Fix tests * Better return type --- pylint/plugins/hass_enforce_type_hints.py | 137 +++++++++++++++++++++- tests/pylint/test_enforce_type_hints.py | 70 +++++++++++ 2 files changed, 205 insertions(+), 2 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a1bf260e968..307510c6621 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -21,10 +21,12 @@ class TypeHintMatch: function_name: str return_type: list[str] | str | None | object - # arg_types is for positional arguments arg_types: dict[int, str] | None = None - # kwarg_types is for the special case `**kwargs` + """arg_types is for positional arguments""" + named_arg_types: dict[str, str] | None = None + """named_arg_types is for named or keyword arguments""" kwargs_type: str | None = None + """kwargs_type is for the special case `**kwargs`""" check_return_type_inheritance: bool = False @@ -448,6 +450,111 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { # Overriding properties and functions are normally checked by mypy, and will only # be checked by pylint when --ignore-missing-annotations is False _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { + "fan": [ + ClassTypeHintMatch( + base_class="FanEntity", + matches=[ + TypeHintMatch( + function_name="is_on", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="percentage", + return_type=["int", None], + ), + TypeHintMatch( + function_name="speed_count", + return_type="int", + ), + TypeHintMatch( + function_name="percentage_step", + return_type="float", + ), + TypeHintMatch( + function_name="current_direction", + return_type=["str", None], + ), + TypeHintMatch( + function_name="oscillating", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="capability_attributes", + return_type="dict[str]", + ), + TypeHintMatch( + function_name="supported_features", + return_type="int", + ), + TypeHintMatch( + function_name="preset_mode", + return_type=["str", None], + ), + TypeHintMatch( + function_name="preset_modes", + return_type=["list[str]", None], + ), + TypeHintMatch( + function_name="set_percentage", + arg_types={1: "int"}, + return_type=None, + ), + TypeHintMatch( + function_name="async_set_percentage", + arg_types={1: "int"}, + return_type=None, + ), + TypeHintMatch( + function_name="set_preset_mode", + arg_types={1: "str"}, + return_type=None, + ), + TypeHintMatch( + function_name="async_set_preset_mode", + arg_types={1: "str"}, + return_type=None, + ), + TypeHintMatch( + function_name="set_direction", + arg_types={1: "str"}, + return_type=None, + ), + TypeHintMatch( + function_name="async_set_direction", + arg_types={1: "str"}, + return_type=None, + ), + TypeHintMatch( + function_name="turn_on", + named_arg_types={ + "percentage": "int | None", + "preset_mode": "str | None", + }, + kwargs_type="Any", + return_type=None, + ), + TypeHintMatch( + function_name="async_turn_on", + named_arg_types={ + "percentage": "int | None", + "preset_mode": "str | None", + }, + kwargs_type="Any", + return_type=None, + ), + TypeHintMatch( + function_name="oscillate", + arg_types={1: "bool"}, + return_type=None, + ), + TypeHintMatch( + function_name="async_oscillate", + arg_types={1: "bool"}, + return_type=None, + ), + ], + ), + ], "lock": [ ClassTypeHintMatch( base_class="LockEntity", @@ -619,6 +726,21 @@ def _get_all_annotations(node: nodes.FunctionDef) -> list[nodes.NodeNG | None]: return annotations +def _get_named_annotation( + node: nodes.FunctionDef, key: str +) -> tuple[nodes.NodeNG, nodes.NodeNG] | tuple[None, None]: + args = node.args + for index, arg_node in enumerate(args.args): + if key == arg_node.name: + return arg_node, args.annotations[index] + + for index, arg_node in enumerate(args.kwonlyargs): + if key == arg_node.name: + return arg_node, args.kwonlyargs_annotations[index] + + return None, None + + def _has_valid_annotations( annotations: list[nodes.NodeNG | None], ) -> bool: @@ -742,6 +864,17 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] args=(key + 1, expected_type), ) + # Check that all keyword arguments are correctly annotated. + if match.named_arg_types is not None: + for arg_name, expected_type in match.named_arg_types.items(): + arg_node, annotation = _get_named_annotation(node, arg_name) + if arg_node and not _is_valid_type(expected_type, annotation): + self.add_message( + "hass-argument-type", + node=arg_node, + args=(arg_name, expected_type), + ) + # Check that kwargs is correctly annotated. if match.kwargs_type and not _is_valid_type( match.kwargs_type, node.args.kwargannotation diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 262ff93afa8..54c7cf6ec4c 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -565,3 +565,73 @@ def test_ignore_invalid_entity_properties( with assert_no_messages(linter): type_hint_checker.visit_classdef(class_node) + + +def test_named_arguments( + linter: UnittestLinter, type_hint_checker: BaseChecker +) -> None: + """Check missing entity properties when ignore_missing_annotations is False.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node, func_node, percentage_node, preset_mode_node = astroid.extract_node( + """ + class FanEntity(): + pass + + class MyFan( #@ + FanEntity + ): + async def async_turn_on( #@ + self, + percentage, #@ + *, + preset_mode: str, #@ + **kwargs + ) -> bool: + pass + """, + "homeassistant.components.pylint_test.fan", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=percentage_node, + args=("percentage", "int | None"), + line=10, + col_offset=8, + end_line=10, + end_col_offset=18, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=preset_mode_node, + args=("preset_mode", "str | None"), + line=12, + col_offset=8, + end_line=12, + end_col_offset=24, + ), + pylint.testutils.MessageTest( + msg_id="hass-argument-type", + node=func_node, + args=("kwargs", "Any"), + line=8, + col_offset=4, + end_line=8, + end_col_offset=27, + ), + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=func_node, + args="None", + line=8, + col_offset=4, + end_line=8, + end_col_offset=27, + ), + ): + type_hint_checker.visit_classdef(class_node) From 03246d2649a51598bebb330394ca7e56eb2c45d0 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Wed, 22 Jun 2022 21:38:44 +1000 Subject: [PATCH 581/947] Use ha-av instead of av and bump to v10.0.0b3 (#73789) * Use ha-av instead of av and bump to v10.0.0b1 * Change generic * Use v10.0.0b2 * Use v10.0.0b3 --- homeassistant/components/generic/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- requirements_all.txt | 8 ++++---- requirements_test_all.txt | 8 ++++---- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index c590ddfffcd..3e8e7717a10 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -2,7 +2,7 @@ "domain": "generic", "name": "Generic Camera", "config_flow": true, - "requirements": ["av==9.2.0", "pillow==9.1.1"], + "requirements": ["ha-av==10.0.0b3", "pillow==9.1.1"], "documentation": "https://www.home-assistant.io/integrations/generic", "codeowners": ["@davet2001"], "iot_class": "local_push" diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index f6e00f7c599..eb525700bb0 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "documentation": "https://www.home-assistant.io/integrations/stream", - "requirements": ["PyTurboJPEG==1.6.6", "av==9.2.0"], + "requirements": ["PyTurboJPEG==1.6.6", "ha-av==10.0.0b3"], "dependencies": ["http"], "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], "quality_scale": "internal", diff --git a/requirements_all.txt b/requirements_all.txt index 8eada81b4e9..9a4a1619630 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -356,10 +356,6 @@ auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.7 -# homeassistant.components.generic -# homeassistant.components.stream -av==9.2.0 - # homeassistant.components.avea # avea==1.5.1 @@ -779,6 +775,10 @@ gstreamer-player==1.1.2 # homeassistant.components.profiler guppy3==3.1.2 +# homeassistant.components.generic +# homeassistant.components.stream +ha-av==10.0.0b3 + # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12a89392d9e..5c59af04700 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -292,10 +292,6 @@ auroranoaa==0.0.2 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.7 -# homeassistant.components.generic -# homeassistant.components.stream -av==9.2.0 - # homeassistant.components.axis axis==44 @@ -558,6 +554,10 @@ growattServer==1.2.2 # homeassistant.components.profiler guppy3==3.1.2 +# homeassistant.components.generic +# homeassistant.components.stream +ha-av==10.0.0b3 + # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 From fb2a3ae13522560fcb7ea68ebf3746bbda571284 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 13:39:19 +0200 Subject: [PATCH 582/947] Update sentry-sdk to 1.6.0 (#73819) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index b76318f046d..3f01b7bc5f0 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -3,7 +3,7 @@ "name": "Sentry", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sentry", - "requirements": ["sentry-sdk==1.5.12"], + "requirements": ["sentry-sdk==1.6.0"], "codeowners": ["@dcramer", "@frenck"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 9a4a1619630..aa93b7bae4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2147,7 +2147,7 @@ sendgrid==6.8.2 sense_energy==0.10.4 # homeassistant.components.sentry -sentry-sdk==1.5.12 +sentry-sdk==1.6.0 # homeassistant.components.sharkiq sharkiq==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c59af04700..1256de737ef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1422,7 +1422,7 @@ securetar==2022.2.0 sense_energy==0.10.4 # homeassistant.components.sentry -sentry-sdk==1.5.12 +sentry-sdk==1.6.0 # homeassistant.components.sharkiq sharkiq==0.0.1 From 33a84838b40b2cb41b1b37401a19bbc1069f5ab4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 13:41:28 +0200 Subject: [PATCH 583/947] Fix type hints in zha smartenergy channel (#73775) * Fix type hints in zha smartenergy channel * Adjust unit_of_measurement --- .../zha/core/channels/smartenergy.py | 22 +++++++++++-------- mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 731ec003011..66d3e3d6810 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -122,8 +122,8 @@ class Metering(ZigbeeChannel): def __init__(self, cluster: zigpy.zcl.Cluster, ch_pool: ChannelPool) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) - self._format_spec = None - self._summa_format = None + self._format_spec: str | None = None + self._summa_format: str | None = None @property def divisor(self) -> int: @@ -131,7 +131,7 @@ class Metering(ZigbeeChannel): return self.cluster.get("divisor") or 1 @property - def device_type(self) -> int | None: + def device_type(self) -> str | int | None: """Return metering device type.""" dev_type = self.cluster.get("metering_device_type") if dev_type is None: @@ -154,7 +154,7 @@ class Metering(ZigbeeChannel): return self.DeviceStatusDefault(status) @property - def unit_of_measurement(self) -> str: + def unit_of_measurement(self) -> int: """Return unit of measurement.""" return self.cluster.get("unit_of_measure") @@ -210,18 +210,22 @@ class Metering(ZigbeeChannel): return f"{{:0{width}.{r_digits}f}}" - def _formatter_function(self, selector: FormatSelector, value: int) -> int | float: + def _formatter_function( + self, selector: FormatSelector, value: int + ) -> int | float | str: """Return formatted value for display.""" - value = value * self.multiplier / self.divisor + value_float = value * self.multiplier / self.divisor if self.unit_of_measurement == 0: # Zigbee spec power unit is kW, but we show the value in W - value_watt = value * 1000 + value_watt = value_float * 1000 if value_watt < 100: return round(value_watt, 1) return round(value_watt) if selector == self.FormatSelector.SUMMATION: - return self._summa_format.format(value).lstrip() - return self._format_spec.format(value).lstrip() + assert self._summa_format + return self._summa_format.format(value_float).lstrip() + assert self._format_spec + return self._format_spec.format(value_float).lstrip() demand_formatter = partialmethod(_formatter_function, FormatSelector.DEMAND) summa_formatter = partialmethod(_formatter_function, FormatSelector.SUMMATION) diff --git a/mypy.ini b/mypy.ini index 26314c5bcad..19241872474 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2997,9 +2997,6 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.channels.security] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels.smartenergy] -ignore_errors = true - [mypy-homeassistant.components.zha.core.device] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 6a4ff9d8cdf..f319ccb5235 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -148,7 +148,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.zha.core.channels.homeautomation", "homeassistant.components.zha.core.channels.hvac", "homeassistant.components.zha.core.channels.security", - "homeassistant.components.zha.core.channels.smartenergy", "homeassistant.components.zha.core.device", "homeassistant.components.zha.core.discovery", "homeassistant.components.zha.core.gateway", From 754fe86dd988b51a20229f8d88dfcdecb60e90d8 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 14:15:44 +0200 Subject: [PATCH 584/947] Add fan to strict typing (#73820) * Add fan to strict typing * Adjust state_attributes * Adjust capability_attributes * Adjust is_on * Adjust vallox component * Revert "Adjust is_on" This reverts commit 48d207f250f99d8126702342c05a6be6e877e4d5. * Fix is_on property --- .strict-typing | 1 + homeassistant/components/fan/__init__.py | 24 +++++++++++++----------- homeassistant/components/vallox/fan.py | 2 +- mypy.ini | 11 +++++++++++ 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/.strict-typing b/.strict-typing index 77f7b6f50c2..1832a83641a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -87,6 +87,7 @@ homeassistant.components.emulated_hue.* homeassistant.components.esphome.* homeassistant.components.energy.* homeassistant.components.evil_genius_labs.* +homeassistant.components.fan.* homeassistant.components.fastdotcom.* homeassistant.components.filesize.* homeassistant.components.fitbit.* diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 5eb3bedabd5..8f6585f6535 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -7,7 +7,7 @@ from enum import IntEnum import functools as ft import logging import math -from typing import final +from typing import Any, final import voluptuous as vol @@ -80,9 +80,11 @@ class NotValidPresetModeError(ValueError): @bind_hass -def is_on(hass, entity_id: str) -> bool: +def is_on(hass: HomeAssistant, entity_id: str) -> bool: """Return if the fans are on based on the statemachine.""" - return hass.states.get(entity_id).state == STATE_ON + entity = hass.states.get(entity_id) + assert entity + return entity.state == STATE_ON async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -235,7 +237,7 @@ class FanEntity(ToggleEntity): """Set new preset mode.""" await self.hass.async_add_executor_job(self.set_preset_mode, preset_mode) - def _valid_preset_mode_or_raise(self, preset_mode): + def _valid_preset_mode_or_raise(self, preset_mode: str) -> None: """Raise NotValidPresetModeError on invalid preset_mode.""" preset_modes = self.preset_modes if not preset_modes or preset_mode not in preset_modes: @@ -247,7 +249,7 @@ class FanEntity(ToggleEntity): """Set the direction of the fan.""" raise NotImplementedError() - async def async_set_direction(self, direction: str): + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" await self.hass.async_add_executor_job(self.set_direction, direction) @@ -255,7 +257,7 @@ class FanEntity(ToggleEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" raise NotImplementedError() @@ -264,7 +266,7 @@ class FanEntity(ToggleEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.hass.async_add_executor_job( @@ -280,12 +282,12 @@ class FanEntity(ToggleEntity): """Oscillate the fan.""" raise NotImplementedError() - async def async_oscillate(self, oscillating: bool): + async def async_oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" await self.hass.async_add_executor_job(self.oscillate, oscillating) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if the entity is on.""" return ( self.percentage is not None and self.percentage > 0 @@ -321,7 +323,7 @@ class FanEntity(ToggleEntity): return self._attr_oscillating @property - def capability_attributes(self): + def capability_attributes(self) -> dict[str, list[str] | None]: """Return capability attributes.""" attrs = {} @@ -335,7 +337,7 @@ class FanEntity(ToggleEntity): @final @property - def state_attributes(self) -> dict: + def state_attributes(self) -> dict[str, float | str | None]: """Return optional state attributes.""" data: dict[str, float | str | None] = {} supported_features = self.supported_features diff --git a/homeassistant/components/vallox/fan.py b/homeassistant/components/vallox/fan.py index 6872acbb5b7..4ba7d2d88fd 100644 --- a/homeassistant/components/vallox/fan.py +++ b/homeassistant/components/vallox/fan.py @@ -132,7 +132,7 @@ class ValloxFan(ValloxEntity, FanEntity): Returns true if the mode has been changed, false otherwise. """ try: - self._valid_preset_mode_or_raise(preset_mode) # type: ignore[no-untyped-call] + self._valid_preset_mode_or_raise(preset_mode) except NotValidPresetModeError as err: _LOGGER.error(err) diff --git a/mypy.ini b/mypy.ini index 19241872474..8dca7403eff 100644 --- a/mypy.ini +++ b/mypy.ini @@ -720,6 +720,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.fan.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.fastdotcom.*] check_untyped_defs = true disallow_incomplete_defs = true From 54591b8ca1b928c883beb2aa080361e236d6d704 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Wed, 22 Jun 2022 14:24:16 +0200 Subject: [PATCH 585/947] BMW Connected Drive: Handle HTTP 429 issues better (#73675) Co-authored-by: rikroe --- .../bmw_connected_drive/config_flow.py | 21 +++++++++++----- .../bmw_connected_drive/coordinator.py | 22 +++++++++++++---- .../bmw_connected_drive/test_config_flow.py | 24 +++++++++++++++---- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index c07be4c8849..3994b0732a8 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Any -from bimmer_connected.account import MyBMWAccount +from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name from httpx import HTTPError import voluptuous as vol @@ -14,7 +14,7 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from . import DOMAIN -from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY +from .const import CONF_ALLOWED_REGIONS, CONF_READ_ONLY, CONF_REFRESH_TOKEN DATA_SCHEMA = vol.Schema( { @@ -32,19 +32,22 @@ async def validate_input( Data has the keys from DATA_SCHEMA with values provided by the user. """ - account = MyBMWAccount( + auth = MyBMWAuthentication( data[CONF_USERNAME], data[CONF_PASSWORD], get_region_from_name(data[CONF_REGION]), ) try: - await account.get_vehicles() + await auth.login() except HTTPError as ex: raise CannotConnect from ex # Return info that you want to store in the config entry. - return {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} + retval = {"title": f"{data[CONF_USERNAME]}{data.get(CONF_SOURCE, '')}"} + if auth.refresh_token: + retval[CONF_REFRESH_TOKEN] = auth.refresh_token + return retval class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): @@ -70,7 +73,13 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" if info: - return self.async_create_entry(title=info["title"], data=user_input) + return self.async_create_entry( + title=info["title"], + data={ + **user_input, + CONF_REFRESH_TOKEN: info.get(CONF_REFRESH_TOKEN), + }, + ) return self.async_show_form( step_id="user", data_schema=DATA_SCHEMA, errors=errors diff --git a/homeassistant/components/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index 1443a3e1e29..e5a968b47fd 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -7,7 +7,7 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name from bimmer_connected.models import GPSPosition -from httpx import HTTPError, TimeoutException +from httpx import HTTPError, HTTPStatusError, TimeoutException from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME @@ -16,7 +16,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda from .const import CONF_READ_ONLY, CONF_REFRESH_TOKEN, DOMAIN -SCAN_INTERVAL = timedelta(seconds=300) +DEFAULT_SCAN_INTERVAL_SECONDS = 300 +SCAN_INTERVAL = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) _LOGGER = logging.getLogger(__name__) @@ -53,8 +54,18 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): try: await self.account.get_vehicles() - except (HTTPError, TimeoutException) as err: - self._update_config_entry_refresh_token(None) + except (HTTPError, HTTPStatusError, TimeoutException) as err: + if isinstance(err, HTTPStatusError) and err.response.status_code == 429: + # Increase scan interval to not jump to not bring up the issue next time + self.update_interval = timedelta( + seconds=DEFAULT_SCAN_INTERVAL_SECONDS * 3 + ) + if isinstance(err, HTTPStatusError) and err.response.status_code in ( + 401, + 403, + ): + # Clear refresh token only on issues with authorization + self._update_config_entry_refresh_token(None) raise UpdateFailed(f"Error communicating with BMW API: {err}") from err if self.account.refresh_token != old_refresh_token: @@ -65,6 +76,9 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator): self.account.refresh_token, ) + # Reset scan interval after successful update + self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) + def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" data = { diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index d3c7c64dc99..10178c22de8 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -1,19 +1,32 @@ """Test the for the BMW Connected Drive config flow.""" from unittest.mock import patch +from bimmer_connected.api.authentication import MyBMWAuthentication from httpx import HTTPError from homeassistant import config_entries, data_entry_flow from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN -from homeassistant.components.bmw_connected_drive.const import CONF_READ_ONLY +from homeassistant.components.bmw_connected_drive.const import ( + CONF_READ_ONLY, + CONF_REFRESH_TOKEN, +) from homeassistant.const import CONF_USERNAME from . import FIXTURE_CONFIG_ENTRY, FIXTURE_USER_INPUT from tests.common import MockConfigEntry -FIXTURE_COMPLETE_ENTRY = FIXTURE_USER_INPUT.copy() -FIXTURE_IMPORT_ENTRY = FIXTURE_USER_INPUT.copy() +FIXTURE_REFRESH_TOKEN = "SOME_REFRESH_TOKEN" +FIXTURE_COMPLETE_ENTRY = { + **FIXTURE_USER_INPUT, + CONF_REFRESH_TOKEN: FIXTURE_REFRESH_TOKEN, +} +FIXTURE_IMPORT_ENTRY = {**FIXTURE_USER_INPUT, CONF_REFRESH_TOKEN: None} + + +def login_sideeffect(self: MyBMWAuthentication): + """Mock logging in and setting a refresh token.""" + self.refresh_token = FIXTURE_REFRESH_TOKEN async def test_show_form(hass): @@ -50,8 +63,9 @@ async def test_connection_error(hass): async def test_full_user_flow_implementation(hass): """Test registering an integration and finishing flow works.""" with patch( - "bimmer_connected.account.MyBMWAccount.get_vehicles", - return_value=[], + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=login_sideeffect, + autospec=True, ), patch( "homeassistant.components.bmw_connected_drive.async_setup_entry", return_value=True, From 19b2b330377d5afa4368aa4767c598347d5ab14a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jun 2022 09:03:18 -0500 Subject: [PATCH 586/947] Speed up subscribing to mqtt topics on connect (#73685) * Speed up subscribing to mqtt topics * update tests * Remove extra function wrapper * Recover debug logging for subscriptions * Small changes and test * Update homeassistant/components/mqtt/client.py * Update client.py Co-authored-by: jbouwh Co-authored-by: Erik Montnemery --- homeassistant/components/mqtt/client.py | 72 ++++++++++++++++++------- tests/components/mqtt/test_init.py | 24 +++++++-- 2 files changed, 74 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 66699372516..d676c128260 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Iterable from functools import lru_cache, partial, wraps import inspect from itertools import groupby @@ -430,7 +430,7 @@ class MQTT: # Only subscribe if currently connected. if self.connected: self._last_subscribe = time.time() - await self._async_perform_subscription(topic, qos) + await self._async_perform_subscriptions(((topic, qos),)) @callback def async_remove() -> None: @@ -464,16 +464,37 @@ class MQTT: _raise_on_error(result) await self._wait_for_mid(mid) - async def _async_perform_subscription(self, topic: str, qos: int) -> None: - """Perform a paho-mqtt subscription.""" + async def _async_perform_subscriptions( + self, subscriptions: Iterable[tuple[str, int]] + ) -> None: + """Perform MQTT client subscriptions.""" + + def _process_client_subscriptions() -> list[tuple[int, int]]: + """Initiate all subscriptions on the MQTT client and return the results.""" + subscribe_result_list = [] + for topic, qos in subscriptions: + result, mid = self._mqttc.subscribe(topic, qos) + subscribe_result_list.append((result, mid)) + _LOGGER.debug("Subscribing to %s, mid: %s", topic, mid) + return subscribe_result_list + async with self._paho_lock: - result: int | None = None - result, mid = await self.hass.async_add_executor_job( - self._mqttc.subscribe, topic, qos + results = await self.hass.async_add_executor_job( + _process_client_subscriptions ) - _LOGGER.debug("Subscribing to %s, mid: %s", topic, mid) - _raise_on_error(result) - await self._wait_for_mid(mid) + + tasks = [] + errors = [] + for result, mid in results: + if result == 0: + tasks.append(self._wait_for_mid(mid)) + else: + errors.append(result) + + if tasks: + await asyncio.gather(*tasks) + if errors: + _raise_on_errors(errors) def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code: int) -> None: """On connect callback. @@ -502,10 +523,16 @@ class MQTT: # Group subscriptions to only re-subscribe once for each topic. keyfunc = attrgetter("topic") - for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), keyfunc): - # Re-subscribe with the highest requested qos - max_qos = max(subscription.qos for subscription in subs) - self.hass.add_job(self._async_perform_subscription, topic, max_qos) + self.hass.add_job( + self._async_perform_subscriptions, + [ + # Re-subscribe with the highest requested qos + (topic, max(subscription.qos for subscription in subs)) + for topic, subs in groupby( + sorted(self.subscriptions, key=keyfunc), keyfunc + ) + ], + ) if ( CONF_BIRTH_MESSAGE in self.conf @@ -638,15 +665,22 @@ class MQTT: ) -def _raise_on_error(result_code: int | None) -> None: +def _raise_on_errors(result_codes: Iterable[int | None]) -> None: """Raise error if error result.""" # pylint: disable-next=import-outside-toplevel import paho.mqtt.client as mqtt - if result_code is not None and result_code != 0: - raise HomeAssistantError( - f"Error talking to MQTT: {mqtt.error_string(result_code)}" - ) + if messages := [ + mqtt.error_string(result_code) + for result_code in result_codes + if result_code != 0 + ]: + raise HomeAssistantError(f"Error talking to MQTT: {', '.join(messages)}") + + +def _raise_on_error(result_code: int | None) -> None: + """Raise error if error result.""" + _raise_on_errors((result_code,)) def _matcher_for_topic(subscription: str) -> Any: diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index a29f1fd88ef..b435798c241 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1312,6 +1312,20 @@ async def test_publish_error(hass, caplog): assert "Failed to connect to MQTT server: Out of memory." in caplog.text +async def test_subscribe_error( + hass, caplog, mqtt_mock_entry_no_yaml_config, mqtt_client_mock +): + """Test publish error.""" + await mqtt_mock_entry_no_yaml_config() + mqtt_client_mock.on_connect(mqtt_client_mock, None, None, 0) + await hass.async_block_till_done() + with pytest.raises(HomeAssistantError): + # simulate client is not connected error before subscribing + mqtt_client_mock.subscribe.side_effect = lambda *args: (4, None) + await mqtt.async_subscribe(hass, "some-topic", lambda *args: 0) + await hass.async_block_till_done() + + async def test_handle_message_callback( hass, caplog, mqtt_mock_entry_no_yaml_config, mqtt_client_mock ): @@ -1424,6 +1438,7 @@ async def test_setup_mqtt_client_protocol(hass): @patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2) +@patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_handle_mqtt_timeout_on_callback(hass, caplog): """Test publish without receiving an ACK callback.""" mid = 0 @@ -1764,9 +1779,12 @@ async def test_mqtt_subscribes_topics_on_connect( assert mqtt_client_mock.disconnect.call_count == 0 - expected = {"topic/test": 0, "home/sensor": 2, "still/pending": 1} - calls = {call[1][1]: call[1][2] for call in hass.add_job.mock_calls} - assert calls == expected + assert len(hass.add_job.mock_calls) == 1 + assert set(hass.add_job.mock_calls[0][1][1]) == { + ("home/sensor", 2), + ("still/pending", 1), + ("topic/test", 0), + } async def test_setup_entry_with_config_override( From 0461ec156648486416334574054467b19e9739a0 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 22 Jun 2022 11:09:21 -0400 Subject: [PATCH 587/947] Fix auth_sign_path with query params (take 2) (#73829) --- homeassistant/components/http/auth.py | 22 +++++- tests/components/http/test_auth.py | 103 ++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index dab6abede4c..09ef6e13e03 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -7,11 +7,11 @@ from ipaddress import ip_address import logging import secrets from typing import Final -from urllib.parse import unquote from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from yarl import URL from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User @@ -29,6 +29,7 @@ _LOGGER = logging.getLogger(__name__) DATA_API_PASSWORD: Final = "api_password" DATA_SIGN_SECRET: Final = "http.auth.sign_secret" SIGN_QUERY_PARAM: Final = "authSig" +SAFE_QUERY_PARAMS: Final = ["height", "width"] STORAGE_VERSION = 1 STORAGE_KEY = "http.auth" @@ -57,18 +58,26 @@ def async_sign_path( else: refresh_token_id = hass.data[STORAGE_KEY] + url = URL(path) now = dt_util.utcnow() + params = dict(sorted(url.query.items())) + for param in SAFE_QUERY_PARAMS: + params.pop(param, None) encoded = jwt.encode( { "iss": refresh_token_id, - "path": unquote(path), + "path": url.path, + "params": params, "iat": now, "exp": now + expiration, }, secret, algorithm="HS256", ) - return f"{path}?{SIGN_QUERY_PARAM}={encoded}" + + params[SIGN_QUERY_PARAM] = encoded + url = url.with_query(params) + return f"{url.path}?{url.query_string}" @callback @@ -176,6 +185,13 @@ async def async_setup_auth(hass: HomeAssistant, app: Application) -> None: if claims["path"] != request.path: return False + params = dict(sorted(request.query.items())) + del params[SIGN_QUERY_PARAM] + for param in SAFE_QUERY_PARAMS: + params.pop(param, None) + if claims["params"] != params: + return False + refresh_token = await hass.auth.async_get_refresh_token(claims["iss"]) if refresh_token is None: diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 4a2e1e8aed3..a06a696e994 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -17,6 +17,7 @@ from homeassistant.components import websocket_api from homeassistant.components.http.auth import ( CONTENT_USER_NAME, DATA_SIGN_SECRET, + SIGN_QUERY_PARAM, STORAGE_KEY, async_setup_auth, async_sign_path, @@ -294,6 +295,108 @@ async def test_auth_access_signed_path_with_refresh_token( assert req.status == HTTPStatus.UNAUTHORIZED +async def test_auth_access_signed_path_with_query_param( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and query params.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, "/?test=test", timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +async def test_auth_access_signed_path_with_query_param_order( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and query params different order.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, + "/?test=test&foo=bar", + timedelta(seconds=5), + refresh_token_id=refresh_token.id, + ) + url = yarl.URL(signed_path) + signed_path = f"{url.path}?{SIGN_QUERY_PARAM}={url.query.get(SIGN_QUERY_PARAM)}&foo=bar&test=test" + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +async def test_auth_access_signed_path_with_query_param_safe_param( + hass, app, aiohttp_client, hass_access_token +): + """Test access with signed url and changing a safe param.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, + "/?test=test&foo=bar", + timedelta(seconds=5), + refresh_token_id=refresh_token.id, + ) + signed_path = f"{signed_path}&width=100" + + req = await client.get(signed_path) + assert req.status == HTTPStatus.OK + data = await req.json() + assert data["user_id"] == refresh_token.user.id + + +@pytest.mark.parametrize( + "base_url,test_url", + [ + ("/?test=test", "/?test=test&foo=bar"), + ("/", "/?test=test"), + ("/?test=test&foo=bar", "/?test=test&foo=baz"), + ("/?test=test&foo=bar", "/?test=test"), + ], +) +async def test_auth_access_signed_path_with_query_param_tamper( + hass, app, aiohttp_client, hass_access_token, base_url: str, test_url: str +): + """Test access with signed url and query params that have been tampered with.""" + app.router.add_post("/", mock_handler) + app.router.add_get("/another_path", mock_handler) + await async_setup_auth(hass, app) + client = await aiohttp_client(app) + + refresh_token = await hass.auth.async_validate_access_token(hass_access_token) + + signed_path = async_sign_path( + hass, base_url, timedelta(seconds=5), refresh_token_id=refresh_token.id + ) + url = yarl.URL(signed_path) + token = url.query.get(SIGN_QUERY_PARAM) + + req = await client.get(f"{test_url}&{SIGN_QUERY_PARAM}={token}") + assert req.status == HTTPStatus.UNAUTHORIZED + + async def test_auth_access_signed_path_via_websocket( hass, app, hass_ws_client, hass_read_only_access_token ): From 7a407d09dc068e06f3dc21408e47e3efd3df32c6 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 22 Jun 2022 17:13:16 +0200 Subject: [PATCH 588/947] Fix filter & room occupied binary sensors (#73827) --- homeassistant/components/sensibo/binary_sensor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 04e4a0b873d..2e1d449fc1d 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -92,6 +92,9 @@ MOTION_DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, .. icon="mdi:motion-sensor", value_fn=lambda data: data.room_occupied, ), +) + +DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( FILTER_CLEAN_REQUIRED_DESCRIPTION, ) @@ -161,7 +164,7 @@ async def async_setup_entry( SensiboDeviceSensor(coordinator, device_id, description) for description in MOTION_DEVICE_SENSOR_TYPES for device_id, device_data in coordinator.data.parsed.items() - if device_data.motion_sensors is not None + if device_data.motion_sensors ) entities.extend( SensiboDeviceSensor(coordinator, device_id, description) @@ -169,6 +172,12 @@ async def async_setup_entry( for device_id, device_data in coordinator.data.parsed.items() if device_data.model == "pure" ) + entities.extend( + SensiboDeviceSensor(coordinator, device_id, description) + for description in DEVICE_SENSOR_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.model != "pure" + ) async_add_entities(entities) From 143e6a7adc74f6fd8b5d5037b9f772f35150dac3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 17:23:51 +0200 Subject: [PATCH 589/947] Add missing type hints in locks (#73831) --- homeassistant/components/sesame/lock.py | 6 ++++-- homeassistant/components/verisure/lock.py | 5 +++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index 99bb2d8a865..b9a230fed63 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -1,6 +1,8 @@ """Support for Sesame, by CANDY HOUSE.""" from __future__ import annotations +from typing import Any + import pysesame2 import voluptuous as vol @@ -61,11 +63,11 @@ class SesameDevice(LockEntity): """Return True if the device is currently locked, else False.""" return self._is_locked - def lock(self, **kwargs) -> None: + def lock(self, **kwargs: Any) -> None: """Lock the device.""" self._sesame.lock() - def unlock(self, **kwargs) -> None: + def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self._sesame.unlock() diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index 8f9556643f8..f96b99e2a8c 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from typing import Any from verisure import Error as VerisureError @@ -122,7 +123,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt """Return the state attributes.""" return {"method": self.changed_method} - async def async_unlock(self, **kwargs) -> None: + async def async_unlock(self, **kwargs: Any) -> None: """Send unlock command.""" code = kwargs.get( ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) @@ -133,7 +134,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt await self.async_set_lock_state(code, STATE_UNLOCKED) - async def async_lock(self, **kwargs) -> None: + async def async_lock(self, **kwargs: Any) -> None: """Send lock command.""" code = kwargs.get( ATTR_CODE, self.coordinator.entry.options.get(CONF_LOCK_DEFAULT_CODE) From 86fde1a644ef5ecde6fc66b90cd0c090feaa847f Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 22 Jun 2022 10:56:17 -0500 Subject: [PATCH 590/947] Handle failures during initial Sonos subscription (#73456) --- homeassistant/components/sonos/exception.py | 4 +++ homeassistant/components/sonos/speaker.py | 39 +++++++++++++-------- tests/components/sonos/test_speaker.py | 18 ++++++++++ 3 files changed, 47 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sonos/exception.py b/homeassistant/components/sonos/exception.py index dd2d30796cc..7ff5dacd293 100644 --- a/homeassistant/components/sonos/exception.py +++ b/homeassistant/components/sonos/exception.py @@ -7,6 +7,10 @@ class UnknownMediaType(BrowseError): """Unknown media type.""" +class SonosSubscriptionsFailed(HomeAssistantError): + """Subscription creation failed.""" + + class SonosUpdateError(HomeAssistantError): """Update failed.""" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 93d0afbcf9c..d37e3bac2a3 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -57,7 +57,7 @@ from .const import ( SONOS_VANISHED, SUBSCRIPTION_TIMEOUT, ) -from .exception import S1BatteryMissing, SonosUpdateError +from .exception import S1BatteryMissing, SonosSubscriptionsFailed, SonosUpdateError from .favorites import SonosFavorites from .helpers import soco_error from .media import SonosMedia @@ -324,12 +324,29 @@ class SonosSpeaker: async with self._subscription_lock: if self._subscriptions: return - await self._async_subscribe() + try: + await self._async_subscribe() + except SonosSubscriptionsFailed: + _LOGGER.warning("Creating subscriptions failed for %s", self.zone_name) + await self._async_offline() async def _async_subscribe(self) -> None: """Create event subscriptions.""" _LOGGER.debug("Creating subscriptions for %s", self.zone_name) + subscriptions = [ + self._subscribe(getattr(self.soco, service), self.async_dispatch_event) + for service in SUBSCRIPTION_SERVICES + ] + results = await asyncio.gather(*subscriptions, return_exceptions=True) + for result in results: + self.log_subscription_result( + result, "Creating subscription", logging.WARNING + ) + + if any(isinstance(result, Exception) for result in results): + raise SonosSubscriptionsFailed + # Create a polling task in case subscriptions fail or callback events do not arrive if not self._poll_timer: self._poll_timer = async_track_time_interval( @@ -342,16 +359,6 @@ class SonosSpeaker: SCAN_INTERVAL, ) - subscriptions = [ - self._subscribe(getattr(self.soco, service), self.async_dispatch_event) - for service in SUBSCRIPTION_SERVICES - ] - results = await asyncio.gather(*subscriptions, return_exceptions=True) - for result in results: - self.log_subscription_result( - result, "Creating subscription", logging.WARNING - ) - async def _subscribe( self, target: SubscriptionBase, sub_callback: Callable ) -> None: @@ -585,6 +592,11 @@ class SonosSpeaker: await self.async_offline() async def async_offline(self) -> None: + """Handle removal of speaker when unavailable.""" + async with self._subscription_lock: + await self._async_offline() + + async def _async_offline(self) -> None: """Handle removal of speaker when unavailable.""" if not self.available: return @@ -602,8 +614,7 @@ class SonosSpeaker: self._poll_timer() self._poll_timer = None - async with self._subscription_lock: - await self.async_unsubscribe() + await self.async_unsubscribe() self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid) diff --git a/tests/components/sonos/test_speaker.py b/tests/components/sonos/test_speaker.py index 96b3d222dc6..e47540a6aab 100644 --- a/tests/components/sonos/test_speaker.py +++ b/tests/components/sonos/test_speaker.py @@ -29,3 +29,21 @@ async def test_fallback_to_polling( assert speaker.subscriptions_failed assert "falling back to polling" in caplog.text assert "Activity on Zone A from SonosSpeaker.update_volume" in caplog.text + + +async def test_subscription_creation_fails(hass: HomeAssistant, async_setup_sonos): + """Test that subscription creation failures are handled.""" + with patch( + "homeassistant.components.sonos.speaker.SonosSpeaker._subscribe", + side_effect=ConnectionError("Took too long"), + ): + await async_setup_sonos() + + speaker = list(hass.data[DATA_SONOS].discovered.values())[0] + assert not speaker._subscriptions + + with patch.object(speaker, "_resub_cooldown_expires_at", None): + speaker.speaker_activity("discovery") + await hass.async_block_till_done() + + assert speaker._subscriptions From 837957d89e80e4f34b88efa346f8030c8daff5ff Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 18:22:01 +0200 Subject: [PATCH 591/947] Adjust set_percentage routine in fans (#73837) --- homeassistant/components/esphome/fan.py | 7 +++++-- homeassistant/components/smartthings/fan.py | 7 +++++-- homeassistant/components/wemo/fan.py | 7 +++++-- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py index f2440465e77..41d7e418673 100644 --- a/homeassistant/components/esphome/fan.py +++ b/homeassistant/components/esphome/fan.py @@ -67,8 +67,11 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): api_version = self._api_version return api_version.major == 1 and api_version.minor > 3 - async def async_set_percentage(self, percentage: int | None) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" + await self._async_set_percentage(percentage) + + async def _async_set_percentage(self, percentage: int | None) -> None: if percentage == 0: await self.async_turn_off() return @@ -95,7 +98,7 @@ class EsphomeFan(EsphomeEntity[FanInfo, FanState], FanEntity): **kwargs: Any, ) -> None: """Turn on the fan.""" - await self.async_set_percentage(percentage) + await self._async_set_percentage(percentage) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index d3f3affa358..36d47533bbb 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -52,8 +52,11 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): _attr_supported_features = FanEntityFeature.SET_SPEED - async def async_set_percentage(self, percentage: int | None) -> None: + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" + await self._async_set_percentage(percentage) + + async def _async_set_percentage(self, percentage: int | None) -> None: if percentage is None: await self._device.switch_on(set_status=True) elif percentage == 0: @@ -72,7 +75,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): **kwargs, ) -> None: """Turn the fan on.""" - await self.async_set_percentage(percentage) + await self._async_set_percentage(percentage) async def async_turn_off(self, **kwargs) -> None: """Turn the fan off.""" diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index d24827bee96..81065cf8108 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -136,15 +136,18 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity): **kwargs: Any, ) -> None: """Turn the fan on.""" - self.set_percentage(percentage) + self._set_percentage(percentage) def turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" with self._wemo_call_wrapper("turn off"): self.wemo.set_state(FanMode.Off) - def set_percentage(self, percentage: int | None) -> None: + def set_percentage(self, percentage: int) -> None: """Set the fan_mode of the Humidifier.""" + self._set_percentage(percentage) + + def _set_percentage(self, percentage: int | None) -> None: if percentage is None: named_speed = self._last_fan_on_mode elif percentage == 0: From 532e25d087b05f2c221af8826567efec4d60be73 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 22 Jun 2022 18:26:25 +0200 Subject: [PATCH 592/947] Sensibo use switch for Pure boost (#73833) * Initial commit * Finalize pure boost switch * Fix service required --- .../components/sensibo/binary_sensor.py | 7 -- homeassistant/components/sensibo/climate.py | 23 ++--- .../components/sensibo/services.yaml | 17 ++-- homeassistant/components/sensibo/switch.py | 42 ++++++++- .../components/sensibo/test_binary_sensor.py | 10 +-- tests/components/sensibo/test_climate.py | 51 +---------- tests/components/sensibo/test_switch.py | 87 ++++++++++++++++++- 7 files changed, 140 insertions(+), 97 deletions(-) diff --git a/homeassistant/components/sensibo/binary_sensor.py b/homeassistant/components/sensibo/binary_sensor.py index 2e1d449fc1d..ed280aab4fe 100644 --- a/homeassistant/components/sensibo/binary_sensor.py +++ b/homeassistant/components/sensibo/binary_sensor.py @@ -99,13 +99,6 @@ DEVICE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( ) PURE_SENSOR_TYPES: tuple[SensiboDeviceBinarySensorEntityDescription, ...] = ( - SensiboDeviceBinarySensorEntityDescription( - key="pure_boost_enabled", - device_class=BinarySensorDeviceClass.RUNNING, - name="Pure Boost Enabled", - icon="mdi:wind-power-outline", - value_fn=lambda data: data.pure_boost_enabled, - ), SensiboDeviceBinarySensorEntityDescription( key="pure_ac_integration", entity_category=EntityCategory.DIAGNOSTIC, diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 6a1084f733c..b4af38ab69c 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -107,21 +107,14 @@ async def async_setup_entry( platform.async_register_entity_service( SERVICE_ENABLE_PURE_BOOST, { - vol.Inclusive(ATTR_AC_INTEGRATION, "settings"): bool, - vol.Inclusive(ATTR_GEO_INTEGRATION, "settings"): bool, - vol.Inclusive(ATTR_INDOOR_INTEGRATION, "settings"): bool, - vol.Inclusive(ATTR_OUTDOOR_INTEGRATION, "settings"): bool, - vol.Inclusive(ATTR_SENSITIVITY, "settings"): vol.In( - ["Normal", "Sensitive"] - ), + vol.Required(ATTR_AC_INTEGRATION): bool, + vol.Required(ATTR_GEO_INTEGRATION): bool, + vol.Required(ATTR_INDOOR_INTEGRATION): bool, + vol.Required(ATTR_OUTDOOR_INTEGRATION): bool, + vol.Required(ATTR_SENSITIVITY): vol.In(["Normal", "Sensitive"]), }, "async_enable_pure_boost", ) - platform.async_register_entity_service( - SERVICE_DISABLE_PURE_BOOST, - {}, - "async_disable_pure_boost", - ) class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): @@ -353,9 +346,3 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): await self.async_send_command("set_pure_boost", params) await self.coordinator.async_refresh() - - async def async_disable_pure_boost(self) -> None: - """Disable Pure Boost Configuration.""" - - await self.async_send_command("set_pure_boost", {"enabled": False}) - await self.coordinator.async_refresh() diff --git a/homeassistant/components/sensibo/services.yaml b/homeassistant/components/sensibo/services.yaml index 6eb5c065789..9ce13b70eaa 100644 --- a/homeassistant/components/sensibo/services.yaml +++ b/homeassistant/components/sensibo/services.yaml @@ -45,45 +45,38 @@ enable_pure_boost: ac_integration: name: AC Integration description: Integrate with Air Conditioner. - required: false + required: true example: true selector: boolean: geo_integration: name: Geo Integration description: Integrate with Presence. - required: false + required: true example: true selector: boolean: indoor_integration: name: Indoor Air Quality description: Integrate with checking indoor air quality. - required: false + required: true example: true selector: boolean: outdoor_integration: name: Outdoor Air Quality description: Integrate with checking outdoor air quality. - required: false + required: true example: true selector: boolean: sensitivity: name: Sensitivity description: Set the sensitivity for Pure Boost. - required: false + required: true example: "Normal" selector: select: options: - "Normal" - "Sensitive" -disable_pure_boost: - name: Disable Pure Boost - description: Disable Pure Boost. - target: - entity: - integration: sensibo - domain: climate diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 3b9915d0a89..d9cf9417504 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -29,7 +29,7 @@ class DeviceBaseEntityDescriptionMixin: """Mixin for required Sensibo base description keys.""" value_fn: Callable[[SensiboDevice], bool | None] - extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None]] + extra_fn: Callable[[SensiboDevice], dict[str, str | bool | None]] | None command_on: str command_off: str remote_key: str @@ -56,6 +56,19 @@ DEVICE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( ), ) +PURE_SWITCH_TYPES: tuple[SensiboDeviceSwitchEntityDescription, ...] = ( + SensiboDeviceSwitchEntityDescription( + key="pure_boost_switch", + device_class=SwitchDeviceClass.SWITCH, + name="Pure Boost", + value_fn=lambda data: data.pure_boost_enabled, + extra_fn=None, + command_on="set_pure_boost", + command_off="set_pure_boost", + remote_key="pure_boost_enabled", + ), +) + def build_params(command: str, device_data: SensiboDevice) -> dict[str, Any] | None: """Build params for turning on switch.""" @@ -66,6 +79,16 @@ def build_params(command: str, device_data: SensiboDevice) -> dict[str, Any] | N "acState": {**device_data.ac_states, "on": new_state}, } return params + if command == "set_pure_boost": + new_state = bool(device_data.pure_boost_enabled is False) + params = {"enabled": new_state} + if device_data.pure_measure_integration is None: + params["sensitivity"] = "N" + params["measurementsIntegration"] = True + params["acIntegration"] = False + params["geoIntegration"] = False + params["primeIntegration"] = False + return params return None @@ -84,6 +107,12 @@ async def async_setup_entry( for device_id, device_data in coordinator.data.parsed.items() if device_data.model != "pure" ) + entities.extend( + SensiboDeviceSwitch(coordinator, device_id, description) + for description in PURE_SWITCH_TYPES + for device_id, device_data in coordinator.data.parsed.items() + if device_data.model == "pure" + ) async_add_entities(entities) @@ -130,7 +159,10 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" - result = await self.async_send_command(self.entity_description.command_off) + params = build_params(self.entity_description.command_on, self.device_data) + result = await self.async_send_command( + self.entity_description.command_off, params + ) if result["status"] == "success": setattr(self.device_data, self.entity_description.remote_key, False) @@ -141,6 +173,8 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): ) @property - def extra_state_attributes(self) -> Mapping[str, Any]: + def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional attributes.""" - return self.entity_description.extra_fn(self.device_data) + if self.entity_description.extra_fn: + return self.entity_description.extra_fn(self.device_data) + return None diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index efa6c5bdb2a..093cfe7e472 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -27,20 +27,18 @@ async def test_binary_sensor( state2 = hass.states.get("binary_sensor.hallway_motion_sensor_main_sensor") state3 = hass.states.get("binary_sensor.hallway_motion_sensor_motion") state4 = hass.states.get("binary_sensor.hallway_room_occupied") - state5 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") - state6 = hass.states.get( + state5 = hass.states.get( "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" ) - state7 = hass.states.get( + state6 = hass.states.get( "binary_sensor.kitchen_pure_boost_linked_with_outdoor_air_quality" ) assert state1.state == "on" assert state2.state == "on" assert state3.state == "on" assert state4.state == "on" - assert state5.state == "off" - assert state6.state == "on" - assert state7.state == "off" + assert state5.state == "on" + assert state6.state == "off" monkeypatch.setattr( get_data.parsed["ABC999111"].motion_sensors["AABBCC"], "alive", False diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index ea7f6eb16fe..e7a3c465f76 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -28,7 +28,6 @@ from homeassistant.components.sensibo.climate import ( ATTR_OUTDOOR_INTEGRATION, ATTR_SENSITIVITY, SERVICE_ASSUME_STATE, - SERVICE_DISABLE_PURE_BOOST, SERVICE_ENABLE_PURE_BOOST, SERVICE_ENABLE_TIMER, _find_valid_target_temp, @@ -809,7 +808,7 @@ async def test_climate_pure_boost( await hass.async_block_till_done() state_climate = hass.states.get("climate.kitchen") - state2 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") + state2 = hass.states.get("switch.kitchen_pure_boost") assert state2.state == "off" with patch( @@ -878,7 +877,7 @@ async def test_climate_pure_boost( ) await hass.async_block_till_done() - state1 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") + state1 = hass.states.get("switch.kitchen_pure_boost") state2 = hass.states.get( "binary_sensor.kitchen_pure_boost_linked_with_indoor_air_quality" ) @@ -890,49 +889,3 @@ async def test_climate_pure_boost( assert state2.state == "on" assert state3.state == "on" assert state4.state == "s" - - with patch( - "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", - return_value=get_data, - ), patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_set_pureboost", - return_value={ - "status": "success", - "result": { - "enabled": False, - "sensitivity": "S", - "measurements_integration": True, - "ac_integration": False, - "geo_integration": False, - "prime_integration": True, - }, - }, - ) as mock_set_pureboost: - await hass.services.async_call( - DOMAIN, - SERVICE_DISABLE_PURE_BOOST, - { - ATTR_ENTITY_ID: state_climate.entity_id, - }, - blocking=True, - ) - await hass.async_block_till_done() - mock_set_pureboost.assert_called_once() - - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", False) - monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_sensitivity", "s") - - with patch( - "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", - return_value=get_data, - ): - async_fire_time_changed( - hass, - dt.utcnow() + timedelta(minutes=5), - ) - await hass.async_block_till_done() - - state1 = hass.states.get("binary_sensor.kitchen_pure_boost_enabled") - state4 = hass.states.get("sensor.kitchen_pure_sensitivity") - assert state1.state == "off" - assert state4.state == "s" diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index 49efca4103e..2a24751d70b 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -25,7 +25,7 @@ from homeassistant.util import dt from tests.common import async_fire_time_changed -async def test_switch( +async def test_switch_timer( hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: MonkeyPatch, @@ -105,6 +105,81 @@ async def test_switch( assert state1.state == STATE_OFF +async def test_switch_pure_boost( + hass: HomeAssistant, + load_int: ConfigEntry, + monkeypatch: MonkeyPatch, + get_data: SensiboData, +) -> None: + """Test the Sensibo switch.""" + + state1 = hass.states.get("switch.kitchen_pure_boost") + assert state1.state == STATE_OFF + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + return_value={"status": "success"}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", True) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + state1 = hass.states.get("switch.kitchen_pure_boost") + assert state1.state == STATE_ON + + with patch( + "homeassistant.components.sensibo.util.SensiboClient.async_get_devices_data", + return_value=get_data, + ), patch( + "homeassistant.components.sensibo.util.SensiboClient.async_set_pureboost", + return_value={"status": "success"}, + ): + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: state1.entity_id, + }, + blocking=True, + ) + await hass.async_block_till_done() + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_boost_enabled", False) + + with patch( + "homeassistant.components.sensibo.coordinator.SensiboClient.async_get_devices_data", + return_value=get_data, + ): + async_fire_time_changed( + hass, + dt.utcnow() + timedelta(minutes=5), + ) + await hass.async_block_till_done() + + state1 = hass.states.get("switch.kitchen_pure_boost") + assert state1.state == STATE_OFF + + async def test_switch_command_failure( hass: HomeAssistant, load_int: ConfigEntry, @@ -162,4 +237,14 @@ async def test_build_params( "minutesFromNow": 60, "acState": {**get_data.parsed["ABC999111"].ac_states, "on": False}, } + + monkeypatch.setattr(get_data.parsed["AAZZAAZZ"], "pure_measure_integration", None) + assert build_params("set_pure_boost", get_data.parsed["AAZZAAZZ"]) == { + "enabled": True, + "sensitivity": "N", + "measurementsIntegration": True, + "acIntegration": False, + "geoIntegration": False, + "primeIntegration": False, + } assert build_params("incorrect_command", get_data.parsed["ABC999111"]) is None From 6b6e5fad3c5869f5c5a9b6cc32e6656b01e13884 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 18:43:41 +0200 Subject: [PATCH 593/947] Add missing type hints in fans (#73835) --- homeassistant/components/comfoconnect/fan.py | 2 +- homeassistant/components/demo/fan.py | 14 ++++--- homeassistant/components/fjaraskupan/fan.py | 8 ++-- homeassistant/components/freedompro/fan.py | 4 +- homeassistant/components/insteon/fan.py | 7 ++-- homeassistant/components/lutron_caseta/fan.py | 10 +++-- homeassistant/components/modern_forms/fan.py | 2 +- homeassistant/components/mqtt/fan.py | 15 +++---- homeassistant/components/smartthings/fan.py | 3 +- homeassistant/components/smarty/fan.py | 10 ++++- homeassistant/components/switch_as_x/fan.py | 4 +- homeassistant/components/template/fan.py | 16 ++++---- homeassistant/components/tuya/fan.py | 4 +- homeassistant/components/vesync/fan.py | 17 ++++---- homeassistant/components/wilight/fan.py | 14 ++++--- homeassistant/components/xiaomi_miio/fan.py | 41 ++++++++++--------- homeassistant/components/zha/fan.py | 8 +++- homeassistant/components/zwave_me/fan.py | 2 +- 18 files changed, 106 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index 84d82c170e1..f52c065a547 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -118,7 +118,7 @@ class ComfoConnectFan(FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" if percentage is None: diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index 66440588f17..ef6875cb7c6 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -1,6 +1,8 @@ """Demo fan platform that has a fake fan.""" from __future__ import annotations +from typing import Any + from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -192,9 +194,9 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): def turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the entity.""" if preset_mode: @@ -262,9 +264,9 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the entity.""" if preset_mode: diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index fcd95090400..ae5da3d189c 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -1,6 +1,8 @@ """Support for Fjäråskupan fans.""" from __future__ import annotations +from typing import Any + from fjaraskupan import ( COMMAND_AFTERCOOKINGTIMERAUTO, COMMAND_AFTERCOOKINGTIMERMANUAL, @@ -93,9 +95,9 @@ class Fan(CoordinatorEntity[Coordinator], FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index b7758813865..3513b7672e0 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -60,7 +60,7 @@ class FreedomproFan(CoordinatorEntity, FanEntity): return self._attr_is_on @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed percentage.""" return self._attr_percentage @@ -111,7 +111,7 @@ class FreedomproFan(CoordinatorEntity, FanEntity): ) await self.coordinator.async_request_refresh() - async def async_set_percentage(self, percentage: int): + async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" rotation_speed = {"rotationSpeed": percentage} payload = json.dumps(rotation_speed) diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 8639dfb79fe..8fe5fc9346e 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations import math +from typing import Any from homeassistant.components.fan import ( DOMAIN as FAN_DOMAIN, @@ -62,9 +63,9 @@ class InsteonFanEntity(InsteonEntity, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.async_set_percentage(percentage or 67) diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index e08a5278572..44dae8324fa 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -1,6 +1,8 @@ """Support for Lutron Caseta fans.""" from __future__ import annotations +from typing import Any + from pylutron_caseta import FAN_HIGH, FAN_LOW, FAN_MEDIUM, FAN_MEDIUM_HIGH, FAN_OFF from homeassistant.components.fan import DOMAIN, FanEntity, FanEntityFeature @@ -58,10 +60,10 @@ class LutronCasetaFan(LutronCasetaDeviceUpdatableEntity, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, - ): + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the fan on.""" if percentage is None: percentage = DEFAULT_ON_PERCENTAGE diff --git a/homeassistant/components/modern_forms/fan.py b/homeassistant/components/modern_forms/fan.py index 318bb8c969d..8bd8665dc3b 100644 --- a/homeassistant/components/modern_forms/fan.py +++ b/homeassistant/components/modern_forms/fan.py @@ -127,7 +127,7 @@ class ModernFormsFanEntity(FanEntity, ModernFormsDeviceEntity): async def async_turn_on( self, percentage: int | None = None, - preset_mode: int | None = None, + preset_mode: str | None = None, **kwargs: Any, ) -> None: """Turn on the fan.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 4e1d1465ac2..4e7e58afb9f 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -4,6 +4,7 @@ from __future__ import annotations import functools import logging import math +from typing import Any import voluptuous as vol @@ -511,17 +512,17 @@ class MqttFan(MqttEntity, FanEntity): return self._state @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage.""" return self._percentage @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset _mode.""" return self._preset_mode @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return self._preset_modes @@ -536,16 +537,16 @@ class MqttFan(MqttEntity, FanEntity): return self._speed_count @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return the oscillation state.""" return self._oscillation # The speed attribute deprecated in the schema, support will be removed after a quarter (2021.7) async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the entity. diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 36d47533bbb..abcd5d12f75 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence import math +from typing import Any from pysmartthings import Capability @@ -72,7 +73,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn the fan on.""" await self._async_set_percentage(percentage) diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index 531b96d2558..a51d6783245 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging import math +from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback @@ -64,7 +65,7 @@ class SmartyFan(FanEntity): return "mdi:air-conditioner" @property - def is_on(self): + def is_on(self) -> bool: """Return state of the fan.""" return bool(self._smarty_fan_speed) @@ -96,7 +97,12 @@ class SmartyFan(FanEntity): self._smarty_fan_speed = fan_speed self.schedule_update_ha_state() - def turn_on(self, percentage=None, preset_mode=None, **kwargs): + def turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn on the fan.""" _LOGGER.debug("Turning on fan. percentage is %s", percentage) self.set_percentage(percentage or DEFAULT_ON_PERCENTAGE) diff --git a/homeassistant/components/switch_as_x/fan.py b/homeassistant/components/switch_as_x/fan.py index 5ebc8902d06..d4f16a93ef6 100644 --- a/homeassistant/components/switch_as_x/fan.py +++ b/homeassistant/components/switch_as_x/fan.py @@ -1,6 +1,8 @@ """Fan support for switch entities.""" from __future__ import annotations +from typing import Any + from homeassistant.components.fan import FanEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ENTITY_ID @@ -53,7 +55,7 @@ class FanSwitch(BaseToggleEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan. diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index ca50f6017bd..67cbeb07170 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -215,35 +215,35 @@ class TemplateFan(TemplateEntity, FanEntity): return self._preset_modes @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._state == STATE_ON @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Return the current preset mode.""" return self._preset_mode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed percentage.""" return self._percentage @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return the oscillation state.""" return self._oscillating @property - def current_direction(self): + def current_direction(self) -> str | None: """Return the oscillation state.""" return self._direction async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" await self.async_run_script( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 2d16ed36d40..36ed4c3c58e 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -158,8 +158,8 @@ class TuyaFanEntity(TuyaEntity, FanEntity): def turn_on( self, - percentage: int = None, - preset_mode: str = None, + percentage: int | None = None, + preset_mode: str | None = None, **kwargs: Any, ) -> None: """Turn on the fan.""" diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 44e74209c30..696a6a9ecf9 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -1,6 +1,9 @@ """Support for VeSync fans.""" +from __future__ import annotations + import logging import math +from typing import Any from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry @@ -91,7 +94,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): self.smartfan = fan @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed.""" if ( self.smartfan.mode == "manual" @@ -115,7 +118,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): return PRESET_MODES[SKU_TO_BASE_DEVICE.get(self.device.device_type)] @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the current preset mode.""" if self.smartfan.mode in (FAN_MODE_AUTO, FAN_MODE_SLEEP): return self.smartfan.mode @@ -148,7 +151,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): return attr - def set_percentage(self, percentage): + def set_percentage(self, percentage: int) -> None: """Set the speed of the device.""" if percentage == 0: self.smartfan.turn_off() @@ -167,7 +170,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): ) self.schedule_update_ha_state() - def set_preset_mode(self, preset_mode): + def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode of device.""" if preset_mode not in self.preset_modes: raise ValueError( @@ -187,9 +190,9 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): def turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn the device on.""" if preset_mode: diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index b96b4b89c61..7a60fa8ab62 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -1,6 +1,8 @@ """Support for WiLight Fan.""" from __future__ import annotations +from typing import Any + from pywilight.const import ( FAN_V1, ITEM_FAN, @@ -64,7 +66,7 @@ class WiLightFan(WiLightDevice, FanEntity): return "mdi:fan" @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" return self._status.get("direction", WL_DIRECTION_OFF) != WL_DIRECTION_OFF @@ -98,9 +100,9 @@ class WiLightFan(WiLightDevice, FanEntity): async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn on the fan.""" if percentage is None: @@ -108,7 +110,7 @@ class WiLightFan(WiLightDevice, FanEntity): else: await self.async_set_percentage(percentage) - async def async_set_percentage(self, percentage: int): + async def async_set_percentage(self, percentage: int) -> None: """Set the speed of the fan.""" if percentage == 0: await self._client.set_fan_direction(self._index, WL_DIRECTION_OFF) @@ -121,7 +123,7 @@ class WiLightFan(WiLightDevice, FanEntity): wl_speed = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage) await self._client.set_fan_speed(self._index, wl_speed) - async def async_set_direction(self, direction: str): + async def async_set_direction(self, direction: str) -> None: """Set the direction of the fan.""" wl_direction = WL_DIRECTION_REVERSE if direction == DIRECTION_FORWARD: diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 969093545f0..31908ba373f 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -1,8 +1,11 @@ """Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier.""" +from __future__ import annotations + from abc import abstractmethod import asyncio import logging import math +from typing import Any from miio.airfresh import OperationMode as AirfreshOperationMode from miio.airfresh_t2017 import OperationMode as AirfreshOperationModeT2017 @@ -291,12 +294,12 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): """Hold operation mode class.""" @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return self._preset_modes @property - def percentage(self): + def percentage(self) -> None: """Return the percentage based speed of the fan.""" return None @@ -306,15 +309,15 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): return self._state_attrs @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._state async def async_turn_on( self, - percentage: int = None, - preset_mode: str = None, - **kwargs, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, ) -> None: """Turn the device on.""" result = await self._try_command( @@ -352,12 +355,12 @@ class XiaomiGenericAirPurifier(XiaomiGenericDevice): self._speed_count = 100 @property - def speed_count(self): + def speed_count(self) -> int: """Return the number of speeds of the fan supported.""" return self._speed_count @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the active preset mode.""" if self._state: preset_mode = self.operation_mode_class(self._mode).name @@ -451,7 +454,7 @@ class XiaomiAirPurifier(XiaomiGenericAirPurifier): return AirpurifierOperationMode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._state: mode = self.operation_mode_class(self._mode) @@ -528,7 +531,7 @@ class XiaomiAirPurifierMiot(XiaomiAirPurifier): return AirpurifierMiotOperationMode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._fan_level is None: return None @@ -642,7 +645,7 @@ class XiaomiAirFresh(XiaomiGenericAirPurifier): return AirfreshOperationMode @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._state: mode = AirfreshOperationMode(self._mode) @@ -733,7 +736,7 @@ class XiaomiAirFreshA1(XiaomiGenericAirPurifier): return AirfreshOperationModeT2017 @property - def percentage(self): + def percentage(self) -> int | None: """Return the current percentage based speed.""" if self._favorite_speed is None: return None @@ -826,17 +829,17 @@ class XiaomiGenericFan(XiaomiGenericDevice): self._percentage = None @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the active preset mode.""" return self._preset_mode @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return [mode.name for mode in self.operation_mode_class] @property - def percentage(self): + def percentage(self) -> int | None: """Return the current speed as a percentage.""" if self._state: return self._percentage @@ -844,7 +847,7 @@ class XiaomiGenericFan(XiaomiGenericDevice): return None @property - def oscillating(self): + def oscillating(self) -> bool | None: """Return whether or not the fan is currently oscillating.""" return self._oscillating @@ -890,12 +893,12 @@ class XiaomiFan(XiaomiGenericFan): """Hold operation mode class.""" @property - def preset_mode(self): + def preset_mode(self) -> str: """Get the active preset mode.""" return ATTR_MODE_NATURE if self._nature_mode else ATTR_MODE_NORMAL @property - def preset_modes(self) -> list: + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" return [ATTR_MODE_NATURE, ATTR_MODE_NORMAL] @@ -1030,7 +1033,7 @@ class XiaomiFanMiot(XiaomiGenericFan): return FanOperationMode @property - def preset_mode(self): + def preset_mode(self) -> str | None: """Get the active preset mode.""" return self._preset_mode diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index a4b9baa5f0a..298f9e47296 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -4,6 +4,7 @@ from __future__ import annotations from abc import abstractmethod import functools import math +from typing import Any from zigpy.exceptions import ZigbeeException from zigpy.zcl.clusters import hvac @@ -87,7 +88,12 @@ class BaseFan(FanEntity): """Return the number of speeds the fan supports.""" return int_states_in_range(SPEED_RANGE) - async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Turn the entity on.""" if percentage is None: percentage = DEFAULT_ON_PERCENTAGE diff --git a/homeassistant/components/zwave_me/fan.py b/homeassistant/components/zwave_me/fan.py index 45d238a6541..c332fb305c5 100644 --- a/homeassistant/components/zwave_me/fan.py +++ b/homeassistant/components/zwave_me/fan.py @@ -66,7 +66,7 @@ class ZWaveMeFan(ZWaveMeEntity, FanEntity): self, percentage: int | None = None, preset_mode: str | None = None, - **kwargs, + **kwargs: Any, ) -> None: """Turn on the fan.""" self.set_percentage(percentage if percentage is not None else 99) From 6cf9b22b5a9f6f8dd6c77ee26fc70840f023c408 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 22 Jun 2022 19:04:39 +0200 Subject: [PATCH 594/947] Python 3.10 / Base image 2022.06.01 (#73830) * Python 3.10 / Base image 2022.06.01 * Update requirements * push opencv * we don't need numpy on core for now * Remove unused ignore Co-authored-by: Franck Nijhof --- .github/workflows/wheels.yml | 126 +----------------- Dockerfile | 15 --- build.yaml | 10 +- .../components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/iqvia/sensor.py | 2 +- homeassistant/components/opencv/manifest.json | 2 +- .../components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 3 + machine/khadas-vim3 | 7 +- machine/raspberrypi3 | 4 +- machine/raspberrypi3-64 | 4 +- machine/raspberrypi4 | 4 +- machine/raspberrypi4-64 | 4 +- machine/tinker | 7 +- requirements_all.txt | 4 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 3 + 19 files changed, 43 insertions(+), 162 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 9bfcb48e09a..27307388546 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -65,47 +65,6 @@ jobs: path: ./requirements_diff.txt core: - name: Build wheels with ${{ matrix.tag }} (${{ matrix.arch }}) for core - if: github.repository_owner == 'home-assistant' - needs: init - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - arch: ${{ fromJson(needs.init.outputs.architectures) }} - tag: - - "3.9-alpine3.14" - steps: - - name: Checkout the repository - uses: actions/checkout@v3.0.2 - - - name: Download env_file - uses: actions/download-artifact@v3 - with: - name: env_file - - - name: Download requirements_diff - uses: actions/download-artifact@v3 - with: - name: requirements_diff - - - name: Build wheels - uses: home-assistant/wheels@2022.01.2 - with: - tag: ${{ matrix.tag }} - arch: ${{ matrix.arch }} - wheels-host: wheels.hass.io - wheels-key: ${{ secrets.WHEELS_KEY }} - wheels-user: wheels - env-file: true - apk: "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;cargo" - pip: "Cython;numpy==1.21.6" - skip-binary: aiohttp - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements.txt" - - core_musllinux: name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for core if: github.repository_owner == 'home-assistant' needs: init @@ -128,18 +87,6 @@ jobs: with: name: requirements_diff - - name: Adjust ENV / CP310 - run: | - if [ "${{ matrix.arch }}" = "i386" ]; then - echo "NPY_DISABLE_SVML=1" >> .env_file - fi - - requirement_files="requirements_all.txt requirements_diff.txt" - for requirement_file in ${requirement_files}; do - sed -i "s|numpy==1.21.6|numpy==1.22.4|g" ${requirement_file} - done - echo "numpy==1.22.4" >> homeassistant/package_constraints.txt - - name: Build wheels uses: home-assistant/wheels@2022.06.6 with: @@ -148,76 +95,13 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;yaml-dev" + apk: "libffi-dev;openssl-dev;yaml-dev" skip-binary: aiohttp constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements.txt" integrations: - name: Build wheels with ${{ matrix.tag }} (${{ matrix.arch }}) for integrations - if: github.repository_owner == 'home-assistant' - needs: init - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - arch: ${{ fromJson(needs.init.outputs.architectures) }} - tag: - - "3.9-alpine3.14" - steps: - - name: Checkout the repository - uses: actions/checkout@v3.0.2 - - - name: Download env_file - uses: actions/download-artifact@v3 - with: - name: env_file - - - name: Download requirements_diff - uses: actions/download-artifact@v3 - with: - name: requirements_diff - - - name: Uncomment packages - run: | - requirement_files="requirements_all.txt requirements_diff.txt" - for requirement_file in ${requirement_files}; do - sed -i "s|# pybluez|pybluez|g" ${requirement_file} - sed -i "s|# bluepy|bluepy|g" ${requirement_file} - sed -i "s|# beacontools|beacontools|g" ${requirement_file} - sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} - sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file} - sed -i "s|# evdev|evdev|g" ${requirement_file} - sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file} - sed -i "s|# pycups|pycups|g" ${requirement_file} - sed -i "s|# homekit|homekit|g" ${requirement_file} - sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} - sed -i "s|# decora|decora|g" ${requirement_file} - sed -i "s|# avion|avion|g" ${requirement_file} - sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} - sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} - sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} - sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} - done - - - name: Build wheels - uses: home-assistant/wheels@2022.01.2 - with: - tag: ${{ matrix.tag }} - arch: ${{ matrix.arch }} - wheels-host: wheels.hass.io - wheels-key: ${{ secrets.WHEELS_KEY }} - wheels-user: wheels - env-file: true - apk: "build-base;cmake;git;linux-headers;libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;cargo" - pip: "Cython;numpy;scikit-build" - skip-binary: aiohttp,grpcio - constraints: "homeassistant/package_constraints.txt" - requirements-diff: "requirements_diff.txt" - requirements: "requirements_all.txt" - - integrations_musllinux: name: Build musllinux wheels with musllinux_1_2 / cp310 at ${{ matrix.arch }} for integrations if: github.repository_owner == 'home-assistant' needs: init @@ -256,18 +140,12 @@ jobs: sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} done - - name: Adjust ENV / CP310 + - name: Adjust ENV run: | if [ "${{ matrix.arch }}" = "i386" ]; then echo "NPY_DISABLE_SVML=1" >> .env_file fi - requirement_files="requirements_all.txt requirements_diff.txt" - for requirement_file in ${requirement_files}; do - sed -i "s|numpy==1.21.6|numpy==1.22.4|g" ${requirement_file} - done - echo "numpy==1.22.4" >> homeassistant/package_constraints.txt - - name: Build wheels uses: home-assistant/wheels@2022.06.6 with: diff --git a/Dockerfile b/Dockerfile index 1d6ce675e74..13552d55a3d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,21 +25,6 @@ RUN \ -e ./homeassistant --use-deprecated=legacy-resolver \ && python3 -m compileall homeassistant/homeassistant -# Fix Bug with Alpine 3.14 and sqlite 3.35 -# https://gitlab.alpinelinux.org/alpine/aports/-/issues/12524 -ARG BUILD_ARCH -RUN \ - if [ "${BUILD_ARCH}" = "amd64" ]; then \ - export APK_ARCH=x86_64; \ - elif [ "${BUILD_ARCH}" = "i386" ]; then \ - export APK_ARCH=x86; \ - else \ - export APK_ARCH=${BUILD_ARCH}; \ - fi \ - && curl -O http://dl-cdn.alpinelinux.org/alpine/v3.13/main/${APK_ARCH}/sqlite-libs-3.34.1-r0.apk \ - && apk add --no-cache sqlite-libs-3.34.1-r0.apk \ - && rm -f sqlite-libs-3.34.1-r0.apk - # Home Assistant S6-Overlay COPY rootfs / diff --git a/build.yaml b/build.yaml index 196277184a3..23486fb5510 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.05.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.05.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.05.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.05.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.05.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.06.1 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.06.1 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.06.1 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.06.1 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.06.1 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index 213e8888e23..eb3135954b0 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,7 @@ "domain": "compensation", "name": "Compensation", "documentation": "https://www.home-assistant.io/integrations/compensation", - "requirements": ["numpy==1.21.6"], + "requirements": ["numpy==1.22.4"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 9bb07157b54..8b0dacd3575 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.21.6", "pyiqvia==2022.04.0"], + "requirements": ["numpy==1.22.4", "pyiqvia==2022.04.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "loggers": ["pyiqvia"] diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 51f2969e9fe..d8c7ea317c8 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -161,7 +161,7 @@ def calculate_trend(indices: list[float]) -> str: """Calculate the "moving average" of a set of indices.""" index_range = np.arange(0, len(indices)) index_array = np.array(indices) - linear_fit = np.polyfit(index_range, index_array, 1) # type: ignore[no-untyped-call] + linear_fit = np.polyfit(index_range, index_array, 1) slope = round(linear_fit[0], 2) if slope > 0: diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index 504b83bdaf9..8cd1604f106 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,7 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.21.6", "opencv-python-headless==4.5.2.54"], + "requirements": ["numpy==1.22.4", "opencv-python-headless==4.6.0.66"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index efd4f3d76d0..4168d820fb6 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -6,7 +6,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.1", - "numpy==1.21.6", + "numpy==1.22.4", "pillow==9.1.1" ], "codeowners": [], diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index aaae8f7cc54..578aea3bbc6 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.21.6"], + "requirements": ["numpy==1.22.4"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8452b4e62fb..16e508ddcda 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -86,6 +86,9 @@ httpcore==0.15.0 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 +# Ensure we run compatible with musllinux build env +numpy>=1.22.0 + # pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead pytest_asyncio==1000000000.0.0 diff --git a/machine/khadas-vim3 b/machine/khadas-vim3 index be07d6c8aba..5aeaca50780 100644 --- a/machine/khadas-vim3 +++ b/machine/khadas-vim3 @@ -2,4 +2,9 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ - usbutils + usbutils \ + && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ + --use-deprecated=legacy-resolver diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index 9985b4a3b7a..6eed9e94142 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index 35c6eec77de..1647f91813c 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index 9985b4a3b7a..6eed9e94142 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index 35c6eec77de..1647f91813c 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -6,7 +6,9 @@ RUN apk --no-cache add \ raspberrypi-libs \ usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - bluepy pybluez -c /usr/src/homeassistant/requirements_all.txt \ + pybluez \ + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ --use-deprecated=legacy-resolver ## diff --git a/machine/tinker b/machine/tinker index 9660ca71b9c..5976d533188 100644 --- a/machine/tinker +++ b/machine/tinker @@ -3,8 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add usbutils \ && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - -c /usr/src/homeassistant/homeassistant/package_constraints.txt \ - --use-deprecated=legacy-resolver \ - bluepy \ pybluez \ - pygatt[GATTTOOL] + pygatt[GATTTOOL] \ + -c /usr/src/homeassistant/requirements_all.txt \ + --use-deprecated=legacy-resolver diff --git a/requirements_all.txt b/requirements_all.txt index aa93b7bae4c..fbe5d177194 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1123,7 +1123,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.6 +numpy==1.22.4 # homeassistant.components.oasa_telematics oasatelematics==0.3 @@ -1156,7 +1156,7 @@ open-garage==0.2.0 open-meteo==0.2.1 # homeassistant.components.opencv -# opencv-python-headless==4.5.2.54 +# opencv-python-headless==4.6.0.66 # homeassistant.components.openerz openerz-api==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1256de737ef..4a627d0d6da 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -773,7 +773,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.21.6 +numpy==1.22.4 # homeassistant.components.google oauth2client==4.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 88524ab63de..5db46ffbeba 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -106,6 +106,9 @@ httpcore==0.15.0 # 5.2.0 fixed a collections abc deprecation hyperframe>=5.2.0 +# Ensure we run compatible with musllinux build env +numpy>=1.22.0 + # pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead pytest_asyncio==1000000000.0.0 From 54d04d233be98bcd0ebe0363f25ddddcb3d4985a Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 22 Jun 2022 13:13:43 -0400 Subject: [PATCH 595/947] Bump version of pyunifiprotect to 4.0.6 (#73843) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 00761790474..2b779c77629 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==4.0.5", "unifi-discovery==1.1.4"], + "requirements": ["pyunifiprotect==4.0.6", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index fbe5d177194..de75044f7db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.5 +pyunifiprotect==4.0.6 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a627d0d6da..7d43748f6fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.5 +pyunifiprotect==4.0.6 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 8d66623036c324016bf2ab9a23adbb287292ad57 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 22 Jun 2022 12:29:34 -0500 Subject: [PATCH 596/947] Add ZoneGroupState statistics to Sonos diagnostics (#73848) --- homeassistant/components/sonos/__init__.py | 8 ++++++++ homeassistant/components/sonos/diagnostics.py | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index c26bf269a30..2e2290dff04 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -190,6 +190,14 @@ class SonosDiscoveryManager: for speaker in self.data.discovered.values(): speaker.activity_stats.log_report() speaker.event_stats.log_report() + if zgs := next( + speaker.soco.zone_group_state for speaker in self.data.discovered.values() + ): + _LOGGER.debug( + "ZoneGroupState stats: (%s/%s) processed", + zgs.processed_count, + zgs.total_requests, + ) await asyncio.gather( *(speaker.async_offline() for speaker in self.data.discovered.values()) ) diff --git a/homeassistant/components/sonos/diagnostics.py b/homeassistant/components/sonos/diagnostics.py index 077ca3a68cd..463884e1ea8 100644 --- a/homeassistant/components/sonos/diagnostics.py +++ b/homeassistant/components/sonos/diagnostics.py @@ -136,4 +136,8 @@ async def async_generate_speaker_info( payload["media"] = await async_generate_media_info(hass, speaker) payload["activity_stats"] = speaker.activity_stats.report() payload["event_stats"] = speaker.event_stats.report() + payload["zone_group_state_stats"] = { + "processed": speaker.soco.zone_group_state.processed_count, + "total_requests": speaker.soco.zone_group_state.total_requests, + } return payload From 9229d14962030f0a5fd11941c2533aa8adb27bac Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 20:17:28 +0200 Subject: [PATCH 597/947] Automatically onboard Wiz (#73851) --- homeassistant/components/wiz/config_flow.py | 13 ++++----- tests/components/wiz/test_config_flow.py | 31 +++++++++++++++++++++ 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/wiz/config_flow.py b/homeassistant/components/wiz/config_flow.py index b2173ccda97..b1bce3eda0d 100644 --- a/homeassistant/components/wiz/config_flow.py +++ b/homeassistant/components/wiz/config_flow.py @@ -9,7 +9,7 @@ from pywizlight.discovery import DiscoveredBulb from pywizlight.exceptions import WizLightConnectionError, WizLightTimeOutError import voluptuous as vol -from homeassistant.components import dhcp +from homeassistant.components import dhcp, onboarding from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST from homeassistant.data_entry_flow import AbortFlow, FlowResult @@ -29,11 +29,12 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + _discovered_device: DiscoveredBulb + _name: str + def __init__(self) -> None: """Initialize the config flow.""" - self._discovered_device: DiscoveredBulb | None = None self._discovered_devices: dict[str, DiscoveredBulb] = {} - self._name: str | None = None async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" @@ -54,7 +55,6 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_handle_discovery(self) -> FlowResult: """Handle any discovery.""" device = self._discovered_device - assert device is not None _LOGGER.debug("Discovered device: %s", device) ip_address = device.ip_address mac = device.mac_address @@ -66,7 +66,6 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): async def _async_connect_discovered_or_abort(self) -> None: """Connect to the device and verify its responding.""" device = self._discovered_device - assert device is not None bulb = wizlight(device.ip_address) try: bulbtype = await bulb.get_bulbtype() @@ -84,10 +83,8 @@ class WizConfigFlow(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm discovery.""" - assert self._discovered_device is not None - assert self._name is not None ip_address = self._discovered_device.ip_address - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): # Make sure the device is still there and # update the name if the firmware has auto # updated since discovery diff --git a/tests/components/wiz/test_config_flow.py b/tests/components/wiz/test_config_flow.py index 58b46bbea9d..f37f2ba21a0 100644 --- a/tests/components/wiz/test_config_flow.py +++ b/tests/components/wiz/test_config_flow.py @@ -506,3 +506,34 @@ async def test_discovery_with_firmware_update(hass): } assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + "source, data", + [ + (config_entries.SOURCE_DHCP, DHCP_DISCOVERY), + (config_entries.SOURCE_INTEGRATION_DISCOVERY, INTEGRATION_DISCOVERY), + ], +) +async def test_discovered_during_onboarding(hass, source, data): + """Test dhcp or discovery during onboarding creates the config entry.""" + with _patch_wizlight(), patch( + "homeassistant.components.wiz.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.wiz.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["title"] == "WiZ Dimmable White ABCABC" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + } + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From 1ead6d6762027f8706b58f080149f9688d104dd0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 21:19:34 +0200 Subject: [PATCH 598/947] Automatically onboard Yeelight (#73854) --- .../components/yeelight/config_flow.py | 4 +- tests/components/yeelight/test_config_flow.py | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py index 5a457b7da95..b4afedd6c51 100644 --- a/homeassistant/components/yeelight/config_flow.py +++ b/homeassistant/components/yeelight/config_flow.py @@ -11,7 +11,7 @@ from yeelight.aio import AsyncBulb from yeelight.main import get_known_models from homeassistant import config_entries, exceptions -from homeassistant.components import dhcp, ssdp, zeroconf +from homeassistant.components import dhcp, onboarding, ssdp, zeroconf from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_ID, CONF_MODEL, CONF_NAME from homeassistant.core import callback @@ -134,7 +134,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_discovery_confirm(self, user_input=None): """Confirm discovery.""" - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self.async_create_entry( title=async_format_model_id(self._discovered_model, self.unique_id), data={ diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 1c19a5e7dfd..7d9acc670b5 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -809,3 +809,53 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant): assert result["type"] == RESULT_TYPE_ABORT assert result["reason"] == "already_configured" assert config_entry.data[CONF_HOST] == IP_ADDRESS + + +@pytest.mark.parametrize( + "source, data", + [ + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=IP_ADDRESS, macaddress="aa:bb:cc:dd:ee:ff", hostname="mock_hostname" + ), + ), + ( + config_entries.SOURCE_HOMEKIT, + zeroconf.ZeroconfServiceInfo( + host=IP_ADDRESS, + addresses=[IP_ADDRESS], + hostname="mock_hostname", + name="mock_name", + port=None, + properties={zeroconf.ATTR_PROPERTIES_ID: "aa:bb:cc:dd:ee:ff"}, + type="mock_type", + ), + ), + ], +) +async def test_discovered_during_onboarding(hass, source, data): + """Test we create a config entry when discovered during onboarding.""" + mocked_bulb = _mocked_bulb() + with _patch_discovery(), _patch_discovery_interval(), patch( + f"{MODULE_CONFIG_FLOW}.AsyncBulb", return_value=mocked_bulb + ), patch(f"{MODULE}.async_setup", return_value=True) as mock_async_setup, patch( + f"{MODULE}.async_setup_entry", return_value=True + ) as mock_async_setup_entry, patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ) as mock_is_onboarded: + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data=data + ) + await hass.async_block_till_done() + + assert result["type"] == "create_entry" + assert result["data"] == { + CONF_HOST: IP_ADDRESS, + CONF_ID: "0x000000000015243f", + CONF_MODEL: MODEL, + } + assert mock_async_setup.called + assert mock_async_setup_entry.called + assert mock_is_onboarded.called From 0e674fc59746fb648cc7c3b736e87c2abeb69acd Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 22 Jun 2022 21:35:26 +0200 Subject: [PATCH 599/947] Clean up zwave_js logging and hass.data (#73856) --- homeassistant/components/zwave_js/__init__.py | 26 +++++-------------- .../components/zwave_js/binary_sensor.py | 3 --- homeassistant/components/zwave_js/cover.py | 3 --- homeassistant/components/zwave_js/entity.py | 6 +---- homeassistant/components/zwave_js/light.py | 3 --- homeassistant/components/zwave_js/lock.py | 4 +-- homeassistant/components/zwave_js/sensor.py | 4 +-- homeassistant/components/zwave_js/switch.py | 3 --- 8 files changed, 10 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 4f5756361c8..fe616e8bdb9 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -104,8 +104,6 @@ from .services import ZWaveServices CONNECT_TIMEOUT = 10 DATA_CLIENT_LISTEN_TASK = "client_listen_task" DATA_START_PLATFORM_TASK = "start_platform_task" -DATA_CONNECT_FAILED_LOGGED = "connect_failed_logged" -DATA_INVALID_SERVER_VERSION_LOGGED = "invalid_server_version_logged" async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -170,28 +168,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await async_ensure_addon_running(hass, entry) client = ZwaveClient(entry.data[CONF_URL], async_get_clientsession(hass)) - entry_hass_data: dict = hass.data[DOMAIN].setdefault(entry.entry_id, {}) # connect and throw error if connection failed try: async with timeout(CONNECT_TIMEOUT): await client.connect() except InvalidServerVersion as err: - if not entry_hass_data.get(DATA_INVALID_SERVER_VERSION_LOGGED): - LOGGER.error("Invalid server version: %s", err) - entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = True if use_addon: async_ensure_addon_updated(hass) - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Invalid server version: {err}") from err except (asyncio.TimeoutError, BaseZwaveJSServerError) as err: - if not entry_hass_data.get(DATA_CONNECT_FAILED_LOGGED): - LOGGER.error("Failed to connect: %s", err) - entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = True - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(f"Failed to connect: {err}") from err else: LOGGER.info("Connected to Zwave JS Server") - entry_hass_data[DATA_CONNECT_FAILED_LOGGED] = False - entry_hass_data[DATA_INVALID_SERVER_VERSION_LOGGED] = False dev_reg = device_registry.async_get(hass) ent_reg = entity_registry.async_get(hass) @@ -202,7 +191,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async_register_api(hass) platform_task = hass.async_create_task(start_platforms(hass, entry, client)) - entry_hass_data[DATA_START_PLATFORM_TASK] = platform_task + hass.data[DOMAIN].setdefault(entry.entry_id, {})[ + DATA_START_PLATFORM_TASK + ] = platform_task return True @@ -635,9 +626,7 @@ async def disconnect_client(hass: HomeAssistant, entry: ConfigEntry) -> None: platform_task: asyncio.Task = data[DATA_START_PLATFORM_TASK] listen_task.cancel() platform_task.cancel() - platform_setup_tasks = ( - hass.data[DOMAIN].get(entry.entry_id, {}).get(DATA_PLATFORM_SETUP, {}).values() - ) + platform_setup_tasks = data.get(DATA_PLATFORM_SETUP, {}).values() for task in platform_setup_tasks: task.cancel() @@ -711,8 +700,7 @@ async def async_ensure_addon_running(hass: HomeAssistant, entry: ConfigEntry) -> try: addon_info = await addon_manager.async_get_addon_info() except AddonError as err: - LOGGER.error(err) - raise ConfigEntryNotReady from err + raise ConfigEntryNotReady(err) from err usb_path: str = entry.data[CONF_USB_PATH] # s0_legacy_key was saved as network_key before s2 was added. diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index fb085cffe62..f6480689910 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -import logging from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import CommandClass @@ -30,8 +29,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - NOTIFICATION_SMOKE_ALARM = "1" NOTIFICATION_CARBON_MONOOXIDE = "2" diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index ee83db4578c..30364d127eb 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -1,7 +1,6 @@ """Support for Z-Wave cover devices.""" from __future__ import annotations -import logging from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient @@ -39,8 +38,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index a4271ac1c02..79dd1d27a4c 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,8 +1,6 @@ """Generic Z-Wave Entity Class.""" from __future__ import annotations -import logging - from zwave_js_server.const import NodeStatus from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value as ZwaveValue, get_value_id @@ -12,12 +10,10 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity -from .const import DOMAIN +from .const import DOMAIN, LOGGER from .discovery import ZwaveDiscoveryInfo from .helpers import get_device_id, get_unique_id -LOGGER = logging.getLogger(__name__) - EVENT_VALUE_UPDATED = "value updated" EVENT_VALUE_REMOVED = "value removed" EVENT_DEAD = "dead" diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 17293e85a21..4c8fe2a3986 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -1,7 +1,6 @@ """Support for Z-Wave lights.""" from __future__ import annotations -import logging from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient @@ -49,8 +48,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - MULTI_COLOR_MAP = { ColorComponent.WARM_WHITE: COLOR_SWITCH_COMBINED_WARM_WHITE, ColorComponent.COLD_WHITE: COLOR_SWITCH_COMBINED_COLD_WHITE, diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index ffe99373991..efeadb9b6b3 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -1,7 +1,6 @@ """Representation of Z-Wave locks.""" from __future__ import annotations -import logging from typing import Any import voluptuous as vol @@ -27,6 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( DATA_CLIENT, DOMAIN, + LOGGER, SERVICE_CLEAR_LOCK_USERCODE, SERVICE_SET_LOCK_USERCODE, ) @@ -35,8 +35,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - STATE_TO_ZWAVE_MAP: dict[int, dict[str, int | bool]] = { CommandClass.DOOR_LOCK: { STATE_UNLOCKED: DoorLockMode.UNSECURED, diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 2b2e2a0de2b..22fbfdab728 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import cast import voluptuous as vol @@ -55,6 +54,7 @@ from .const import ( ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TOTAL_INCREASING, ENTITY_DESC_KEY_VOLTAGE, + LOGGER, SERVICE_RESET_METER, ) from .discovery import ZwaveDiscoveryInfo @@ -67,8 +67,6 @@ from .helpers import get_device_id, get_valueless_base_unique_id PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - STATUS_ICON: dict[NodeStatus, str] = { NodeStatus.ALIVE: "mdi:heart-pulse", NodeStatus.ASLEEP: "mdi:sleep", diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 52b8f813326..154106d56f5 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -1,7 +1,6 @@ """Representation of Z-Wave switches.""" from __future__ import annotations -import logging from typing import Any from zwave_js_server.client import Client as ZwaveClient @@ -23,8 +22,6 @@ from .entity import ZWaveBaseEntity PARALLEL_UPDATES = 0 -LOGGER = logging.getLogger(__name__) - async def async_setup_entry( hass: HomeAssistant, From 9ac28d20760614e11b79dd42775022e9e6d3daa2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 21:40:22 +0200 Subject: [PATCH 600/947] Adjust vesync type hints (#73842) --- homeassistant/components/vesync/fan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 696a6a9ecf9..9932790fa96 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -113,9 +113,9 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): ) @property - def preset_modes(self): + def preset_modes(self) -> list[str]: """Get the list of available preset modes.""" - return PRESET_MODES[SKU_TO_BASE_DEVICE.get(self.device.device_type)] + return PRESET_MODES[SKU_TO_BASE_DEVICE[self.device.device_type]] @property def preset_mode(self) -> str | None: From 8b067e83f73f3aaf4a0a6207baf4547ed42c5bfb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jun 2022 14:59:51 -0500 Subject: [PATCH 601/947] Initial orjson support take 3 (#73849) * Initial orjson support take 2 Still need to work out problem building wheels -- Redux of #72754 / #32153 Now possible since the following is solved: ijl/orjson#220 (comment) This implements orjson where we use our default encoder. This does not implement orjson where `ExtendedJSONEncoder` is used as these areas tend to be called far less frequently. If its desired, this could be done in a followup, but it seemed like a case of diminishing returns (except maybe for large diagnostics files, or traces, but those are not expected to be downloaded frequently). Areas where this makes a perceptible difference: - Anything that subscribes to entities (Initial subscribe_entities payload) - Initial download of registries on first connection / restore - History queries - Saving states to the database - Large logbook queries - Anything that subscribes to events (appdaemon) Cavets: orjson supports serializing dataclasses natively (and much faster) which eliminates the need to implement `as_dict` in many places when the data is already in a dataclass. This works well as long as all the data in the dataclass can also be serialized. I audited all places where we have an `as_dict` for a dataclass and found only backups needs to be adjusted (support for `Path` needed to be added for backups). I was a little bit worried about `SensorExtraStoredData` with `Decimal` but it all seems to work out from since it converts it before it gets to the json encoding cc @dgomes If it turns out to be a problem we can disable this with option |= [orjson.OPT_PASSTHROUGH_DATACLASS](https://github.com/ijl/orjson#opt_passthrough_dataclass) and it will fallback to `as_dict` Its quite impressive for history queries Screen_Shot_2022-05-30_at_23_46_30 * use for views as well * handle UnicodeEncodeError * tweak * DRY * DRY * not needed * fix tests * Update tests/components/http/test_view.py * Update tests/components/http/test_view.py * black * templates --- homeassistant/components/history/__init__.py | 2 +- homeassistant/components/http/view.py | 7 +-- .../components/logbook/websocket_api.py | 2 +- homeassistant/components/recorder/const.py | 10 +--- homeassistant/components/recorder/core.py | 15 +++-- .../components/recorder/db_schema.py | 57 ++++++++++--------- homeassistant/components/recorder/models.py | 4 +- .../components/websocket_api/commands.py | 18 +++--- .../components/websocket_api/connection.py | 3 +- .../components/websocket_api/const.py | 7 --- .../components/websocket_api/messages.py | 7 ++- homeassistant/helpers/aiohttp_client.py | 2 + homeassistant/helpers/json.py | 49 +++++++++++++++- homeassistant/helpers/template.py | 5 +- homeassistant/package_constraints.txt | 1 + homeassistant/scripts/benchmark/__init__.py | 3 +- homeassistant/util/json.py | 9 ++- pyproject.toml | 2 + requirements.txt | 1 + tests/components/energy/test_validate.py | 7 ++- tests/components/http/test_view.py | 12 +++- .../components/websocket_api/test_commands.py | 6 +- 22 files changed, 149 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 27acff54f99..77301532d3d 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -24,10 +24,10 @@ from homeassistant.components.recorder.statistics import ( ) from homeassistant.components.recorder.util import session_scope from homeassistant.components.websocket_api import messages -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import INCLUDE_EXCLUDE_BASE_FILTER_SCHEMA +from homeassistant.helpers.json import JSON_DUMP from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 192d2d5d57b..6ab3b2a84a4 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from http import HTTPStatus -import json import logging from typing import Any @@ -21,7 +20,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, is_callback -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS, json_bytes from .const import KEY_AUTHENTICATED, KEY_HASS @@ -53,8 +52,8 @@ class HomeAssistantView: ) -> web.Response: """Return a JSON response.""" try: - msg = json.dumps(result, cls=JSONEncoder, allow_nan=False).encode("UTF-8") - except (ValueError, TypeError) as err: + msg = json_bytes(result) + except JSON_ENCODE_EXCEPTIONS as err: _LOGGER.error("Unable to serialize to JSON: %s\n%s", err, result) raise HTTPInternalServerError from err response = web.Response( diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index a8f9bc50920..04afa82e75b 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -14,10 +14,10 @@ from homeassistant.components import websocket_api from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util from .const import LOGBOOK_ENTITIES_FILTER diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index e558d19b530..532644c7feb 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -1,12 +1,10 @@ """Recorder constants.""" -from functools import partial -import json -from typing import Final - from homeassistant.backports.enum import StrEnum from homeassistant.const import ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import ( # noqa: F401 pylint: disable=unused-import + JSON_DUMP, +) DATA_INSTANCE = "recorder_instance" SQLITE_URL_PREFIX = "sqlite://" @@ -27,8 +25,6 @@ MAX_ROWS_TO_PURGE = 998 DB_WORKER_PREFIX = "DbWorker" -JSON_DUMP: Final = partial(json.dumps, cls=JSONEncoder, separators=(",", ":")) - ALL_DOMAIN_EXCLUDE_ATTRS = {ATTR_ATTRIBUTION, ATTR_RESTORED, ATTR_SUPPORTED_FEATURES} ATTR_KEEP_DAYS = "keep_days" diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index d8260976ccf..9585804690a 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -36,6 +36,7 @@ from homeassistant.helpers.event import ( async_track_time_interval, async_track_utc_time_change, ) +from homeassistant.helpers.json import JSON_ENCODE_EXCEPTIONS from homeassistant.helpers.typing import UNDEFINED, UndefinedType import homeassistant.util.dt as dt_util @@ -754,11 +755,12 @@ class Recorder(threading.Thread): return try: - shared_data = EventData.shared_data_from_event(event) - except (TypeError, ValueError) as ex: + shared_data_bytes = EventData.shared_data_bytes_from_event(event) + except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning("Event is not JSON serializable: %s: %s", event, ex) return + shared_data = shared_data_bytes.decode("utf-8") # Matching attributes found in the pending commit if pending_event_data := self._pending_event_data.get(shared_data): dbevent.event_data_rel = pending_event_data @@ -766,7 +768,7 @@ class Recorder(threading.Thread): elif data_id := self._event_data_ids.get(shared_data): dbevent.data_id = data_id else: - data_hash = EventData.hash_shared_data(shared_data) + data_hash = EventData.hash_shared_data_bytes(shared_data_bytes) # Matching attributes found in the database if data_id := self._find_shared_data_in_db(data_hash, shared_data): self._event_data_ids[shared_data] = dbevent.data_id = data_id @@ -785,10 +787,10 @@ class Recorder(threading.Thread): assert self.event_session is not None try: dbstate = States.from_event(event) - shared_attrs = StateAttributes.shared_attrs_from_event( + shared_attrs_bytes = StateAttributes.shared_attrs_bytes_from_event( event, self._exclude_attributes_by_domain ) - except (TypeError, ValueError) as ex: + except JSON_ENCODE_EXCEPTIONS as ex: _LOGGER.warning( "State is not JSON serializable: %s: %s", event.data.get("new_state"), @@ -796,6 +798,7 @@ class Recorder(threading.Thread): ) return + shared_attrs = shared_attrs_bytes.decode("utf-8") dbstate.attributes = None # Matching attributes found in the pending commit if pending_attributes := self._pending_state_attributes.get(shared_attrs): @@ -804,7 +807,7 @@ class Recorder(threading.Thread): elif attributes_id := self._state_attributes_ids.get(shared_attrs): dbstate.attributes_id = attributes_id else: - attr_hash = StateAttributes.hash_shared_attrs(shared_attrs) + attr_hash = StateAttributes.hash_shared_attrs_bytes(shared_attrs_bytes) # Matching attributes found in the database if attributes_id := self._find_shared_attr_in_db(attr_hash, shared_attrs): dbstate.attributes_id = attributes_id diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 642efe2e969..f300cc0bae7 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -3,12 +3,12 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -import json import logging from typing import Any, cast import ciso8601 from fnvhash import fnv1a_32 +import orjson from sqlalchemy import ( JSON, BigInteger, @@ -39,9 +39,10 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.json import JSON_DUMP, json_bytes import homeassistant.util.dt as dt_util -from .const import ALL_DOMAIN_EXCLUDE_ATTRS, JSON_DUMP +from .const import ALL_DOMAIN_EXCLUDE_ATTRS from .models import StatisticData, StatisticMetaData, process_timestamp # SQLAlchemy Schema @@ -124,7 +125,7 @@ class JSONLiteral(JSON): # type: ignore[misc] def process(value: Any) -> str: """Dump json.""" - return json.dumps(value) + return JSON_DUMP(value) return process @@ -187,7 +188,7 @@ class Events(Base): # type: ignore[misc,valid-type] try: return Event( self.event_type, - json.loads(self.event_data) if self.event_data else {}, + orjson.loads(self.event_data) if self.event_data else {}, EventOrigin(self.origin) if self.origin else EVENT_ORIGIN_ORDER[self.origin_idx], @@ -195,7 +196,7 @@ class Events(Base): # type: ignore[misc,valid-type] context=context, ) except ValueError: - # When json.loads fails + # When orjson.loads fails _LOGGER.exception("Error converting to event: %s", self) return None @@ -223,25 +224,26 @@ class EventData(Base): # type: ignore[misc,valid-type] @staticmethod def from_event(event: Event) -> EventData: """Create object from an event.""" - shared_data = JSON_DUMP(event.data) + shared_data = json_bytes(event.data) return EventData( - shared_data=shared_data, hash=EventData.hash_shared_data(shared_data) + shared_data=shared_data.decode("utf-8"), + hash=EventData.hash_shared_data_bytes(shared_data), ) @staticmethod - def shared_data_from_event(event: Event) -> str: - """Create shared_attrs from an event.""" - return JSON_DUMP(event.data) + def shared_data_bytes_from_event(event: Event) -> bytes: + """Create shared_data from an event.""" + return json_bytes(event.data) @staticmethod - def hash_shared_data(shared_data: str) -> int: + def hash_shared_data_bytes(shared_data_bytes: bytes) -> int: """Return the hash of json encoded shared data.""" - return cast(int, fnv1a_32(shared_data.encode("utf-8"))) + return cast(int, fnv1a_32(shared_data_bytes)) def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], json.loads(self.shared_data)) + return cast(dict[str, Any], orjson.loads(self.shared_data)) except ValueError: _LOGGER.exception("Error converting row to event data: %s", self) return {} @@ -328,9 +330,9 @@ class States(Base): # type: ignore[misc,valid-type] parent_id=self.context_parent_id, ) try: - attrs = json.loads(self.attributes) if self.attributes else {} + attrs = orjson.loads(self.attributes) if self.attributes else {} except ValueError: - # When json.loads fails + # When orjson.loads fails _LOGGER.exception("Error converting row to state: %s", self) return None if self.last_changed is None or self.last_changed == self.last_updated: @@ -376,40 +378,39 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] """Create object from a state_changed event.""" state: State | None = event.data.get("new_state") # None state means the state was removed from the state machine - dbstate = StateAttributes( - shared_attrs="{}" if state is None else JSON_DUMP(state.attributes) - ) - dbstate.hash = StateAttributes.hash_shared_attrs(dbstate.shared_attrs) + attr_bytes = b"{}" if state is None else json_bytes(state.attributes) + dbstate = StateAttributes(shared_attrs=attr_bytes.decode("utf-8")) + dbstate.hash = StateAttributes.hash_shared_attrs_bytes(attr_bytes) return dbstate @staticmethod - def shared_attrs_from_event( + def shared_attrs_bytes_from_event( event: Event, exclude_attrs_by_domain: dict[str, set[str]] - ) -> str: + ) -> bytes: """Create shared_attrs from a state_changed event.""" state: State | None = event.data.get("new_state") # None state means the state was removed from the state machine if state is None: - return "{}" + return b"{}" domain = split_entity_id(state.entity_id)[0] exclude_attrs = ( exclude_attrs_by_domain.get(domain, set()) | ALL_DOMAIN_EXCLUDE_ATTRS ) - return JSON_DUMP( + return json_bytes( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} ) @staticmethod - def hash_shared_attrs(shared_attrs: str) -> int: - """Return the hash of json encoded shared attributes.""" - return cast(int, fnv1a_32(shared_attrs.encode("utf-8"))) + def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: + """Return the hash of orjson encoded shared attributes.""" + return cast(int, fnv1a_32(shared_attrs_bytes)) def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], json.loads(self.shared_attrs)) + return cast(dict[str, Any], orjson.loads(self.shared_attrs)) except ValueError: - # When json.loads fails + # When orjson.loads fails _LOGGER.exception("Error converting row to state attributes: %s", self) return {} diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index ef1f76df9fc..64fb44289b0 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -2,10 +2,10 @@ from __future__ import annotations from datetime import datetime -import json import logging from typing import Any, TypedDict, overload +import orjson from sqlalchemy.engine.row import Row from homeassistant.components.websocket_api.const import ( @@ -253,7 +253,7 @@ def decode_attributes_from_row( if not source or source == EMPTY_JSON_OBJECT: return {} try: - attr_cache[source] = attributes = json.loads(source) + attr_cache[source] = attributes = orjson.loads(source) except ValueError: _LOGGER.exception("Error converting row to state attributes: %s", source) attr_cache[source] = attributes = {} diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 61bcb8badf0..bea08722eb0 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -29,7 +29,7 @@ from homeassistant.helpers.event import ( TrackTemplateResult, async_track_template_result, ) -from homeassistant.helpers.json import ExtendedJSONEncoder +from homeassistant.helpers.json import JSON_DUMP, ExtendedJSONEncoder from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import IntegrationNotFound, async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_get_loaded_integrations @@ -241,13 +241,13 @@ def handle_get_states( # to succeed for the UI to show. response = messages.result_message(msg["id"], states) try: - connection.send_message(const.JSON_DUMP(response)) + connection.send_message(JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=const.JSON_DUMP) + find_paths_unserializable_data(response, dump=JSON_DUMP) ), ) del response @@ -256,13 +256,13 @@ def handle_get_states( serialized = [] for state in states: try: - serialized.append(const.JSON_DUMP(state)) + serialized.append(JSON_DUMP(state)) except (ValueError, TypeError): # Error is already logged above pass # We now have partially serialized states. Craft some JSON. - response2 = const.JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) + response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized)) connection.send_message(response2) @@ -315,13 +315,13 @@ def handle_subscribe_entities( # to succeed for the UI to show. response = messages.event_message(msg["id"], data) try: - connection.send_message(const.JSON_DUMP(response)) + connection.send_message(JSON_DUMP(response)) return except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(response, dump=const.JSON_DUMP) + find_paths_unserializable_data(response, dump=JSON_DUMP) ), ) del response @@ -330,14 +330,14 @@ def handle_subscribe_entities( cannot_serialize: list[str] = [] for entity_id, state_dict in add_entities.items(): try: - const.JSON_DUMP(state_dict) + JSON_DUMP(state_dict) except (ValueError, TypeError): cannot_serialize.append(entity_id) for entity_id in cannot_serialize: del add_entities[entity_id] - connection.send_message(const.JSON_DUMP(messages.event_message(msg["id"], data))) + connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data))) @decorators.websocket_command({vol.Required("type"): "get_services"}) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 0280863f83e..26c4c6f8321 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.auth.models import RefreshToken, User from homeassistant.core import Context, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, Unauthorized +from homeassistant.helpers.json import JSON_DUMP from . import const, messages @@ -56,7 +57,7 @@ class ActiveConnection: async def send_big_result(self, msg_id: int, result: Any) -> None: """Send a result message that would be expensive to JSON serialize.""" content = await self.hass.async_add_executor_job( - const.JSON_DUMP, messages.result_message(msg_id, result) + JSON_DUMP, messages.result_message(msg_id, result) ) self.send_message(content) diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index 107cf6d0270..60a00126092 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -4,12 +4,9 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable from concurrent import futures -from functools import partial -import json from typing import TYPE_CHECKING, Any, Final from homeassistant.core import HomeAssistant -from homeassistant.helpers.json import JSONEncoder if TYPE_CHECKING: from .connection import ActiveConnection # noqa: F401 @@ -53,10 +50,6 @@ SIGNAL_WEBSOCKET_DISCONNECTED: Final = "websocket_disconnected" # Data used to store the current connection list DATA_CONNECTIONS: Final = f"{DOMAIN}.connections" -JSON_DUMP: Final = partial( - json.dumps, cls=JSONEncoder, allow_nan=False, separators=(",", ":") -) - COMPRESSED_STATE_STATE = "s" COMPRESSED_STATE_ATTRIBUTES = "a" COMPRESSED_STATE_CONTEXT = "c" diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index f546ba5eec6..c3e5f6bb5f5 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.json import JSON_DUMP from homeassistant.util.json import ( find_paths_unserializable_data, format_unserializable_data, @@ -193,15 +194,15 @@ def compressed_state_dict_add(state: State) -> dict[str, Any]: def message_to_json(message: dict[str, Any]) -> str: """Serialize a websocket message to json.""" try: - return const.JSON_DUMP(message) + return JSON_DUMP(message) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize to JSON. Bad data found at %s", format_unserializable_data( - find_paths_unserializable_data(message, dump=const.JSON_DUMP) + find_paths_unserializable_data(message, dump=JSON_DUMP) ), ) - return const.JSON_DUMP( + return JSON_DUMP( error_message( message["id"], const.ERR_UNKNOWN_ERROR, "Invalid JSON in response" ) diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index eaabb002b0a..2e56698db41 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -14,6 +14,7 @@ from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout import async_timeout +import orjson from homeassistant import config_entries from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ @@ -97,6 +98,7 @@ def _async_create_clientsession( """Create a new ClientSession with kwargs, i.e. for cookies.""" clientsession = aiohttp.ClientSession( connector=_async_get_connector(hass, verify_ssl), + json_serialize=lambda x: orjson.dumps(x).decode("utf-8"), **kwargs, ) # Prevent packages accidentally overriding our default headers diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index c581e5a9361..9248c613b95 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -1,7 +1,12 @@ """Helpers to help with encoding Home Assistant objects in JSON.""" import datetime import json -from typing import Any +from pathlib import Path +from typing import Any, Final + +import orjson + +JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError) class JSONEncoder(json.JSONEncoder): @@ -22,6 +27,20 @@ class JSONEncoder(json.JSONEncoder): return json.JSONEncoder.default(self, o) +def json_encoder_default(obj: Any) -> Any: + """Convert Home Assistant objects. + + Hand other objects to the original method. + """ + if isinstance(obj, set): + return list(obj) + if hasattr(obj, "as_dict"): + return obj.as_dict() + if isinstance(obj, Path): + return obj.as_posix() + raise TypeError + + class ExtendedJSONEncoder(JSONEncoder): """JSONEncoder that supports Home Assistant objects and falls back to repr(o).""" @@ -40,3 +59,31 @@ class ExtendedJSONEncoder(JSONEncoder): return super().default(o) except TypeError: return {"__type": str(type(o)), "repr": repr(o)} + + +def json_bytes(data: Any) -> bytes: + """Dump json bytes.""" + return orjson.dumps( + data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ) + + +def json_dumps(data: Any) -> str: + """Dump json string. + + orjson supports serializing dataclasses natively which + eliminates the need to implement as_dict in many places + when the data is already in a dataclass. This works + well as long as all the data in the dataclass can also + be serialized. + + If it turns out to be a problem we can disable this + with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it + will fallback to as_dict + """ + return orjson.dumps( + data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ).decode("utf-8") + + +JSON_DUMP: Final = json_dumps diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 053beab307e..02f95254c86 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -27,6 +27,7 @@ import jinja2 from jinja2 import pass_context, pass_environment from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace +import orjson import voluptuous as vol from homeassistant.const import ( @@ -566,7 +567,7 @@ class Template: variables["value"] = value with suppress(ValueError, TypeError): - variables["value_json"] = json.loads(value) + variables["value_json"] = orjson.loads(value) try: return _render_with_context( @@ -1743,7 +1744,7 @@ def ordinal(value): def from_json(value): """Convert a JSON string to an object.""" - return json.loads(value) + return orjson.loads(value) def to_json(value, ensure_ascii=True): diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 16e508ddcda..e9599bbe3a9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,6 +20,7 @@ httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.7 +orjson==3.7.2 paho-mqtt==1.6.1 pillow==9.1.1 pip>=21.0,<22.2 diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index a681b3e210d..efbfec5e961 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -12,14 +12,13 @@ from timeit import default_timer as timer from typing import TypeVar from homeassistant import core -from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.const import EVENT_STATE_CHANGED from homeassistant.helpers.entityfilter import convert_include_exclude_filter from homeassistant.helpers.event import ( async_track_state_change, async_track_state_change_event, ) -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSON_DUMP, JSONEncoder # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs # mypy: no-warn-return-any diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index fdee7a7a90f..82ecfd34d6d 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -7,6 +7,8 @@ import json import logging from typing import Any +import orjson + from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError @@ -30,7 +32,7 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: """ try: with open(filename, encoding="utf-8") as fdesc: - return json.loads(fdesc.read()) # type: ignore[no-any-return] + return orjson.loads(fdesc.read()) # type: ignore[no-any-return] except FileNotFoundError: # This is not a fatal error _LOGGER.debug("JSON file not found: %s", filename) @@ -56,7 +58,10 @@ def save_json( Returns True on success. """ try: - json_data = json.dumps(data, indent=4, cls=encoder) + if encoder: + json_data = json.dumps(data, indent=2, cls=encoder) + else: + json_data = orjson.dumps(data, option=orjson.OPT_INDENT_2).decode("utf-8") except TypeError as error: msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data))}" _LOGGER.error(msg) diff --git a/pyproject.toml b/pyproject.toml index f17015cc9ba..165e781983e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "PyJWT==2.4.0", # PyJWT has loose dependency. We want the latest one. "cryptography==36.0.2", + "orjson==3.7.2", "pip>=21.0,<22.2", "python-slugify==4.0.1", "pyyaml==6.0", @@ -119,6 +120,7 @@ extension-pkg-allow-list = [ "av.audio.stream", "av.stream", "ciso8601", + "orjson", "cv2", ] diff --git a/requirements.txt b/requirements.txt index ba7c9e4dd13..0b3791d6eed 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,7 @@ ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 cryptography==36.0.2 +orjson==3.7.2 pip>=21.0,<22.2 python-slugify==4.0.1 pyyaml==6.0 diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index 37ebe4147c5..e802688daaf 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from homeassistant.components.energy import async_get_manager, validate +from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component @@ -408,7 +409,11 @@ async def test_validation_grid( }, ) - assert (await validate.async_validate(hass)).as_dict() == { + result = await validate.async_validate(hass) + # verify its also json serializable + JSON_DUMP(result) + + assert result.as_dict() == { "energy_sources": [ [ { diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index fdd4fbc7808..f6a2ff85d3a 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -1,5 +1,6 @@ """Tests for Home Assistant View.""" from http import HTTPStatus +import json from unittest.mock import AsyncMock, Mock from aiohttp.web_exceptions import ( @@ -34,9 +35,16 @@ async def test_invalid_json(caplog): view = HomeAssistantView() with pytest.raises(HTTPInternalServerError): - view.json(float("NaN")) + view.json(rb"\ud800") - assert str(float("NaN")) in caplog.text + assert "Unable to serialize to JSON" in caplog.text + + +async def test_nan_serialized_to_null(caplog): + """Test nan serialized to null JSON.""" + view = HomeAssistantView() + response = view.json(float("NaN")) + assert json.loads(response.body.decode("utf-8")) is None async def test_handling_unauthorized(mock_request): diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 4d3302f7c13..0f4695596fc 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -619,12 +619,15 @@ async def test_states_filters_visible(hass, hass_admin_user, websocket_client): async def test_get_states_not_allows_nan(hass, websocket_client): - """Test get_states command not allows NaN floats.""" + """Test get_states command converts NaN to None.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) hass.states.async_set("greeting.bye", "universe") await websocket_client.send_json({"id": 5, "type": "get_states"}) + bad = dict(hass.states.get("greeting.bad").as_dict()) + bad["attributes"] = dict(bad["attributes"]) + bad["attributes"]["hello"] = None msg = await websocket_client.receive_json() assert msg["id"] == 5 @@ -632,6 +635,7 @@ async def test_get_states_not_allows_nan(hass, websocket_client): assert msg["success"] assert msg["result"] == [ hass.states.get("greeting.hello").as_dict(), + bad, hass.states.get("greeting.bye").as_dict(), ] From 75cfe845e120e06d9aa9f582cddae809de344db5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 22:17:50 +0200 Subject: [PATCH 602/947] Adjust freedompro type hints (#73839) --- homeassistant/components/freedompro/fan.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/freedompro/fan.py b/homeassistant/components/freedompro/fan.py index 3513b7672e0..443a1375f24 100644 --- a/homeassistant/components/freedompro/fan.py +++ b/homeassistant/components/freedompro/fan.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +from typing import Any from pyfreedompro import put_state @@ -87,27 +88,30 @@ class FreedomproFan(CoordinatorEntity, FanEntity): await super().async_added_to_hass() self._handle_coordinator_update() - async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs): + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: """Async function to turn on the fan.""" payload = {"on": True} - payload = json.dumps(payload) await put_state( self._session, self._api_key, self.unique_id, - payload, + json.dumps(payload), ) await self.coordinator.async_request_refresh() - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Async function to turn off the fan.""" payload = {"on": False} - payload = json.dumps(payload) await put_state( self._session, self._api_key, self.unique_id, - payload, + json.dumps(payload), ) await self.coordinator.async_request_refresh() From 73c54b14d0146874c7f542e14ab3db71f267e801 Mon Sep 17 00:00:00 2001 From: Khole Date: Wed, 22 Jun 2022 21:20:47 +0100 Subject: [PATCH 603/947] Hive bump pyhiveapi version (#73846) --- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 45c2b468f23..29477bf7414 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -6,7 +6,7 @@ "models": ["HHKBridge*"] }, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.5.10"], + "requirements": ["pyhiveapi==0.5.11"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/requirements_all.txt b/requirements_all.txt index de75044f7db..b15f6c885ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1541,7 +1541,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.5.10 +pyhiveapi==0.5.11 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7d43748f6fd..9c37efee222 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.10 +pyhiveapi==0.5.11 # homeassistant.components.homematic pyhomematic==0.1.77 From ad7da9803fe77656118b3ab956915832c49fe46b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 22:37:01 +0200 Subject: [PATCH 604/947] Adjust lutron_caseta type hints (#73840) Co-authored-by: Franck Nijhof --- homeassistant/components/lutron_caseta/fan.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 44dae8324fa..46eee1bde6b 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -86,6 +86,6 @@ class LutronCasetaFan(LutronCasetaDeviceUpdatableEntity, FanEntity): await self._smartbridge.set_fan(self.device_id, named_speed) @property - def is_on(self): + def is_on(self) -> bool: """Return true if device is on.""" - return self.percentage and self.percentage > 0 + return bool(self.percentage) From 320ef550851862089a859cfb3784093a251a4380 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 22:37:25 +0200 Subject: [PATCH 605/947] Automatically onboard Elgato (#73847) --- .../components/elgato/config_flow.py | 5 ++- tests/components/elgato/conftest.py | 10 ++++++ tests/components/elgato/test_config_flow.py | 36 +++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/elgato/config_flow.py b/homeassistant/components/elgato/config_flow.py index 7abf570ba3e..9e63df0a503 100644 --- a/homeassistant/components/elgato/config_flow.py +++ b/homeassistant/components/elgato/config_flow.py @@ -6,7 +6,7 @@ from typing import Any from elgato import Elgato, ElgatoError import voluptuous as vol -from homeassistant.components import zeroconf +from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PORT from homeassistant.core import callback @@ -56,6 +56,9 @@ class ElgatoFlowHandler(ConfigFlow, domain=DOMAIN): except ElgatoError: return self.async_abort(reason="cannot_connect") + if not onboarding.async_is_onboarded(self.hass): + return self._async_create_entry() + self._set_confirm_only() return self.async_show_form( step_id="zeroconf_confirm", diff --git a/tests/components/elgato/conftest.py b/tests/components/elgato/conftest.py index efae0739c7b..b0d5415110d 100644 --- a/tests/components/elgato/conftest.py +++ b/tests/components/elgato/conftest.py @@ -37,6 +37,16 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: yield mock_setup +@pytest.fixture +def mock_onboarding() -> Generator[None, MagicMock, None]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding + + @pytest.fixture def mock_elgato_config_flow() -> Generator[None, MagicMock, None]: """Return a mocked Elgato client.""" diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index fdc0ad834d1..0e3916a005e 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -204,3 +204,39 @@ async def test_zeroconf_device_exists_abort( entries = hass.config_entries.async_entries(DOMAIN) assert entries[0].data[CONF_HOST] == "127.0.0.2" + + +async def test_zeroconf_during_onboarding( + hass: HomeAssistant, + mock_elgato_config_flow: MagicMock, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, +) -> None: + """Test the zeroconf creates an entry during onboarding.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="127.0.0.1", + addresses=["127.0.0.1"], + hostname="example.local.", + name="mock_name", + port=9123, + properties={"id": "AA:BB:CC:DD:EE:FF"}, + type="mock_type", + ), + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "CN11A1A00001" + assert result.get("data") == { + CONF_HOST: "127.0.0.1", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + CONF_PORT: 9123, + } + assert "result" in result + assert result["result"].unique_id == "CN11A1A00001" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_elgato_config_flow.info.mock_calls) == 1 + assert len(mock_onboarding.mock_calls) == 1 From ec119ae7186b4cb9e10992a3c4af9be946ebb3e2 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 22:37:36 +0200 Subject: [PATCH 606/947] Automatically onboard WLED (#73853) --- homeassistant/components/wled/config_flow.py | 4 +-- tests/components/wled/conftest.py | 20 ++++++++--- tests/components/wled/test_config_flow.py | 38 ++++++++++++++++++-- 3 files changed, 53 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/wled/config_flow.py b/homeassistant/components/wled/config_flow.py index 99630f5781c..1dda368a2b0 100644 --- a/homeassistant/components/wled/config_flow.py +++ b/homeassistant/components/wled/config_flow.py @@ -6,7 +6,7 @@ from typing import Any import voluptuous as vol from wled import WLED, Device, WLEDConnectionError -from homeassistant.components import zeroconf +from homeassistant.components import onboarding, zeroconf from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_HOST, CONF_MAC from homeassistant.core import callback @@ -97,7 +97,7 @@ class WLEDFlowHandler(ConfigFlow, domain=DOMAIN): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Handle a flow initiated by zeroconf.""" - if user_input is not None: + if user_input is not None or not onboarding.async_is_onboarded(self.hass): return self.async_create_entry( title=self.discovered_device.info.name, data={ diff --git a/tests/components/wled/conftest.py b/tests/components/wled/conftest.py index f89d92aaa16..d0b5b24a8fb 100644 --- a/tests/components/wled/conftest.py +++ b/tests/components/wled/conftest.py @@ -1,7 +1,7 @@ """Fixtures for WLED integration tests.""" from collections.abc import Generator import json -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from wled import Device as WLEDDevice @@ -25,10 +25,22 @@ def mock_config_entry() -> MockConfigEntry: @pytest.fixture -def mock_setup_entry() -> Generator[None, None, None]: +def mock_setup_entry() -> Generator[None, AsyncMock, None]: """Mock setting up a config entry.""" - with patch("homeassistant.components.wled.async_setup_entry", return_value=True): - yield + with patch( + "homeassistant.components.wled.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup + + +@pytest.fixture +def mock_onboarding() -> Generator[None, MagicMock, None]: + """Mock that Home Assistant is currently onboarding.""" + with patch( + "homeassistant.components.onboarding.async_is_onboarded", + return_value=False, + ) as mock_onboarding: + yield mock_onboarding @pytest.fixture diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index c23f35534b8..e1cf08069da 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -1,5 +1,5 @@ """Tests for the WLED config flow.""" -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock from wled import WLEDConnectionError @@ -18,7 +18,7 @@ from tests.common import MockConfigEntry async def test_full_user_flow_implementation( - hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: None + hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: AsyncMock ) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -43,7 +43,7 @@ async def test_full_user_flow_implementation( async def test_full_zeroconf_flow_implementation( - hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: None + hass: HomeAssistant, mock_wled_config_flow: MagicMock, mock_setup_entry: AsyncMock ) -> None: """Test the full manual user flow from start to finish.""" result = await hass.config_entries.flow.async_init( @@ -84,6 +84,38 @@ async def test_full_zeroconf_flow_implementation( assert result2["result"].unique_id == "aabbccddeeff" +async def test_zeroconf_during_onboarding( + hass: HomeAssistant, + mock_wled_config_flow: MagicMock, + mock_setup_entry: AsyncMock, + mock_onboarding: MagicMock, +) -> None: + """Test we create a config entry when discovered during onboarding.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host="192.168.1.123", + addresses=["192.168.1.123"], + hostname="example.local.", + name="mock_name", + port=None, + properties={CONF_MAC: "aabbccddeeff"}, + type="mock_type", + ), + ) + + assert result.get("title") == "WLED RGB Light" + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + + assert result.get("data") == {CONF_HOST: "192.168.1.123"} + assert "result" in result + assert result["result"].unique_id == "aabbccddeeff" + + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_onboarding.mock_calls) == 1 + + async def test_connection_error( hass: HomeAssistant, mock_wled_config_flow: MagicMock ) -> None: From a8a033681f4fd3d8d1d50e9f7e6a93c032fd0f8c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 22 Jun 2022 22:37:49 +0200 Subject: [PATCH 607/947] Automatically onboard DiscoveryFlows (#73841) --- homeassistant/helpers/config_entry_flow.py | 4 ++-- tests/helpers/test_config_entry_flow.py | 24 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index fddc5c82725..1190e947eba 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -6,7 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, cast from homeassistant import config_entries -from homeassistant.components import dhcp, mqtt, ssdp, zeroconf +from homeassistant.components import dhcp, mqtt, onboarding, ssdp, zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -52,7 +52,7 @@ class DiscoveryFlowHandler(config_entries.ConfigFlow, Generic[_R]): self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm setup.""" - if user_input is None: + if user_input is None and onboarding.async_is_onboarded(self.hass): self._set_confirm_only() return self.async_show_form(step_id="confirm") diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py index 78b28a8ef10..979aa8bf088 100644 --- a/tests/helpers/test_config_entry_flow.py +++ b/tests/helpers/test_config_entry_flow.py @@ -139,6 +139,30 @@ async def test_discovery_confirmation(hass, discovery_flow_conf, source): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY +@pytest.mark.parametrize( + "source", + [ + config_entries.SOURCE_DISCOVERY, + config_entries.SOURCE_MQTT, + config_entries.SOURCE_SSDP, + config_entries.SOURCE_ZEROCONF, + config_entries.SOURCE_DHCP, + ], +) +async def test_discovery_during_onboarding(hass, discovery_flow_conf, source): + """Test we create config entry via discovery during onboarding.""" + flow = config_entries.HANDLERS["test"]() + flow.hass = hass + flow.context = {"source": source} + + with patch( + "homeassistant.components.onboarding.async_is_onboarded", return_value=False + ): + result = await getattr(flow, f"async_step_{source}")({}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + async def test_multiple_discoveries(hass, discovery_flow_conf): """Test we only create one instance for multiple discoveries.""" mock_entity_platform(hass, "config_flow.test", None) From 5c5fd746fd42f7e33271c3dbb0f12488297b7d0c Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 22 Jun 2022 16:38:45 -0400 Subject: [PATCH 608/947] Add digital loggers as a Belkin supported brand (#72515) --- homeassistant/components/wemo/manifest.json | 5 ++++- homeassistant/components/wemo/wemo_device.py | 15 +++++++++++++-- tests/components/wemo/conftest.py | 15 +++++++++++++++ tests/components/wemo/test_wemo_device.py | 9 +++++++++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index b324ba060ea..5486a192787 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -14,5 +14,8 @@ }, "codeowners": ["@esev"], "iot_class": "local_push", - "loggers": ["pywemo"] + "loggers": ["pywemo"], + "supported_brands": { + "digital_loggers": "Digital Loggers" + } } diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 1f3e07881c8..826df24a108 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -9,6 +9,8 @@ from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONFIGURATION_URL, + ATTR_IDENTIFIERS, CONF_DEVICE_ID, CONF_NAME, CONF_PARAMS, @@ -42,7 +44,7 @@ class DeviceCoordinator(DataUpdateCoordinator): self.hass = hass self.wemo = wemo self.device_id = device_id - self.device_info = _device_info(wemo) + self.device_info = _create_device_info(wemo) self.supports_long_press = wemo.supports_long_press() self.update_lock = asyncio.Lock() @@ -124,6 +126,15 @@ class DeviceCoordinator(DataUpdateCoordinator): raise UpdateFailed("WeMo update failed") from err +def _create_device_info(wemo: WeMoDevice) -> DeviceInfo: + """Create device information. Modify if special device.""" + _dev_info = _device_info(wemo) + if wemo.model_name == "DLI emulated Belkin Socket": + _dev_info[ATTR_CONFIGURATION_URL] = f"http://{wemo.host}" + _dev_info[ATTR_IDENTIFIERS] = {(DOMAIN, wemo.serialnumber[:-1])} + return _dev_info + + def _device_info(wemo: WeMoDevice) -> DeviceInfo: return DeviceInfo( connections={(CONNECTION_UPNP, wemo.udn)}, @@ -144,7 +155,7 @@ async def async_register_device( device_registry = async_get_device_registry(hass) entry = device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, **_device_info(wemo) + config_entry_id=config_entry.entry_id, **_create_device_info(wemo) ) device = DeviceCoordinator(hass, wemo, entry.id) diff --git a/tests/components/wemo/conftest.py b/tests/components/wemo/conftest.py index 1a5998c1f94..6dc7b1e5d2c 100644 --- a/tests/components/wemo/conftest.py +++ b/tests/components/wemo/conftest.py @@ -97,6 +97,15 @@ def pywemo_device_fixture(pywemo_registry, pywemo_model): yield pywemo_device +@pytest.fixture(name="pywemo_dli_device") +def pywemo_dli_device_fixture(pywemo_registry, pywemo_model): + """Fixture for Digital Loggers emulated instances.""" + with create_pywemo_device(pywemo_registry, pywemo_model) as pywemo_dli_device: + pywemo_dli_device.model_name = "DLI emulated Belkin Socket" + pywemo_dli_device.serialnumber = "1234567891" + yield pywemo_dli_device + + @pytest.fixture(name="wemo_entity_suffix") def wemo_entity_suffix_fixture(): """Fixture to select a specific entity for wemo_entity.""" @@ -129,3 +138,9 @@ async def async_create_wemo_entity(hass, pywemo_device, wemo_entity_suffix): async def async_wemo_entity_fixture(hass, pywemo_device, wemo_entity_suffix): """Fixture for a Wemo entity in hass.""" return await async_create_wemo_entity(hass, pywemo_device, wemo_entity_suffix) + + +@pytest.fixture(name="wemo_dli_entity") +async def async_wemo_dli_entity_fixture(hass, pywemo_dli_device, wemo_entity_suffix): + """Fixture for a Wemo entity in hass.""" + return await async_create_wemo_entity(hass, pywemo_dli_device, wemo_entity_suffix) diff --git a/tests/components/wemo/test_wemo_device.py b/tests/components/wemo/test_wemo_device.py index 9bd3367aeee..a16efb173ae 100644 --- a/tests/components/wemo/test_wemo_device.py +++ b/tests/components/wemo/test_wemo_device.py @@ -168,6 +168,15 @@ async def test_device_info(hass, wemo_entity): assert device_entries[0].sw_version == MOCK_FIRMWARE_VERSION +async def test_dli_device_info(hass, wemo_dli_entity): + """Verify the DeviceInfo data for Digital Loggers emulated wemo device.""" + dr = device_registry.async_get(hass) + device_entries = list(dr.devices.values()) + + assert device_entries[0].configuration_url == "http://127.0.0.1" + assert device_entries[0].identifiers == {(DOMAIN, "123456789")} + + class TestInsight: """Tests specific to the WeMo Insight device.""" From 01a9367281ba71ce7560cd3b51463fdca13475e6 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 22 Jun 2022 16:57:21 -0400 Subject: [PATCH 609/947] UniFi Protect cleanup and enable unadopted devices (#73860) --- .../components/unifiprotect/__init__.py | 11 ++- .../components/unifiprotect/binary_sensor.py | 14 ++-- .../components/unifiprotect/button.py | 2 +- .../components/unifiprotect/camera.py | 11 ++- .../components/unifiprotect/config_flow.py | 8 +- homeassistant/components/unifiprotect/data.py | 4 +- .../components/unifiprotect/entity.py | 13 +-- .../components/unifiprotect/light.py | 6 +- homeassistant/components/unifiprotect/lock.py | 21 ++--- .../components/unifiprotect/media_player.py | 26 +++--- .../components/unifiprotect/migrate.py | 6 +- .../components/unifiprotect/models.py | 8 +- .../components/unifiprotect/select.py | 9 +- .../components/unifiprotect/sensor.py | 17 ++-- .../components/unifiprotect/switch.py | 9 +- .../components/unifiprotect/utils.py | 34 +------- tests/components/unifiprotect/conftest.py | 2 +- .../unifiprotect/test_binary_sensor.py | 15 +++- tests/components/unifiprotect/test_camera.py | 15 +++- tests/components/unifiprotect/test_init.py | 24 ++++++ tests/components/unifiprotect/test_light.py | 10 ++- tests/components/unifiprotect/test_lock.py | 10 ++- .../unifiprotect/test_media_player.py | 13 ++- tests/components/unifiprotect/test_migrate.py | 84 +++++++++++++++++++ 24 files changed, 258 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/unifiprotect/__init__.py b/homeassistant/components/unifiprotect/__init__.py index c83221b0ccf..40214b60766 100644 --- a/homeassistant/components/unifiprotect/__init__.py +++ b/homeassistant/components/unifiprotect/__init__.py @@ -61,6 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: subscribed_models=DEVICES_FOR_SUBSCRIBE, override_connection_host=entry.options.get(CONF_OVERRIDE_CHOST, False), ignore_stats=not entry.options.get(CONF_ALL_UPDATES, False), + ignore_unadopted=False, ) _LOGGER.debug("Connect to UniFi Protect") data_service = ProtectData(hass, protect, SCAN_INTERVAL, entry) @@ -127,7 +128,9 @@ async def async_remove_config_entry_device( } api = async_ufp_instance_for_config_entry_ids(hass, {config_entry.entry_id}) assert api is not None - return api.bootstrap.nvr.mac not in unifi_macs and not any( - device.mac in unifi_macs - for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT) - ) + if api.bootstrap.nvr.mac in unifi_macs: + return False + for device in async_get_devices(api.bootstrap, DEVICES_THAT_ADOPT): + if device.is_adopted_by_us and device.mac in unifi_macs: + return False + return True diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 68c395faaf7..eb4b2024233 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -35,7 +35,6 @@ from .entity import ( async_all_device_entities, ) from .models import PermRequired, ProtectRequiredKeysMixin -from .utils import async_get_is_highfps _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" @@ -103,7 +102,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( icon="mdi:video-high-definition", entity_category=EntityCategory.DIAGNOSTIC, ufp_required_field="feature_flags.has_highfps", - ufp_value_fn=async_get_is_highfps, + ufp_value="is_high_fps_enabled", ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( @@ -386,12 +385,15 @@ def _async_motion_entities( ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] for device in data.api.bootstrap.cameras.values(): + if not device.is_adopted_by_us: + continue + for description in MOTION_SENSORS: entities.append(ProtectEventBinarySensor(data, device, description)) _LOGGER.debug( "Adding binary sensor entity %s for %s", description.name, - device.name, + device.display_name, ) return entities @@ -468,9 +470,9 @@ class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): slot = self._disk.slot self._attr_available = False - if self.device.system_info.ustorage is None: - return - + # should not be possible since it would require user to + # _downgrade_ to make ustorage disppear + assert self.device.system_info.ustorage is not None for disk in self.device.system_info.ustorage.disks: if disk.slot == slot: self._disk = disk diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 01714868261..d647cdac64a 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -103,7 +103,7 @@ class ProtectButton(ProtectDeviceEntity, ButtonEntity): ) -> None: """Initialize an UniFi camera.""" super().__init__(data, device, description) - self._attr_name = f"{self.device.name} {self.entity_description.name}" + self._attr_name = f"{self.device.display_name} {self.entity_description.name}" async def async_press(self) -> None: """Press the button.""" diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index c78e8e2f77a..a84346a8384 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -36,9 +36,14 @@ def get_camera_channels( ) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]: """Get all the camera channels.""" for camera in protect.bootstrap.cameras.values(): + if not camera.is_adopted_by_us: + continue + if not camera.channels: _LOGGER.warning( - "Camera does not have any channels: %s (id: %s)", camera.name, camera.id + "Camera does not have any channels: %s (id: %s)", + camera.display_name, + camera.id, ) continue @@ -116,10 +121,10 @@ class ProtectCamera(ProtectDeviceEntity, Camera): if self._secure: self._attr_unique_id = f"{self.device.mac}_{self.channel.id}" - self._attr_name = f"{self.device.name} {self.channel.name}" + self._attr_name = f"{self.device.display_name} {self.channel.name}" else: self._attr_unique_id = f"{self.device.mac}_{self.channel.id}_insecure" - self._attr_name = f"{self.device.name} {self.channel.name} Insecure" + self._attr_name = f"{self.device.display_name} {self.channel.name} Insecure" # only the default (first) channel is enabled by default self._attr_entity_registry_enabled_default = is_default and secure diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 9cd15c4e3c2..8e114c4f38b 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -175,9 +175,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_VERIFY_SSL] = False nvr_data, errors = await self._async_get_nvr_data(user_input) if nvr_data and not errors: - return self._async_create_entry( - nvr_data.name or nvr_data.type, user_input - ) + return self._async_create_entry(nvr_data.display_name, user_input) placeholders = { "name": discovery_info["hostname"] @@ -323,9 +321,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self.async_set_unique_id(nvr_data.mac) self._abort_if_unique_id_configured() - return self._async_create_entry( - nvr_data.name or nvr_data.type, user_input - ) + return self._async_create_entry(nvr_data.display_name, user_input) user_input = user_input or {} return self.async_show_form( diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 78a3c5ebac8..4a20e816ce2 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -120,7 +120,9 @@ class ProtectData: @callback def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: # removed packets are not processed yet - if message.new_obj is None: # pragma: no cover + if message.new_obj is None or not getattr( + message.new_obj, "is_adopted_by_us", True + ): return if message.new_obj.model in DEVICES_WITH_ENTITIES: diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 9bf3c8de7a0..65734569de2 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -44,6 +44,9 @@ def _async_device_entities( entities: list[ProtectDeviceEntity] = [] for device in data.get_by_types({model_type}): + if not device.is_adopted_by_us: + continue + assert isinstance(device, (Camera, Light, Sensor, Viewer, Doorlock, Chime)) for description in descs: if description.ufp_perm is not None: @@ -69,7 +72,7 @@ def _async_device_entities( "Adding %s entity %s for %s", klass.__name__, description.name, - device.name, + device.display_name, ) return entities @@ -126,12 +129,12 @@ class ProtectDeviceEntity(Entity): if description is None: self._attr_unique_id = f"{self.device.mac}" - self._attr_name = f"{self.device.name}" + self._attr_name = f"{self.device.display_name}" else: self.entity_description = description self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" - self._attr_name = f"{self.device.name} {name.title()}" + self._attr_name = f"{self.device.display_name} {name.title()}" self._attr_attribution = DEFAULT_ATTRIBUTION self._async_set_device_info() @@ -147,7 +150,7 @@ class ProtectDeviceEntity(Entity): @callback def _async_set_device_info(self) -> None: self._attr_device_info = DeviceInfo( - name=self.device.name, + name=self.device.display_name, manufacturer=DEFAULT_BRAND, model=self.device.type, via_device=(DOMAIN, self.data.api.bootstrap.nvr.mac), @@ -214,7 +217,7 @@ class ProtectNVREntity(ProtectDeviceEntity): connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, identifiers={(DOMAIN, self.device.mac)}, manufacturer=DEFAULT_BRAND, - name=self.device.name, + name=self.device.display_name, model=self.device.type, sw_version=str(self.device.version), configuration_url=self.device.api.base_url, diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index b200fb85e03..bd64905a289 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -27,12 +27,12 @@ async def async_setup_entry( data: ProtectData = hass.data[DOMAIN][entry.entry_id] entities = [] for device in data.api.bootstrap.lights.values(): + if not device.is_adopted_by_us: + continue + if device.can_write(data.api.bootstrap.auth_user): entities.append(ProtectLight(data, device)) - if not entities: - return - async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 9cef3e19e36..7258dc5f952 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -26,13 +26,14 @@ async def async_setup_entry( """Set up locks on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - ProtectLock( - data, - lock, - ) - for lock in data.api.bootstrap.doorlocks.values() - ) + entities = [] + for device in data.api.bootstrap.doorlocks.values(): + if not device.is_adopted_by_us: + continue + + entities.append(ProtectLock(data, device)) + + async_add_entities(entities) class ProtectLock(ProtectDeviceEntity, LockEntity): @@ -53,7 +54,7 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): LockEntityDescription(key="lock"), ) - self._attr_name = f"{self.device.name} Lock" + self._attr_name = f"{self.device.display_name} Lock" @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -82,10 +83,10 @@ class ProtectLock(ProtectDeviceEntity, LockEntity): async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - _LOGGER.debug("Unlocking %s", self.device.name) + _LOGGER.debug("Unlocking %s", self.device.display_name) return await self.device.open_lock() async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" - _LOGGER.debug("Locking %s", self.device.name) + _LOGGER.debug("Locking %s", self.device.display_name) return await self.device.close_lock() diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index c4fb1dbe15b..b0391c9d860 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -40,16 +40,14 @@ async def async_setup_entry( """Discover cameras with speakers on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] - async_add_entities( - [ - ProtectMediaPlayer( - data, - camera, - ) - for camera in data.api.bootstrap.cameras.values() - if camera.feature_flags.has_speaker - ] - ) + entities = [] + for device in data.api.bootstrap.cameras.values(): + if not device.is_adopted_by_us or not device.feature_flags.has_speaker: + continue + + entities.append(ProtectMediaPlayer(data, device)) + + async_add_entities(entities) class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): @@ -79,7 +77,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): ), ) - self._attr_name = f"{self.device.name} Speaker" + self._attr_name = f"{self.device.display_name} Speaker" self._attr_media_content_type = MEDIA_TYPE_MUSIC @callback @@ -108,7 +106,7 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): self.device.talkback_stream is not None and self.device.talkback_stream.is_running ): - _LOGGER.debug("Stopping playback for %s Speaker", self.device.name) + _LOGGER.debug("Stopping playback for %s Speaker", self.device.display_name) await self.device.stop_audio() self._async_updated_event(self.device) @@ -126,7 +124,9 @@ class ProtectMediaPlayer(ProtectDeviceEntity, MediaPlayerEntity): if media_type != MEDIA_TYPE_MUSIC: raise HomeAssistantError("Only music media type is supported") - _LOGGER.debug("Playing Media %s for %s Speaker", media_id, self.device.name) + _LOGGER.debug( + "Playing Media %s for %s Speaker", media_id, self.device.display_name + ) await self.async_media_stop() try: await self.device.play_audio(media_id, blocking=False) diff --git a/homeassistant/components/unifiprotect/migrate.py b/homeassistant/components/unifiprotect/migrate.py index 3273bd80408..893ca3e458a 100644 --- a/homeassistant/components/unifiprotect/migrate.py +++ b/homeassistant/components/unifiprotect/migrate.py @@ -14,8 +14,6 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import entity_registry as er -from .utils import async_device_by_id - _LOGGER = logging.getLogger(__name__) @@ -69,7 +67,7 @@ async def async_migrate_buttons( bootstrap = await async_get_bootstrap(protect) count = 0 for button in to_migrate: - device = async_device_by_id(bootstrap, button.unique_id) + device = bootstrap.get_device_from_id(button.unique_id) if device is None: continue @@ -130,7 +128,7 @@ async def async_migrate_device_ids( if parts[0] == bootstrap.nvr.id: device: NVR | ProtectAdoptableDeviceModel | None = bootstrap.nvr else: - device = async_device_by_id(bootstrap, parts[0]) + device = bootstrap.get_device_from_id(parts[0]) if device is None: continue diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 81ad8438dd7..dee2006b429 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -5,9 +5,9 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass from enum import Enum import logging -from typing import Any, Generic, TypeVar +from typing import Any, Generic, TypeVar, Union -from pyunifiprotect.data import ProtectDeviceModel +from pyunifiprotect.data import NVR, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription @@ -15,7 +15,7 @@ from .utils import get_nested_attr _LOGGER = logging.getLogger(__name__) -T = TypeVar("T", bound=ProtectDeviceModel) +T = TypeVar("T", bound=Union[ProtectAdoptableDeviceModel, NVR]) class PermRequired(int, Enum): @@ -63,7 +63,7 @@ class ProtectSetableKeysMixin(ProtectRequiredKeysMixin[T]): async def ufp_set(self, obj: T, value: Any) -> None: """Set value for UniFi Protect device.""" - _LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.name) + _LOGGER.debug("Setting %s to %s for %s", self.name, value, obj.display_name) if self.ufp_set_method is not None: await getattr(obj, self.ufp_set_method)(value) elif self.ufp_set_method_fn is not None: diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 15377a37b27..17bdfa390a6 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -143,7 +143,7 @@ def _get_doorbell_options(api: ProtectApiClient) -> list[dict[str, Any]]: def _get_paired_camera_options(api: ProtectApiClient) -> list[dict[str, Any]]: options = [{"id": TYPE_EMPTY_VALUE, "name": "Not Paired"}] for camera in api.bootstrap.cameras.values(): - options.append({"id": camera.id, "name": camera.name or camera.type}) + options.append({"id": camera.id, "name": camera.display_name or camera.type}) return options @@ -353,7 +353,7 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): ) -> None: """Initialize the unifi protect select entity.""" super().__init__(data, device, description) - self._attr_name = f"{self.device.name} {self.entity_description.name}" + self._attr_name = f"{self.device.display_name} {self.entity_description.name}" self._async_set_options() @callback @@ -421,7 +421,10 @@ class ProtectSelects(ProtectDeviceEntity, SelectEntity): timeout_msg = f" with timeout of {duration} minute(s)" _LOGGER.debug( - 'Setting message for %s to "%s"%s', self.device.name, message, timeout_msg + 'Setting message for %s to "%s"%s', + self.device.display_name, + message, + timeout_msg, ) await self.device.set_lcd_text( DoorbellMessageType.CUSTOM_MESSAGE, message, reset_at=reset_at diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 57fe0d5aabd..012d52ae215 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -108,7 +108,7 @@ def _get_alarm_sound(obj: Sensor) -> str: ALL_DEVICES_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( - ProtectSensorEntityDescription[ProtectDeviceModel]( + ProtectSensorEntityDescription( key="uptime", name="Uptime", icon="mdi:clock", @@ -353,7 +353,7 @@ SENSE_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( name="Paired Camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="camera.name", + ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, ), ) @@ -373,13 +373,13 @@ DOORLOCK_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( name="Paired Camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="camera.name", + ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, ), ) NVR_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( - ProtectSensorEntityDescription[ProtectDeviceModel]( + ProtectSensorEntityDescription( key="uptime", name="Uptime", icon="mdi:clock", @@ -541,7 +541,7 @@ LIGHT_SENSORS: tuple[ProtectSensorEntityDescription, ...] = ( name="Paired Camera", icon="mdi:cctv", entity_category=EntityCategory.DIAGNOSTIC, - ufp_value="camera.name", + ufp_value="camera.display_name", ufp_perm=PermRequired.NO_WRITE, ), ) @@ -618,11 +618,14 @@ def _async_motion_entities( entities: list[ProtectDeviceEntity] = [] for device in data.api.bootstrap.cameras.values(): for description in MOTION_TRIP_SENSORS: + if not device.is_adopted_by_us: + continue + entities.append(ProtectDeviceSensor(data, device, description)) _LOGGER.debug( "Adding trip sensor entity %s for %s", description.name, - device.name, + device.display_name, ) if not device.feature_flags.has_smart_detect: @@ -633,7 +636,7 @@ def _async_motion_entities( _LOGGER.debug( "Adding sensor entity %s for %s", description.name, - device.name, + device.display_name, ) return entities diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index efa91b3a6ba..8b3661ce324 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -22,7 +22,6 @@ from .const import DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T -from .utils import async_get_is_highfps _LOGGER = logging.getLogger(__name__) @@ -81,7 +80,7 @@ CAMERA_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = ( icon="mdi:video-high-definition", entity_category=EntityCategory.CONFIG, ufp_required_field="feature_flags.has_highfps", - ufp_value_fn=async_get_is_highfps, + ufp_value="is_high_fps_enabled", ufp_set_method_fn=_set_highfps, ufp_perm=PermRequired.WRITE, ), @@ -328,7 +327,7 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): ) -> None: """Initialize an UniFi Protect Switch.""" super().__init__(data, device, description) - self._attr_name = f"{self.device.name} {self.entity_description.name}" + self._attr_name = f"{self.device.display_name} {self.entity_description.name}" self._switch_type = self.entity_description.key if not isinstance(self.device, Camera): @@ -362,7 +361,9 @@ class ProtectSwitch(ProtectDeviceEntity, SwitchEntity): if self._switch_type == _KEY_PRIVACY_MODE: assert isinstance(self.device, Camera) - _LOGGER.debug("Setting Privacy Mode to false for %s", self.device.name) + _LOGGER.debug( + "Setting Privacy Mode to false for %s", self.device.display_name + ) await self.device.set_privacy( False, self._previous_mic_level, self._previous_record_mode ) diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index b2eb8c1ca65..72baab334f3 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -9,18 +9,15 @@ from typing import Any from pyunifiprotect.data import ( Bootstrap, - Camera, Light, LightModeEnableType, LightModeType, ProtectAdoptableDeviceModel, - ProtectDeviceModel, - VideoMode, ) from homeassistant.core import HomeAssistant, callback -from .const import DEVICES_THAT_ADOPT, ModelType +from .const import ModelType def get_nested_attr(obj: Any, attr: str) -> Any: @@ -79,30 +76,10 @@ def async_get_devices_by_type( return devices -@callback -def async_device_by_id( - bootstrap: Bootstrap, - device_id: str, - device_type: ModelType | None = None, -) -> ProtectAdoptableDeviceModel | None: - """Get devices by type.""" - - device_types = DEVICES_THAT_ADOPT - if device_type is not None: - device_types = {device_type} - - device = None - for model in device_types: - device = async_get_devices_by_type(bootstrap, model).get(device_id) - if device is not None: - break - return device - - @callback def async_get_devices( bootstrap: Bootstrap, model_type: Iterable[ModelType] -) -> Generator[ProtectDeviceModel, None, None]: +) -> Generator[ProtectAdoptableDeviceModel, None, None]: """Return all device by type.""" return ( device @@ -111,13 +88,6 @@ def async_get_devices( ) -@callback -def async_get_is_highfps(obj: Camera) -> bool: - """Return if camera has High FPS mode enabled.""" - - return bool(obj.video_mode == VideoMode.HIGH_FPS) - - @callback def async_get_light_motion_current(obj: Light) -> str: """Get light motion mode for Flood Light.""" diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index adc69cc8bf9..68945ac0988 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -284,7 +284,7 @@ def ids_from_device_description( def generate_random_ids() -> tuple[str, str]: """Generate random IDs for device.""" - return random_hex(24).upper(), random_hex(12).upper() + return random_hex(24).lower(), random_hex(12).upper() def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None: diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 8e868b4af21..da9969ad868 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -36,6 +36,7 @@ from .conftest import ( MockEntityFixture, assert_entity_counts, ids_from_device_description, + regenerate_device_ids, reset_objects, ) @@ -65,11 +66,22 @@ async def camera_fixture( camera_obj.last_ring = now - timedelta(hours=1) camera_obj.is_dark = False camera_obj.is_motion_detected = False + regenerate_device_ids(camera_obj) + + no_camera_obj = mock_camera.copy() + no_camera_obj._api = mock_entry.api + no_camera_obj.channels[0]._api = mock_entry.api + no_camera_obj.channels[1]._api = mock_entry.api + no_camera_obj.channels[2]._api = mock_entry.api + no_camera_obj.name = "Unadopted Camera" + no_camera_obj.is_adopted = False + regenerate_device_ids(no_camera_obj) reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, + no_camera_obj.id: no_camera_obj, } await hass.config_entries.async_setup(mock_entry.entry.entry_id) @@ -135,6 +147,7 @@ async def camera_none_fixture( reset_objects(mock_entry.api.bootstrap) mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] + mock_entry.api.bootstrap.nvr.system_info.ustorage = None mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, } @@ -142,7 +155,7 @@ async def camera_none_fixture( await hass.config_entries.async_setup(mock_entry.entry.entry_id) await hass.async_block_till_done() - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) yield camera_obj diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 03b52c7e52e..66da8e8ec04 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -529,13 +529,22 @@ async def test_camera_ws_update( new_camera = camera[0].copy() new_camera.is_recording = True - mock_msg = Mock() - mock_msg.changed_data = {} - mock_msg.new_obj = new_camera + no_camera = camera[0].copy() + no_camera.is_adopted = False new_bootstrap.cameras = {new_camera.id: new_camera} mock_entry.api.bootstrap = new_bootstrap + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = new_camera mock_entry.api.ws_subscription(mock_msg) + + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = no_camera + mock_entry.api.ws_subscription(mock_msg) + await hass.async_block_till_done() state = hass.states.get(camera[1]) diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index d36183ba135..5c06eedc4c9 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -242,3 +242,27 @@ async def test_device_remove_devices( await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) is True ) + + +async def test_device_remove_devices_nvr( + hass: HomeAssistant, + mock_entry: MockEntityFixture, + hass_ws_client: Callable[ + [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] + ], +) -> None: + """Test we can only remove a NVR device that no longer exists.""" + assert await async_setup_component(hass, "config", {}) + + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + entry_id = mock_entry.entry.entry_id + + device_registry = dr.async_get(hass) + + live_device_entry = list(device_registry.devices.values())[0] + assert ( + await remove_device(await hass_ws_client(hass), live_device_entry.id, entry_id) + is False + ) diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index c4f324f30fd..3bcca436911 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -20,7 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts +from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids @pytest.fixture(name="light") @@ -36,9 +36,17 @@ async def light_fixture( light_obj._api = mock_entry.api light_obj.name = "Test Light" light_obj.is_light_on = False + regenerate_device_ids(light_obj) + + no_light_obj = mock_light.copy() + no_light_obj._api = mock_entry.api + no_light_obj.name = "Unadopted Light" + no_light_obj.is_adopted = False + regenerate_device_ids(no_light_obj) mock_entry.api.bootstrap.lights = { light_obj.id: light_obj, + no_light_obj.id: no_light_obj, } await hass.config_entries.async_setup(mock_entry.entry.entry_id) diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 36b3d140871..3ebfd2de22f 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -23,7 +23,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts +from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids @pytest.fixture(name="doorlock") @@ -39,9 +39,17 @@ async def doorlock_fixture( lock_obj._api = mock_entry.api lock_obj.name = "Test Lock" lock_obj.lock_status = LockStatusType.OPEN + regenerate_device_ids(lock_obj) + + no_lock_obj = mock_doorlock.copy() + no_lock_obj._api = mock_entry.api + no_lock_obj.name = "Unadopted Lock" + no_lock_obj.is_adopted = False + regenerate_device_ids(no_lock_obj) mock_entry.api.bootstrap.doorlocks = { lock_obj.id: lock_obj, + no_lock_obj.id: no_lock_obj, } await hass.config_entries.async_setup(mock_entry.entry.entry_id) diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index a4cbc9e8d22..c18a407eadb 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -26,7 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts +from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids @pytest.fixture(name="camera") @@ -45,9 +45,20 @@ async def camera_fixture( camera_obj.channels[2]._api = mock_entry.api camera_obj.name = "Test Camera" camera_obj.feature_flags.has_speaker = True + regenerate_device_ids(camera_obj) + + no_camera_obj = mock_camera.copy() + no_camera_obj._api = mock_entry.api + no_camera_obj.channels[0]._api = mock_entry.api + no_camera_obj.channels[1]._api = mock_entry.api + no_camera_obj.channels[2]._api = mock_entry.api + no_camera_obj.name = "Unadopted Camera" + no_camera_obj.is_adopted = False + regenerate_device_ids(no_camera_obj) mock_entry.api.bootstrap.cameras = { camera_obj.id: camera_obj, + no_camera_obj.id: no_camera_obj, } await hass.config_entries.async_setup(mock_entry.entry.entry_id) diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index b62aa9d7757..206c85e3654 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -5,6 +5,8 @@ from __future__ import annotations from unittest.mock import AsyncMock from pyunifiprotect.data import Light +from pyunifiprotect.data.bootstrap import ProtectDeviceRef +from pyunifiprotect.exceptions import NvrError from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState @@ -34,6 +36,10 @@ async def test_migrate_reboot_button( light1.id: light1, light2.id: light2, } + mock_entry.api.bootstrap.id_lookup = { + light1.id: ProtectDeviceRef(id=light1.id, model=light1.model), + light2.id: ProtectDeviceRef(id=light2.id, model=light2.model), + } mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) registry = er.async_get(hass) @@ -77,6 +83,41 @@ async def test_migrate_reboot_button( assert light.unique_id == f"{light2.mac}_reboot" +async def test_migrate_nvr_mac( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Test migrating unique ID of NVR to use MAC address.""" + + mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) + nvr = mock_entry.api.bootstrap.nvr + regenerate_device_ids(nvr) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.SENSOR, + DOMAIN, + f"{nvr.id}_storage_utilization", + config_entry=mock_entry.entry, + ) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.LOADED + assert mock_entry.api.update.called + assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + + assert registry.async_get(f"{Platform.SENSOR}.{DOMAIN}_storage_utilization") is None + assert ( + registry.async_get(f"{Platform.SENSOR}.{DOMAIN}_storage_utilization_2") is None + ) + sensor = registry.async_get( + f"{Platform.SENSOR}.{DOMAIN}_{nvr.id}_storage_utilization" + ) + assert sensor is not None + assert sensor.unique_id == f"{nvr.mac}_storage_utilization" + + async def test_migrate_reboot_button_no_device( hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light ): @@ -132,6 +173,9 @@ async def test_migrate_reboot_button_fail( mock_entry.api.bootstrap.lights = { light1.id: light1, } + mock_entry.api.bootstrap.id_lookup = { + light1.id: ProtectDeviceRef(id=light1.id, model=light1.model), + } mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) registry = er.async_get(hass) @@ -175,6 +219,9 @@ async def test_migrate_device_mac_button_fail( mock_entry.api.bootstrap.lights = { light1.id: light1, } + mock_entry.api.bootstrap.id_lookup = { + light1.id: ProtectDeviceRef(id=light1.id, model=light1.model) + } mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) registry = er.async_get(hass) @@ -203,3 +250,40 @@ async def test_migrate_device_mac_button_fail( light = registry.async_get(f"{Platform.BUTTON}.test_light_1") assert light is not None assert light.unique_id == f"{light1.id}_reboot" + + +async def test_migrate_device_mac_bootstrap_fail( + hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light +): + """Test migrating with a network error.""" + + light1 = mock_light.copy() + light1._api = mock_entry.api + light1.name = "Test Light 1" + regenerate_device_ids(light1) + + mock_entry.api.bootstrap.lights = { + light1.id: light1, + } + mock_entry.api.get_bootstrap = AsyncMock(side_effect=NvrError) + + registry = er.async_get(hass) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light1.id}_reboot", + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + registry.async_get_or_create( + Platform.BUTTON, + DOMAIN, + f"{light1.mac}_reboot", + config_entry=mock_entry.entry, + suggested_object_id=light1.name, + ) + + await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.async_block_till_done() + + assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY From aef69f87f4b46545054d7ea023eb1e5324921670 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 22 Jun 2022 23:02:34 +0200 Subject: [PATCH 610/947] More enums in deCONZ Alarm Control Panel (#73800) --- .../components/deconz/alarm_control_panel.py | 41 ++++++++----------- .../components/deconz/deconz_event.py | 15 +++---- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../deconz/test_alarm_control_panel.py | 37 +++++++---------- tests/components/deconz/test_deconz_event.py | 29 ++++++------- 7 files changed, 52 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/deconz/alarm_control_panel.py b/homeassistant/components/deconz/alarm_control_panel.py index 90c34da0f12..bf0f39b75d0 100644 --- a/homeassistant/components/deconz/alarm_control_panel.py +++ b/homeassistant/components/deconz/alarm_control_panel.py @@ -1,20 +1,11 @@ """Support for deCONZ alarm control panel devices.""" from __future__ import annotations -from pydeconz.interfaces.alarm_systems import ArmAction +from pydeconz.models.alarm_system import AlarmSystemArmAction from pydeconz.models.event import EventType from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY, - ANCILLARY_CONTROL_ARMING_AWAY, - ANCILLARY_CONTROL_ARMING_NIGHT, - ANCILLARY_CONTROL_ARMING_STAY, - ANCILLARY_CONTROL_DISARMED, - ANCILLARY_CONTROL_ENTRY_DELAY, - ANCILLARY_CONTROL_EXIT_DELAY, - ANCILLARY_CONTROL_IN_ALARM, AncillaryControl, + AncillaryControlPanel, ) from homeassistant.components.alarm_control_panel import ( @@ -40,16 +31,16 @@ from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry DECONZ_TO_ALARM_STATE = { - ANCILLARY_CONTROL_ARMED_AWAY: STATE_ALARM_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY: STATE_ALARM_ARMED_HOME, - ANCILLARY_CONTROL_ARMING_AWAY: STATE_ALARM_ARMING, - ANCILLARY_CONTROL_ARMING_NIGHT: STATE_ALARM_ARMING, - ANCILLARY_CONTROL_ARMING_STAY: STATE_ALARM_ARMING, - ANCILLARY_CONTROL_DISARMED: STATE_ALARM_DISARMED, - ANCILLARY_CONTROL_ENTRY_DELAY: STATE_ALARM_PENDING, - ANCILLARY_CONTROL_EXIT_DELAY: STATE_ALARM_PENDING, - ANCILLARY_CONTROL_IN_ALARM: STATE_ALARM_TRIGGERED, + AncillaryControlPanel.ARMED_AWAY: STATE_ALARM_ARMED_AWAY, + AncillaryControlPanel.ARMED_NIGHT: STATE_ALARM_ARMED_NIGHT, + AncillaryControlPanel.ARMED_STAY: STATE_ALARM_ARMED_HOME, + AncillaryControlPanel.ARMING_AWAY: STATE_ALARM_ARMING, + AncillaryControlPanel.ARMING_NIGHT: STATE_ALARM_ARMING, + AncillaryControlPanel.ARMING_STAY: STATE_ALARM_ARMING, + AncillaryControlPanel.DISARMED: STATE_ALARM_DISARMED, + AncillaryControlPanel.ENTRY_DELAY: STATE_ALARM_PENDING, + AncillaryControlPanel.EXIT_DELAY: STATE_ALARM_PENDING, + AncillaryControlPanel.IN_ALARM: STATE_ALARM_TRIGGERED, } @@ -133,26 +124,26 @@ class DeconzAlarmControlPanel(DeconzDevice, AlarmControlPanelEntity): """Send arm away command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.AWAY, code + self.alarm_system_id, AlarmSystemArmAction.AWAY, code ) async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.STAY, code + self.alarm_system_id, AlarmSystemArmAction.STAY, code ) async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.NIGHT, code + self.alarm_system_id, AlarmSystemArmAction.NIGHT, code ) async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code: await self.gateway.api.alarmsystems.arm( - self.alarm_system_id, ArmAction.DISARM, code + self.alarm_system_id, AlarmSystemArmAction.DISARM, code ) diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py index fa53ef1b5bc..270e66bf91d 100644 --- a/homeassistant/components/deconz/deconz_event.py +++ b/homeassistant/components/deconz/deconz_event.py @@ -6,11 +6,8 @@ from typing import Any from pydeconz.models.event import EventType from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_EMERGENCY, - ANCILLARY_CONTROL_FIRE, - ANCILLARY_CONTROL_INVALID_CODE, - ANCILLARY_CONTROL_PANIC, AncillaryControl, + AncillaryControlAction, ) from pydeconz.models.sensor.switch import Switch @@ -33,10 +30,10 @@ CONF_DECONZ_EVENT = "deconz_event" CONF_DECONZ_ALARM_EVENT = "deconz_alarm_event" SUPPORTED_DECONZ_ALARM_EVENTS = { - ANCILLARY_CONTROL_EMERGENCY, - ANCILLARY_CONTROL_FIRE, - ANCILLARY_CONTROL_INVALID_CODE, - ANCILLARY_CONTROL_PANIC, + AncillaryControlAction.EMERGENCY, + AncillaryControlAction.FIRE, + AncillaryControlAction.INVALID_CODE, + AncillaryControlAction.PANIC, } @@ -183,7 +180,7 @@ class DeconzAlarmEvent(DeconzEventBase): CONF_ID: self.event_id, CONF_UNIQUE_ID: self.serial, CONF_DEVICE_ID: self.device_id, - CONF_EVENT: self._device.action, + CONF_EVENT: self._device.action.value, } self.gateway.hass.bus.async_fire(CONF_DECONZ_ALARM_EVENT, data) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 2306d088c48..ce10845a5b1 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,7 +3,7 @@ "name": "deCONZ", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", - "requirements": ["pydeconz==93"], + "requirements": ["pydeconz==94"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index b15f6c885ef..75dd9dfad8a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1441,7 +1441,7 @@ pydaikin==2.7.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==93 +pydeconz==94 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9c37efee222..8f523f9e9b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.7.0 # homeassistant.components.deconz -pydeconz==93 +pydeconz==94 # homeassistant.components.dexcom pydexcom==0.2.3 diff --git a/tests/components/deconz/test_alarm_control_panel.py b/tests/components/deconz/test_alarm_control_panel.py index 5c9c192a0aa..213ce3b2e08 100644 --- a/tests/components/deconz/test_alarm_control_panel.py +++ b/tests/components/deconz/test_alarm_control_panel.py @@ -2,19 +2,7 @@ from unittest.mock import patch -from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_ARMED_NIGHT, - ANCILLARY_CONTROL_ARMED_STAY, - ANCILLARY_CONTROL_ARMING_AWAY, - ANCILLARY_CONTROL_ARMING_NIGHT, - ANCILLARY_CONTROL_ARMING_STAY, - ANCILLARY_CONTROL_DISARMED, - ANCILLARY_CONTROL_ENTRY_DELAY, - ANCILLARY_CONTROL_EXIT_DELAY, - ANCILLARY_CONTROL_IN_ALARM, - ANCILLARY_CONTROL_NOT_READY, -) +from pydeconz.models.sensor.ancillary_control import AncillaryControlPanel from homeassistant.components.alarm_control_panel import ( DOMAIN as ALARM_CONTROL_PANEL_DOMAIN, @@ -123,7 +111,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"panel": AncillaryControlPanel.ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -137,7 +125,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_ARMED_NIGHT}, + "state": {"panel": AncillaryControlPanel.ARMED_NIGHT}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -153,7 +141,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_ARMED_STAY}, + "state": {"panel": AncillaryControlPanel.ARMED_STAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -167,7 +155,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_DISARMED}, + "state": {"panel": AncillaryControlPanel.DISARMED}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -177,9 +165,9 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): # Event signals alarm control panel arming for arming_event in { - ANCILLARY_CONTROL_ARMING_AWAY, - ANCILLARY_CONTROL_ARMING_NIGHT, - ANCILLARY_CONTROL_ARMING_STAY, + AncillaryControlPanel.ARMING_AWAY, + AncillaryControlPanel.ARMING_NIGHT, + AncillaryControlPanel.ARMING_STAY, }: event_changed_sensor = { @@ -196,7 +184,10 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): # Event signals alarm control panel pending - for pending_event in {ANCILLARY_CONTROL_ENTRY_DELAY, ANCILLARY_CONTROL_EXIT_DELAY}: + for pending_event in { + AncillaryControlPanel.ENTRY_DELAY, + AncillaryControlPanel.EXIT_DELAY, + }: event_changed_sensor = { "t": "event", @@ -219,7 +210,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_IN_ALARM}, + "state": {"panel": AncillaryControlPanel.IN_ALARM}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -233,7 +224,7 @@ async def test_alarm_control_panel(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "0", - "state": {"panel": ANCILLARY_CONTROL_NOT_READY}, + "state": {"panel": AncillaryControlPanel.NOT_READY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py index e697edd5a9a..c326892aef2 100644 --- a/tests/components/deconz/test_deconz_event.py +++ b/tests/components/deconz/test_deconz_event.py @@ -3,11 +3,8 @@ from unittest.mock import patch from pydeconz.models.sensor.ancillary_control import ( - ANCILLARY_CONTROL_ARMED_AWAY, - ANCILLARY_CONTROL_EMERGENCY, - ANCILLARY_CONTROL_FIRE, - ANCILLARY_CONTROL_INVALID_CODE, - ANCILLARY_CONTROL_PANIC, + AncillaryControlAction, + AncillaryControlPanel, ) from homeassistant.components.deconz.const import DOMAIN as DECONZ_DOMAIN @@ -286,7 +283,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_EMERGENCY}, + "state": {"action": AncillaryControlAction.EMERGENCY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -300,7 +297,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_EMERGENCY, + CONF_EVENT: AncillaryControlAction.EMERGENCY.value, } # Fire event @@ -310,7 +307,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_FIRE}, + "state": {"action": AncillaryControlAction.FIRE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -324,7 +321,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_FIRE, + CONF_EVENT: AncillaryControlAction.FIRE.value, } # Invalid code event @@ -334,7 +331,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_INVALID_CODE}, + "state": {"action": AncillaryControlAction.INVALID_CODE}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -348,7 +345,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_INVALID_CODE, + CONF_EVENT: AncillaryControlAction.INVALID_CODE.value, } # Panic event @@ -358,7 +355,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_PANIC}, + "state": {"action": AncillaryControlAction.PANIC}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -372,7 +369,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): CONF_ID: "keypad", CONF_UNIQUE_ID: "00:00:00:00:00:00:00:01", CONF_DEVICE_ID: device.id, - CONF_EVENT: ANCILLARY_CONTROL_PANIC, + CONF_EVENT: AncillaryControlAction.PANIC.value, } # Only care for changes to specific action events @@ -382,7 +379,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"action": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"action": AncillaryControlAction.ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -396,7 +393,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): "e": "changed", "r": "sensors", "id": "1", - "state": {"panel": ANCILLARY_CONTROL_ARMED_AWAY}, + "state": {"panel": AncillaryControlPanel.ARMED_AWAY}, } await mock_deconz_websocket(data=event_changed_sensor) await hass.async_block_till_done() @@ -415,7 +412,7 @@ async def test_deconz_alarm_events(hass, aioclient_mock, mock_deconz_websocket): assert len(hass.states.async_all()) == 0 -async def test_deconz_events_bad_unique_id(hass, aioclient_mock, mock_deconz_websocket): +async def test_deconz_events_bad_unique_id(hass, aioclient_mock): """Verify no devices are created if unique id is bad or missing.""" data = { "sensors": { From b17d4ac65cde9a27ff6032d70b148792e5eba8df Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 22 Jun 2022 23:51:40 +0200 Subject: [PATCH 611/947] Remove replicated async definitions in pylint plugin (#73823) Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- pylint/plugins/hass_enforce_type_hints.py | 95 ++++++----------------- 1 file changed, 22 insertions(+), 73 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 307510c6621..a241252c9a9 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -28,6 +28,15 @@ class TypeHintMatch: kwargs_type: str | None = None """kwargs_type is for the special case `**kwargs`""" check_return_type_inheritance: bool = False + has_async_counterpart: bool = False + + def need_to_check_function(self, node: nodes.FunctionDef) -> bool: + """Confirm if function should be checked.""" + return ( + self.function_name == node.name + or self.has_async_counterpart + and node.name == f"async_{self.function_name}" + ) @dataclass @@ -60,14 +69,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type="bool", - ), - TypeHintMatch( - function_name="async_setup", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type="bool", + has_async_counterpart=True, ), TypeHintMatch( function_name="async_setup_entry", @@ -121,16 +123,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 3: "DiscoveryInfoType | None", }, return_type=None, - ), - TypeHintMatch( - function_name="async_setup_platform", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - 2: "AddEntitiesCallback", - 3: "DiscoveryInfoType | None", - }, - return_type=None, + has_async_counterpart=True, ), TypeHintMatch( function_name="async_setup_entry", @@ -314,14 +307,7 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { 1: "ConfigType", }, return_type=["DeviceScanner", "DeviceScanner | None"], - ), - TypeHintMatch( - function_name="async_get_scanner", - arg_types={ - 0: "HomeAssistant", - 1: "ConfigType", - }, - return_type=["DeviceScanner", "DeviceScanner | None"], + has_async_counterpart=True, ), ], "device_trigger": [ @@ -498,31 +484,19 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="set_percentage", arg_types={1: "int"}, return_type=None, - ), - TypeHintMatch( - function_name="async_set_percentage", - arg_types={1: "int"}, - return_type=None, + has_async_counterpart=True, ), TypeHintMatch( function_name="set_preset_mode", arg_types={1: "str"}, return_type=None, - ), - TypeHintMatch( - function_name="async_set_preset_mode", - arg_types={1: "str"}, - return_type=None, + has_async_counterpart=True, ), TypeHintMatch( function_name="set_direction", arg_types={1: "str"}, return_type=None, - ), - TypeHintMatch( - function_name="async_set_direction", - arg_types={1: "str"}, - return_type=None, + has_async_counterpart=True, ), TypeHintMatch( function_name="turn_on", @@ -532,25 +506,13 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, kwargs_type="Any", return_type=None, - ), - TypeHintMatch( - function_name="async_turn_on", - named_arg_types={ - "percentage": "int | None", - "preset_mode": "str | None", - }, - kwargs_type="Any", - return_type=None, + has_async_counterpart=True, ), TypeHintMatch( function_name="oscillate", arg_types={1: "bool"}, return_type=None, - ), - TypeHintMatch( - function_name="async_oscillate", - arg_types={1: "bool"}, - return_type=None, + has_async_counterpart=True, ), ], ), @@ -587,31 +549,19 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="lock", kwargs_type="Any", return_type=None, - ), - TypeHintMatch( - function_name="async_lock", - kwargs_type="Any", - return_type=None, + has_async_counterpart=True, ), TypeHintMatch( function_name="unlock", kwargs_type="Any", return_type=None, - ), - TypeHintMatch( - function_name="async_unlock", - kwargs_type="Any", - return_type=None, + has_async_counterpart=True, ), TypeHintMatch( function_name="open", kwargs_type="Any", return_type=None, - ), - TypeHintMatch( - function_name="async_open", - kwargs_type="Any", - return_type=None, + has_async_counterpart=True, ), ], ), @@ -831,14 +781,13 @@ class HassTypeHintChecker(BaseChecker): # type: ignore[misc] ) -> None: for match in matches: for function_node in node.mymethods(): - function_name: str | None = function_node.name - if match.function_name == function_name: + if match.need_to_check_function(function_node): self._check_function(function_node, match) def visit_functiondef(self, node: nodes.FunctionDef) -> None: """Called when a FunctionDef node is visited.""" for match in self._function_matchers: - if node.name != match.function_name or node.is_method(): + if not match.need_to_check_function(node) or node.is_method(): continue self._check_function(node, match) From b0f4b3030f5200fee343c8b78853defc7acd460c Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 22 Jun 2022 17:58:10 -0500 Subject: [PATCH 612/947] Extend timeouts for Spotify and Plex playback on Sonos (#73803) --- .../components/sonos/media_player.py | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 938a651c34d..0f766f33e6f 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -67,6 +67,7 @@ from .speaker import SonosMedia, SonosSpeaker _LOGGER = logging.getLogger(__name__) +LONG_SERVICE_TIMEOUT = 30.0 VOLUME_INCREMENT = 2 REPEAT_TO_SONOS = { @@ -580,36 +581,44 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): if result.shuffle: self.set_shuffle(True) if enqueue == MediaPlayerEnqueue.ADD: - plex_plugin.add_to_queue(result.media) + plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT) elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 - new_pos = plex_plugin.add_to_queue(result.media, position=pos) + new_pos = plex_plugin.add_to_queue( + result.media, position=pos, timeout=LONG_SERVICE_TIMEOUT + ) if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() - plex_plugin.add_to_queue(result.media) + plex_plugin.add_to_queue(result.media, timeout=LONG_SERVICE_TIMEOUT) soco.play_from_queue(0) return share_link = self.coordinator.share_link if share_link.is_share_link(media_id): if enqueue == MediaPlayerEnqueue.ADD: - share_link.add_share_link_to_queue(media_id) + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT + ) elif enqueue in ( MediaPlayerEnqueue.NEXT, MediaPlayerEnqueue.PLAY, ): pos = (self.media.queue_position or 0) + 1 - new_pos = share_link.add_share_link_to_queue(media_id, position=pos) + new_pos = share_link.add_share_link_to_queue( + media_id, position=pos, timeout=LONG_SERVICE_TIMEOUT + ) if enqueue == MediaPlayerEnqueue.PLAY: soco.play_from_queue(new_pos - 1) elif enqueue == MediaPlayerEnqueue.REPLACE: soco.clear_queue() - share_link.add_share_link_to_queue(media_id) + share_link.add_share_link_to_queue( + media_id, timeout=LONG_SERVICE_TIMEOUT + ) soco.play_from_queue(0) elif media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK): # If media ID is a relative URL, we serve it from HA. From fe54db6eb9ac57af96d6dde86fa94349c8a6ea14 Mon Sep 17 00:00:00 2001 From: Alex Groleau Date: Wed, 22 Jun 2022 18:58:36 -0400 Subject: [PATCH 613/947] Improve Tuya integration fan controller support (#73062) --- homeassistant/components/tuya/const.py | 1 + homeassistant/components/tuya/fan.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index e7340040658..5486e94786d 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -223,6 +223,7 @@ class DPCode(StrEnum): FAN_SPEED = "fan_speed" FAN_SPEED_ENUM = "fan_speed_enum" # Speed mode FAN_SPEED_PERCENT = "fan_speed_percent" # Stepless speed + FAN_SWITCH = "fan_switch" FAN_MODE = "fan_mode" FAN_VERTICAL = "fan_vertical" # Vertical swing flap angle FAR_DETECTION = "far_detection" diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 36ed4c3c58e..021745f4c81 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -74,7 +74,7 @@ class TuyaFanEntity(TuyaEntity, FanEntity): super().__init__(device, device_manager) self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.SWITCH), prefer_function=True + (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True ) self._attr_preset_modes = [] @@ -177,7 +177,6 @@ class TuyaFanEntity(TuyaEntity, FanEntity): "value": int(self._speed.remap_value_from(percentage, 1, 100)), } ) - return if percentage is not None and self._speeds is not None: commands.append( From e855529f73705d58a5533a268239268cb14e9f74 Mon Sep 17 00:00:00 2001 From: Waldemar Tomme Date: Thu, 23 Jun 2022 01:08:51 +0200 Subject: [PATCH 614/947] Fix fints integration (#69041) --- homeassistant/components/fints/manifest.json | 4 +-- homeassistant/components/fints/sensor.py | 30 ++++++++++---------- requirements_all.txt | 2 +- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json index ede1025a6db..11d673a2837 100644 --- a/homeassistant/components/fints/manifest.json +++ b/homeassistant/components/fints/manifest.json @@ -2,8 +2,8 @@ "domain": "fints", "name": "FinTS", "documentation": "https://www.home-assistant.io/integrations/fints", - "requirements": ["fints==1.0.1"], + "requirements": ["fints==3.1.0"], "codeowners": [], - "iot_class": "local_push", + "iot_class": "cloud_polling", "loggers": ["fints", "mt_940", "sepaxml"] } diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 4b6cb336bde..2e2ccd8e6b6 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -7,7 +7,6 @@ import logging from typing import Any from fints.client import FinTS3PinTanClient -from fints.dialog import FinTSDialogError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -128,6 +127,9 @@ class FinTsClient: As the fints library is stateless, there is not benefit in caching the client objects. If that ever changes, consider caching the client object and also think about potential concurrency problems. + + Note: As of version 2, the fints library is not stateless anymore. + This should be considered when reworking this integration. """ return FinTS3PinTanClient( @@ -140,24 +142,22 @@ class FinTsClient: def detect_accounts(self): """Identify the accounts of the bank.""" + bank = self.client + accounts = bank.get_sepa_accounts() + account_types = { + x["iban"]: x["type"] + for x in bank.get_information()["accounts"] + if x["iban"] is not None + } + balance_accounts = [] holdings_accounts = [] - for account in self.client.get_sepa_accounts(): - try: - self.client.get_balance(account) + for account in accounts: + account_type = account_types[account.iban] + if 1 <= account_type <= 9: # 1-9 is balance account balance_accounts.append(account) - except IndexError: - # account is not a balance account. - pass - except FinTSDialogError: - # account is not a balance account. - pass - try: - self.client.get_holdings(account) + elif 30 <= account_type <= 39: # 30-39 is holdings account holdings_accounts.append(account) - except FinTSDialogError: - # account is not a holdings account. - pass return balance_accounts, holdings_accounts diff --git a/requirements_all.txt b/requirements_all.txt index 75dd9dfad8a..c40ecd7dc6d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -638,7 +638,7 @@ feedparser==6.0.2 fiblary3==0.1.8 # homeassistant.components.fints -fints==1.0.1 +fints==3.1.0 # homeassistant.components.fitbit fitbit==0.3.1 From 3cd18ba38f1891929f6e31b537e3fac13791ae28 Mon Sep 17 00:00:00 2001 From: Liam <101684827+SkiingIsFun123@users.noreply.github.com> Date: Wed, 22 Jun 2022 16:41:22 -0700 Subject: [PATCH 615/947] Update CODE_OF_CONDUCT.md (#73468) --- CODE_OF_CONDUCT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index f9b1ea79314..09828047616 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -123,7 +123,7 @@ enforcement ladder][mozilla]. ## Adoption -This Code of Conduct was first adopted January 21st, 2017 and announced in +This Code of Conduct was first adopted on January 21st, 2017, and announced in [this][coc-blog] blog post and has been updated on May 25th, 2020 to version 2.0 of the [Contributor Covenant][homepage] as announced in [this][coc2-blog] blog post. From 3ce5b05aa558171a594e295e7da849d35bd8edd3 Mon Sep 17 00:00:00 2001 From: Christian Rodriguez Date: Wed, 22 Jun 2022 20:59:59 -0300 Subject: [PATCH 616/947] Add bypassed custom attribute to NX584ZoneSensor (#71767) --- homeassistant/components/nx584/binary_sensor.py | 5 ++++- tests/components/nx584/test_binary_sensor.py | 17 +++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index cbd1796b768..6fdea44f836 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -116,7 +116,10 @@ class NX584ZoneSensor(BinarySensorEntity): @property def extra_state_attributes(self): """Return the state attributes.""" - return {"zone_number": self._zone["number"]} + return { + "zone_number": self._zone["number"], + "bypassed": self._zone.get("bypassed", False), + } class NX584Watcher(threading.Thread): diff --git a/tests/components/nx584/test_binary_sensor.py b/tests/components/nx584/test_binary_sensor.py index 83f8a49c091..290567345ca 100644 --- a/tests/components/nx584/test_binary_sensor.py +++ b/tests/components/nx584/test_binary_sensor.py @@ -153,11 +153,28 @@ def test_nx584_zone_sensor_normal(): assert not sensor.should_poll assert sensor.is_on assert sensor.extra_state_attributes["zone_number"] == 1 + assert not sensor.extra_state_attributes["bypassed"] zone["state"] = False assert not sensor.is_on +def test_nx584_zone_sensor_bypassed(): + """Test for the NX584 zone sensor.""" + zone = {"number": 1, "name": "foo", "state": True, "bypassed": True} + sensor = nx584.NX584ZoneSensor(zone, "motion") + assert sensor.name == "foo" + assert not sensor.should_poll + assert sensor.is_on + assert sensor.extra_state_attributes["zone_number"] == 1 + assert sensor.extra_state_attributes["bypassed"] + + zone["state"] = False + zone["bypassed"] = False + assert not sensor.is_on + assert not sensor.extra_state_attributes["bypassed"] + + @mock.patch.object(nx584.NX584ZoneSensor, "schedule_update_ha_state") def test_nx584_watcher_process_zone_event(mock_update): """Test the processing of zone events.""" From 33c263d09bdc35de22eb5cb3128dc3176903d7a4 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Thu, 23 Jun 2022 00:20:13 +0000 Subject: [PATCH 617/947] [ci skip] Translation update --- .../accuweather/translations/sv.json | 11 +++++++++ .../components/aemet/translations/sv.json | 11 +++++++++ .../components/airly/translations/sv.json | 1 + .../components/airnow/translations/sv.json | 11 +++++++++ .../components/airvisual/translations/sv.json | 5 ++++ .../aladdin_connect/translations/sv.json | 15 ++++++++++++ .../components/ambee/translations/sv.json | 16 +++++++++++++ .../components/awair/translations/sv.json | 11 +++++++++ .../components/baf/translations/sv.json | 11 +++++++++ .../binary_sensor/translations/sv.json | 4 ++-- .../components/bond/translations/sv.json | 11 +++++++++ .../components/bsblan/translations/sv.json | 1 + .../components/coinbase/translations/sv.json | 9 +++++++ .../components/control4/translations/sv.json | 3 +++ .../components/daikin/translations/sv.json | 1 + .../components/deluge/translations/sv.json | 1 + .../components/doorbird/translations/sv.json | 1 + .../eight_sleep/translations/sv.json | 3 +++ .../components/elkm1/translations/sv.json | 1 + .../components/epson/translations/sv.json | 7 ++++++ .../flick_electric/translations/sv.json | 3 +++ .../components/flo/translations/sv.json | 1 + .../components/foscam/translations/sv.json | 1 + .../freedompro/translations/sv.json | 14 +++++++++++ .../components/generic/translations/sv.json | 2 ++ .../geocaching/translations/sv.json | 21 ++++++++++++++++ .../components/geofency/translations/sv.json | 3 +++ .../geonetnz_volcano/translations/sv.json | 3 +++ .../components/gogogate2/translations/sv.json | 3 +++ .../components/google/translations/de.json | 1 + .../components/google/translations/id.json | 1 + .../components/google/translations/sv.json | 8 +++++++ .../google_travel_time/translations/sv.json | 1 + .../components/group/translations/sv.json | 14 ++++++++++- .../components/habitica/translations/sv.json | 11 +++++++++ .../components/heos/translations/sv.json | 3 +++ .../here_travel_time/translations/sv.json | 18 ++++++++++++++ .../components/hlk_sw16/translations/sv.json | 11 +++++++++ .../humidifier/translations/sv.json | 5 ++++ .../hvv_departures/translations/sv.json | 3 +++ .../components/iaqualink/translations/sv.json | 3 +++ .../components/iqvia/translations/sv.json | 3 +++ .../components/kmtronic/translations/sv.json | 1 + .../components/kodi/translations/sv.json | 5 ++++ .../components/laundrify/translations/sv.json | 12 ++++++++++ .../components/meater/translations/sv.json | 1 + .../components/metoffice/translations/sv.json | 11 +++++++++ .../components/mjpeg/translations/sv.json | 3 +++ .../motion_blinds/translations/sv.json | 14 +++++++++++ .../components/nam/translations/sv.json | 3 ++- .../components/nest/translations/de.json | 1 + .../components/nest/translations/id.json | 5 ++++ .../components/nest/translations/sv.json | 1 + .../components/nuki/translations/sv.json | 11 +++++++++ .../components/octoprint/translations/sv.json | 1 + .../components/onewire/translations/sv.json | 1 + .../components/onvif/translations/sv.json | 1 + .../opengarage/translations/sv.json | 11 +++++++++ .../overkiz/translations/sensor.ca.json | 5 ++++ .../overkiz/translations/sensor.de.json | 5 ++++ .../overkiz/translations/sensor.et.json | 5 ++++ .../overkiz/translations/sensor.fr.json | 5 ++++ .../overkiz/translations/sensor.id.json | 4 ++++ .../overkiz/translations/sensor.pt-BR.json | 5 ++++ .../overkiz/translations/sensor.sv.json | 9 +++++++ .../overkiz/translations/sensor.zh-Hant.json | 5 ++++ .../panasonic_viera/translations/sv.json | 5 ++++ .../components/pi_hole/translations/sv.json | 17 +++++++++++++ .../components/plugwise/translations/sv.json | 5 ++++ .../components/prosegur/translations/sv.json | 1 + .../components/pvoutput/translations/sv.json | 16 +++++++++++++ .../components/qnap_qsw/translations/sv.json | 3 +++ .../radiotherm/translations/sv.json | 18 ++++++++++++++ .../components/rfxtrx/translations/sv.json | 3 +++ .../ruckus_unleashed/translations/sv.json | 1 + .../components/scrape/translations/sv.json | 8 ++++++- .../components/season/translations/sv.json | 7 ++++++ .../sensibo/translations/sensor.sv.json | 7 ++++++ .../components/sensibo/translations/sv.json | 16 +++++++++++++ .../components/sia/translations/sv.json | 7 ++++++ .../components/skybell/translations/sv.json | 21 ++++++++++++++++ .../components/slack/translations/sv.json | 9 +++++++ .../components/solaredge/translations/sv.json | 3 +++ .../squeezebox/translations/sv.json | 5 ++++ .../system_bridge/translations/sv.json | 16 +++++++++++++ .../components/tailscale/translations/sv.json | 16 +++++++++++++ .../tankerkoenig/translations/sv.json | 19 +++++++++++++++ .../components/tautulli/translations/sv.json | 5 ++++ .../components/tibber/translations/sv.json | 11 +++++++++ .../tomorrowio/translations/sv.json | 11 +++++++++ .../trafikverket_ferry/translations/sv.json | 19 +++++++++++++++ .../trafikverket_train/translations/sv.json | 16 +++++++++++++ .../translations/sv.json | 11 +++++++++ .../transmission/translations/id.json | 10 +++++++- .../transmission/translations/sv.json | 9 ++++++- .../tuya/translations/select.sv.json | 17 +++++++++++++ .../ukraine_alarm/translations/sv.json | 8 +++++++ .../uptimerobot/translations/sv.json | 16 +++++++++++++ .../components/vesync/translations/sv.json | 3 +++ .../components/vicare/translations/sv.json | 1 + .../vlc_telnet/translations/sv.json | 11 +++++++++ .../water_heater/translations/sv.json | 3 ++- .../components/whirlpool/translations/sv.json | 3 +++ .../wled/translations/select.sv.json | 7 ++++++ .../components/yolink/translations/sv.json | 24 +++++++++++++++++++ .../components/zwave_js/translations/sv.json | 5 ++++ 106 files changed, 777 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/accuweather/translations/sv.json create mode 100644 homeassistant/components/aemet/translations/sv.json create mode 100644 homeassistant/components/airnow/translations/sv.json create mode 100644 homeassistant/components/ambee/translations/sv.json create mode 100644 homeassistant/components/awair/translations/sv.json create mode 100644 homeassistant/components/baf/translations/sv.json create mode 100644 homeassistant/components/bond/translations/sv.json create mode 100644 homeassistant/components/epson/translations/sv.json create mode 100644 homeassistant/components/freedompro/translations/sv.json create mode 100644 homeassistant/components/geocaching/translations/sv.json create mode 100644 homeassistant/components/google/translations/sv.json create mode 100644 homeassistant/components/habitica/translations/sv.json create mode 100644 homeassistant/components/here_travel_time/translations/sv.json create mode 100644 homeassistant/components/hlk_sw16/translations/sv.json create mode 100644 homeassistant/components/laundrify/translations/sv.json create mode 100644 homeassistant/components/metoffice/translations/sv.json create mode 100644 homeassistant/components/motion_blinds/translations/sv.json create mode 100644 homeassistant/components/nuki/translations/sv.json create mode 100644 homeassistant/components/opengarage/translations/sv.json create mode 100644 homeassistant/components/overkiz/translations/sensor.sv.json create mode 100644 homeassistant/components/pi_hole/translations/sv.json create mode 100644 homeassistant/components/pvoutput/translations/sv.json create mode 100644 homeassistant/components/radiotherm/translations/sv.json create mode 100644 homeassistant/components/season/translations/sv.json create mode 100644 homeassistant/components/sensibo/translations/sensor.sv.json create mode 100644 homeassistant/components/sensibo/translations/sv.json create mode 100644 homeassistant/components/sia/translations/sv.json create mode 100644 homeassistant/components/skybell/translations/sv.json create mode 100644 homeassistant/components/system_bridge/translations/sv.json create mode 100644 homeassistant/components/tailscale/translations/sv.json create mode 100644 homeassistant/components/tankerkoenig/translations/sv.json create mode 100644 homeassistant/components/tibber/translations/sv.json create mode 100644 homeassistant/components/tomorrowio/translations/sv.json create mode 100644 homeassistant/components/trafikverket_ferry/translations/sv.json create mode 100644 homeassistant/components/trafikverket_train/translations/sv.json create mode 100644 homeassistant/components/trafikverket_weatherstation/translations/sv.json create mode 100644 homeassistant/components/tuya/translations/select.sv.json create mode 100644 homeassistant/components/ukraine_alarm/translations/sv.json create mode 100644 homeassistant/components/uptimerobot/translations/sv.json create mode 100644 homeassistant/components/vlc_telnet/translations/sv.json create mode 100644 homeassistant/components/wled/translations/select.sv.json create mode 100644 homeassistant/components/yolink/translations/sv.json diff --git a/homeassistant/components/accuweather/translations/sv.json b/homeassistant/components/accuweather/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/accuweather/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/aemet/translations/sv.json b/homeassistant/components/aemet/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/aemet/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airly/translations/sv.json b/homeassistant/components/airly/translations/sv.json index d182115230b..ac976224924 100644 --- a/homeassistant/components/airly/translations/sv.json +++ b/homeassistant/components/airly/translations/sv.json @@ -4,6 +4,7 @@ "already_configured": "Airly-integrationen f\u00f6r dessa koordinater \u00e4r redan konfigurerad." }, "error": { + "invalid_api_key": "Ogiltig API-nyckel", "wrong_location": "Inga Airly m\u00e4tstationer i detta omr\u00e5de." }, "step": { diff --git a/homeassistant/components/airnow/translations/sv.json b/homeassistant/components/airnow/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/airnow/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/translations/sv.json b/homeassistant/components/airvisual/translations/sv.json index 6a33c0393d9..5273668aa12 100644 --- a/homeassistant/components/airvisual/translations/sv.json +++ b/homeassistant/components/airvisual/translations/sv.json @@ -5,6 +5,11 @@ "invalid_api_key": "Ogiltig API-nyckel" }, "step": { + "geography_by_name": { + "data": { + "api_key": "API-nyckel" + } + }, "node_pro": { "data": { "ip_address": "Enhets IP-adress / v\u00e4rdnamn", diff --git a/homeassistant/components/aladdin_connect/translations/sv.json b/homeassistant/components/aladdin_connect/translations/sv.json index 23c825f256f..867d5d1c5c7 100644 --- a/homeassistant/components/aladdin_connect/translations/sv.json +++ b/homeassistant/components/aladdin_connect/translations/sv.json @@ -1,8 +1,23 @@ { "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering" + }, "step": { + "reauth_confirm": { + "data": { + "password": "L\u00f6senord" + }, + "title": "\u00c5terautenticera integration" + }, "user": { "data": { + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/ambee/translations/sv.json b/homeassistant/components/ambee/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/ambee/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/sv.json b/homeassistant/components/awair/translations/sv.json new file mode 100644 index 00000000000..1fda5b91f5a --- /dev/null +++ b/homeassistant/components/awair/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/baf/translations/sv.json b/homeassistant/components/baf/translations/sv.json new file mode 100644 index 00000000000..e3270d4036a --- /dev/null +++ b/homeassistant/components/baf/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/translations/sv.json b/homeassistant/components/binary_sensor/translations/sv.json index 58f97e77977..904ecd8fddc 100644 --- a/homeassistant/components/binary_sensor/translations/sv.json +++ b/homeassistant/components/binary_sensor/translations/sv.json @@ -121,7 +121,7 @@ "on": "\u00d6ppen" }, "gas": { - "off": "Klart", + "off": "Rensa", "on": "Detekterad" }, "heat": { @@ -169,7 +169,7 @@ "on": "Detekterad" }, "vibration": { - "off": "Klart", + "off": "Rensa", "on": "Detekterad" }, "window": { diff --git a/homeassistant/components/bond/translations/sv.json b/homeassistant/components/bond/translations/sv.json new file mode 100644 index 00000000000..1fda5b91f5a --- /dev/null +++ b/homeassistant/components/bond/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/bsblan/translations/sv.json b/homeassistant/components/bsblan/translations/sv.json index 2d2d9662e4b..6dad0946ee5 100644 --- a/homeassistant/components/bsblan/translations/sv.json +++ b/homeassistant/components/bsblan/translations/sv.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "port": "Port", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/coinbase/translations/sv.json b/homeassistant/components/coinbase/translations/sv.json index 5610f0c79fd..1ab71904a13 100644 --- a/homeassistant/components/coinbase/translations/sv.json +++ b/homeassistant/components/coinbase/translations/sv.json @@ -1,4 +1,13 @@ { + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + }, "options": { "error": { "currency_unavailable": "En eller flera av de beg\u00e4rda valutasaldona tillhandah\u00e5lls inte av ditt Coinbase API.", diff --git a/homeassistant/components/control4/translations/sv.json b/homeassistant/components/control4/translations/sv.json index 23c825f256f..e1ecf8798c1 100644 --- a/homeassistant/components/control4/translations/sv.json +++ b/homeassistant/components/control4/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "Ov\u00e4ntat fel" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/daikin/translations/sv.json b/homeassistant/components/daikin/translations/sv.json index a704822f0b7..03b7358ee1b 100644 --- a/homeassistant/components/daikin/translations/sv.json +++ b/homeassistant/components/daikin/translations/sv.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "api_key": "API-nyckel", "host": "V\u00e4rddatorn", "password": "Enhetsl\u00f6senord (anv\u00e4nds endast av SKYFi-enheter)" }, diff --git a/homeassistant/components/deluge/translations/sv.json b/homeassistant/components/deluge/translations/sv.json index 23c825f256f..1a65fe29a6f 100644 --- a/homeassistant/components/deluge/translations/sv.json +++ b/homeassistant/components/deluge/translations/sv.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "port": "Port", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/doorbird/translations/sv.json b/homeassistant/components/doorbird/translations/sv.json index b2a809a576e..56c44dee6fb 100644 --- a/homeassistant/components/doorbird/translations/sv.json +++ b/homeassistant/components/doorbird/translations/sv.json @@ -8,6 +8,7 @@ "data": { "host": "V\u00e4rd (IP-adress)", "name": "Enhetsnamn", + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/eight_sleep/translations/sv.json b/homeassistant/components/eight_sleep/translations/sv.json index 78879942876..af5c7e7fe8d 100644 --- a/homeassistant/components/eight_sleep/translations/sv.json +++ b/homeassistant/components/eight_sleep/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/elkm1/translations/sv.json b/homeassistant/components/elkm1/translations/sv.json index e49782de873..8765d95baf6 100644 --- a/homeassistant/components/elkm1/translations/sv.json +++ b/homeassistant/components/elkm1/translations/sv.json @@ -13,6 +13,7 @@ }, "manual_connection": { "data": { + "protocol": "Protokoll", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/epson/translations/sv.json b/homeassistant/components/epson/translations/sv.json new file mode 100644 index 00000000000..e7ec27624a5 --- /dev/null +++ b/homeassistant/components/epson/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/sv.json b/homeassistant/components/flick_electric/translations/sv.json index 23c825f256f..2957bed953a 100644 --- a/homeassistant/components/flick_electric/translations/sv.json +++ b/homeassistant/components/flick_electric/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_auth": "Ogiltig autentisering" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/flo/translations/sv.json b/homeassistant/components/flo/translations/sv.json index 23c825f256f..78879942876 100644 --- a/homeassistant/components/flo/translations/sv.json +++ b/homeassistant/components/flo/translations/sv.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/foscam/translations/sv.json b/homeassistant/components/foscam/translations/sv.json index 23c825f256f..78879942876 100644 --- a/homeassistant/components/foscam/translations/sv.json +++ b/homeassistant/components/foscam/translations/sv.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/freedompro/translations/sv.json b/homeassistant/components/freedompro/translations/sv.json new file mode 100644 index 00000000000..9feab4808f7 --- /dev/null +++ b/homeassistant/components/freedompro/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/generic/translations/sv.json b/homeassistant/components/generic/translations/sv.json index 96aee85c779..62b30963a50 100644 --- a/homeassistant/components/generic/translations/sv.json +++ b/homeassistant/components/generic/translations/sv.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "authentication": "Autentiseringen", "username": "Anv\u00e4ndarnamn" } } @@ -15,6 +16,7 @@ "step": { "init": { "data": { + "authentication": "Autentiseringen", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/geocaching/translations/sv.json b/homeassistant/components/geocaching/translations/sv.json new file mode 100644 index 00000000000..d8622ba37fb --- /dev/null +++ b/homeassistant/components/geocaching/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "missing_configuration": "Komponenten har inte konfigurerats. F\u00f6lj dokumentationen.", + "no_url_available": "Ingen webbadress tillg\u00e4nglig. F\u00f6r information om detta fel, [kolla hj\u00e4lpavsnittet]({docs_url})", + "oauth_error": "Mottog ogiltiga tokendata.", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "create_entry": { + "default": "Autentiserats" + }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + }, + "reauth_confirm": { + "title": "\u00c5terautenticera integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/geofency/translations/sv.json b/homeassistant/components/geofency/translations/sv.json index 453b33533ce..8b48a30ce8b 100644 --- a/homeassistant/components/geofency/translations/sv.json +++ b/homeassistant/components/geofency/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "cloud_not_connected": "Ej ansluten till Home Assistant Cloud." + }, "create_entry": { "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera webhook funktionen i Geofency.\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information." }, diff --git a/homeassistant/components/geonetnz_volcano/translations/sv.json b/homeassistant/components/geonetnz_volcano/translations/sv.json index 0ad4f7f0853..65e867f8269 100644 --- a/homeassistant/components/geonetnz_volcano/translations/sv.json +++ b/homeassistant/components/geonetnz_volcano/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Platsen \u00e4r redan konfigurerad" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/gogogate2/translations/sv.json b/homeassistant/components/gogogate2/translations/sv.json index 23c825f256f..f7461922566 100644 --- a/homeassistant/components/gogogate2/translations/sv.json +++ b/homeassistant/components/gogogate2/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/google/translations/de.json b/homeassistant/components/google/translations/de.json index 2111e0c8bab..433324147dd 100644 --- a/homeassistant/components/google/translations/de.json +++ b/homeassistant/components/google/translations/de.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "Konto wurde bereits konfiguriert", "already_in_progress": "Der Konfigurationsablauf wird bereits ausgef\u00fchrt", + "cannot_connect": "Verbindung fehlgeschlagen", "code_expired": "Der Authentifizierungscode ist abgelaufen oder die Anmeldedaten sind ung\u00fcltig, bitte versuche es erneut.", "invalid_access_token": "Ung\u00fcltiger Zugriffs-Token", "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", diff --git a/homeassistant/components/google/translations/id.json b/homeassistant/components/google/translations/id.json index 085d49e92a3..ea13f27fce5 100644 --- a/homeassistant/components/google/translations/id.json +++ b/homeassistant/components/google/translations/id.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "Akun sudah dikonfigurasi", "already_in_progress": "Alur konfigurasi sedang berlangsung", + "cannot_connect": "Gagal terhubung", "code_expired": "Kode autentikasi kedaluwarsa atau penyiapan kredensial tidak valid, coba lagi.", "invalid_access_token": "Token akses tidak valid", "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", diff --git a/homeassistant/components/google/translations/sv.json b/homeassistant/components/google/translations/sv.json new file mode 100644 index 00000000000..7f1f140af90 --- /dev/null +++ b/homeassistant/components/google/translations/sv.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats", + "cannot_connect": "Det gick inte att ansluta." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google_travel_time/translations/sv.json b/homeassistant/components/google_travel_time/translations/sv.json index 18a9d3d507e..1b8cc14bce7 100644 --- a/homeassistant/components/google_travel_time/translations/sv.json +++ b/homeassistant/components/google_travel_time/translations/sv.json @@ -6,6 +6,7 @@ "step": { "user": { "data": { + "api_key": "API-nyckel", "destination": "Destination", "origin": "Ursprung" } diff --git a/homeassistant/components/group/translations/sv.json b/homeassistant/components/group/translations/sv.json index 77ae43cb1ee..c62d9e18ffc 100644 --- a/homeassistant/components/group/translations/sv.json +++ b/homeassistant/components/group/translations/sv.json @@ -9,8 +9,20 @@ }, "title": "Ny grupp" }, + "light": { + "title": "L\u00e4gg till grupp" + }, "user": { - "title": "Ny grupp" + "title": "L\u00e4gg till grupp" + } + } + }, + "options": { + "step": { + "binary_sensor": { + "data": { + "all": "Alla entiteter" + } } } }, diff --git a/homeassistant/components/habitica/translations/sv.json b/homeassistant/components/habitica/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/habitica/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/translations/sv.json b/homeassistant/components/heos/translations/sv.json index a2cec73f291..100f8fd83a5 100644 --- a/homeassistant/components/heos/translations/sv.json +++ b/homeassistant/components/heos/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/here_travel_time/translations/sv.json b/homeassistant/components/here_travel_time/translations/sv.json new file mode 100644 index 00000000000..0757cc44bf1 --- /dev/null +++ b/homeassistant/components/here_travel_time/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hlk_sw16/translations/sv.json b/homeassistant/components/hlk_sw16/translations/sv.json new file mode 100644 index 00000000000..eba844f6c03 --- /dev/null +++ b/homeassistant/components/hlk_sw16/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/humidifier/translations/sv.json b/homeassistant/components/humidifier/translations/sv.json index 325e9f2e6a0..2818d7b7b04 100644 --- a/homeassistant/components/humidifier/translations/sv.json +++ b/homeassistant/components/humidifier/translations/sv.json @@ -3,5 +3,10 @@ "trigger_type": { "turned_off": "{entity_name} st\u00e4ngdes av" } + }, + "state": { + "_": { + "on": "P\u00e5" + } } } \ No newline at end of file diff --git a/homeassistant/components/hvv_departures/translations/sv.json b/homeassistant/components/hvv_departures/translations/sv.json index 8d17443df9f..3a2983d1035 100644 --- a/homeassistant/components/hvv_departures/translations/sv.json +++ b/homeassistant/components/hvv_departures/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/iaqualink/translations/sv.json b/homeassistant/components/iaqualink/translations/sv.json index 0e086c9c413..e697b5a02b9 100644 --- a/homeassistant/components/iaqualink/translations/sv.json +++ b/homeassistant/components/iaqualink/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/iqvia/translations/sv.json b/homeassistant/components/iqvia/translations/sv.json index 71eb118a858..f6500296b58 100644 --- a/homeassistant/components/iqvia/translations/sv.json +++ b/homeassistant/components/iqvia/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad" + }, "error": { "invalid_zip_code": "Ogiltigt postnummer" }, diff --git a/homeassistant/components/kmtronic/translations/sv.json b/homeassistant/components/kmtronic/translations/sv.json index 23c825f256f..a265d988aaa 100644 --- a/homeassistant/components/kmtronic/translations/sv.json +++ b/homeassistant/components/kmtronic/translations/sv.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "host": "V\u00e4rd", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/kodi/translations/sv.json b/homeassistant/components/kodi/translations/sv.json index 36b53053594..9102efc653e 100644 --- a/homeassistant/components/kodi/translations/sv.json +++ b/homeassistant/components/kodi/translations/sv.json @@ -5,6 +5,11 @@ "data": { "username": "Anv\u00e4ndarnamn" } + }, + "user": { + "data": { + "host": "V\u00e4rd" + } } } } diff --git a/homeassistant/components/laundrify/translations/sv.json b/homeassistant/components/laundrify/translations/sv.json new file mode 100644 index 00000000000..dd7447e847e --- /dev/null +++ b/homeassistant/components/laundrify/translations/sv.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meater/translations/sv.json b/homeassistant/components/meater/translations/sv.json index 44b4ba96acf..47a719743f5 100644 --- a/homeassistant/components/meater/translations/sv.json +++ b/homeassistant/components/meater/translations/sv.json @@ -1,6 +1,7 @@ { "config": { "error": { + "invalid_auth": "Ogiltig autentisering", "service_unavailable_error": "Programmeringsgr\u00e4nssnittet g\u00e5r inte att komma \u00e5t f\u00f6r n\u00e4rvarande. F\u00f6rs\u00f6k igen senare." }, "step": { diff --git a/homeassistant/components/metoffice/translations/sv.json b/homeassistant/components/metoffice/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/metoffice/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mjpeg/translations/sv.json b/homeassistant/components/mjpeg/translations/sv.json index 291fbedbcfb..dd590fec4ba 100644 --- a/homeassistant/components/mjpeg/translations/sv.json +++ b/homeassistant/components/mjpeg/translations/sv.json @@ -9,6 +9,9 @@ } }, "options": { + "error": { + "invalid_auth": "Ogiltig autentisering" + }, "step": { "init": { "data": { diff --git a/homeassistant/components/motion_blinds/translations/sv.json b/homeassistant/components/motion_blinds/translations/sv.json new file mode 100644 index 00000000000..eee10b4c765 --- /dev/null +++ b/homeassistant/components/motion_blinds/translations/sv.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "step": { + "connect": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nam/translations/sv.json b/homeassistant/components/nam/translations/sv.json index 9f12ea5a385..ffa62b8fae2 100644 --- a/homeassistant/components/nam/translations/sv.json +++ b/homeassistant/components/nam/translations/sv.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "device_unsupported": "Enheten st\u00f6ds ej" + "device_unsupported": "Enheten st\u00f6ds ej", + "reauth_unsuccessful": "\u00c5terautentiseringen misslyckades. Ta bort integrationen och konfigurera den igen." }, "error": { "cannot_connect": "Det gick inte att ansluta ", diff --git a/homeassistant/components/nest/translations/de.json b/homeassistant/components/nest/translations/de.json index 659755fc6b4..933231fd1e8 100644 --- a/homeassistant/components/nest/translations/de.json +++ b/homeassistant/components/nest/translations/de.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "Konto wurde bereits konfiguriert", "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.", "invalid_access_token": "Ung\u00fcltiger Zugriffs-Token", "missing_configuration": "Die Komponente ist nicht konfiguriert. Bitte der Dokumentation folgen.", diff --git a/homeassistant/components/nest/translations/id.json b/homeassistant/components/nest/translations/id.json index e58f891f6c4..6a45ccaee8c 100644 --- a/homeassistant/components/nest/translations/id.json +++ b/homeassistant/components/nest/translations/id.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "Akun sudah dikonfigurasi", "authorize_url_timeout": "Tenggang waktu pembuatan URL otorisasi habis.", "invalid_access_token": "Token akses tidak valid", "missing_configuration": "Komponen tidak dikonfigurasi. Ikuti petunjuk dalam dokumentasi.", @@ -33,6 +34,7 @@ "title": "Tautkan Akun Google" }, "auth_upgrade": { + "description": "Autentikasi Aplikasi tidak digunakan lagi oleh Google untuk meningkatkan keamanan, dan Anda perlu mengambil tindakan dengan membuat kredensial aplikasi baru. \n\nBuka [dokumentasi]({more_info_url}) untuk panduan langkah selanjutnya yang perlu diambil untuk memulihkan akses ke perangkat Nest Anda.", "title": "Nest: Penghentian Autentikasi Aplikasi" }, "cloud_project": { @@ -43,15 +45,18 @@ "title": "Nest: Masukkan ID Proyek Cloud" }, "create_cloud_project": { + "description": "Integrasi Nest memungkinkan Anda mengintegrasikan Nest Thermostat, Kamera, dan Bel Pintu menggunakan API Smart Device Management. API SDM **memerlukan biaya penyiapan satu kali sebesar USD5**. Lihat dokumentasi untuk [info lebih lanjut]({more_info_url}). \n\n1. Buka [Konsol Google Cloud]({cloud_console_url}).\n1. Jika ini adalah proyek pertama Anda, klik **Buat Proyek** lalu **Proyek Baru**.\n1. Beri Nama Proyek Cloud Anda, lalu klik **Buat**.\n1. Simpan ID Proyek Cloud misalnya *example-project-12345* karena Anda akan membutuhkannya nanti\n1. Buka Perpustakaan API untuk [API Smart Device Management]({sdm_api_url}) dan klik **Aktifkan**.\n1. Buka Perpustakaan API untuk [API Cloud Pub/Sub]({pubsub_api_url}) dan klik **Aktifkan**. \n\n Lanjutkan saat proyek cloud Anda sudah disiapkan.", "title": "Nest: Buat dan konfigurasikan Proyek Cloud" }, "device_project": { "data": { "project_id": "ID Proyek Akses Perangkat" }, + "description": "Buat proyek Akses Perangkat Nest yang **membutuhkan biaya USD5** untuk menyiapkannya.\n1. Buka [Konsol Akses Perangkat]({device_access_console_url}), dan ikuti alur pembayaran.\n1. Klik **Buat proyek**\n1. Beri nama proyek Akses Perangkat Anda dan klik **Berikutnya**.\n1. Masukkan ID Klien OAuth Anda\n1. Aktifkan acara dengan mengklik **Aktifkan** dan **Buat proyek**. \n\n Masukkan ID Proyek Akses Perangkat Anda di bawah ini ([more info]({more_info_url})).\n", "title": "Nest: Buat Proyek Akses Perangkat" }, "device_project_upgrade": { + "description": "Perbarui Proyek Akses Perangkat Nest dengan ID Klien OAuth baru Anda ([info lebih lanjut]({more_info_url}))\n1. Buka [Konsol Akses Perangkat]( {device_access_console_url} ).\n1. Klik ikon tempat sampah di samping *ID Klien OAuth*.\n1. Klik menu luapan `...` dan *Tambah ID Klien*.\n1. Masukkan ID Klien OAuth baru Anda dan klik **Tambah**. \n\n ID Klien OAuth Anda adalah: `{client_id}`", "title": "Nest: Perbarui Proyek Akses Perangkat" }, "init": { diff --git a/homeassistant/components/nest/translations/sv.json b/homeassistant/components/nest/translations/sv.json index e0fef47aaec..9e91f3ddf94 100644 --- a/homeassistant/components/nest/translations/sv.json +++ b/homeassistant/components/nest/translations/sv.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Konto har redan konfigurerats", "authorize_url_timeout": "Timeout vid generering av en autentisieringsadress." }, "error": { diff --git a/homeassistant/components/nuki/translations/sv.json b/homeassistant/components/nuki/translations/sv.json new file mode 100644 index 00000000000..563e2d4a773 --- /dev/null +++ b/homeassistant/components/nuki/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/octoprint/translations/sv.json b/homeassistant/components/octoprint/translations/sv.json index dad4741f152..08b58e15cc6 100644 --- a/homeassistant/components/octoprint/translations/sv.json +++ b/homeassistant/components/octoprint/translations/sv.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "host": "V\u00e4rd", "ssl": "Anv\u00e4nd SSL", "username": "Anv\u00e4ndarnamn" } diff --git a/homeassistant/components/onewire/translations/sv.json b/homeassistant/components/onewire/translations/sv.json index 1b100c60d97..9b57beabc8f 100644 --- a/homeassistant/components/onewire/translations/sv.json +++ b/homeassistant/components/onewire/translations/sv.json @@ -3,6 +3,7 @@ "step": { "device_selection": { "data": { + "clear_device_options": "Rensa alla enhetskonfigurationer", "device_selection": "V\u00e4lj enheter att konfigurera" } } diff --git a/homeassistant/components/onvif/translations/sv.json b/homeassistant/components/onvif/translations/sv.json index f2fd2e8429e..2cc40c1e465 100644 --- a/homeassistant/components/onvif/translations/sv.json +++ b/homeassistant/components/onvif/translations/sv.json @@ -3,6 +3,7 @@ "step": { "configure": { "data": { + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } }, diff --git a/homeassistant/components/opengarage/translations/sv.json b/homeassistant/components/opengarage/translations/sv.json new file mode 100644 index 00000000000..eba844f6c03 --- /dev/null +++ b/homeassistant/components/opengarage/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.ca.json b/homeassistant/components/overkiz/translations/sensor.ca.json index 2d94ab16d1a..9e1a40941b0 100644 --- a/homeassistant/components/overkiz/translations/sensor.ca.json +++ b/homeassistant/components/overkiz/translations/sensor.ca.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Net", "dirty": "Brut" + }, + "overkiz__three_way_handle_direction": { + "closed": "Tancada", + "open": "Oberta", + "tilt": "Inclinada" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.de.json b/homeassistant/components/overkiz/translations/sensor.de.json index 8f262514e5f..216df8f0cff 100644 --- a/homeassistant/components/overkiz/translations/sensor.de.json +++ b/homeassistant/components/overkiz/translations/sensor.de.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Sauber", "dirty": "Schmutzig" + }, + "overkiz__three_way_handle_direction": { + "closed": "Geschlossen", + "open": "Offen", + "tilt": "Kippen" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.et.json b/homeassistant/components/overkiz/translations/sensor.et.json index 974d57b095c..72021d675c8 100644 --- a/homeassistant/components/overkiz/translations/sensor.et.json +++ b/homeassistant/components/overkiz/translations/sensor.et.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Puhas", "dirty": "R\u00e4pane" + }, + "overkiz__three_way_handle_direction": { + "closed": "Suletud", + "open": "Avatud", + "tilt": "Kallutamine" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.fr.json b/homeassistant/components/overkiz/translations/sensor.fr.json index af9fd658ab9..23fe8993570 100644 --- a/homeassistant/components/overkiz/translations/sensor.fr.json +++ b/homeassistant/components/overkiz/translations/sensor.fr.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Propre", "dirty": "Sale" + }, + "overkiz__three_way_handle_direction": { + "closed": "Ferm\u00e9e", + "open": "Ouverte", + "tilt": "Inclin\u00e9e" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.id.json b/homeassistant/components/overkiz/translations/sensor.id.json index efe4f588a71..bf4703507f8 100644 --- a/homeassistant/components/overkiz/translations/sensor.id.json +++ b/homeassistant/components/overkiz/translations/sensor.id.json @@ -36,6 +36,10 @@ "overkiz__sensor_room": { "clean": "Bersih", "dirty": "Kotor" + }, + "overkiz__three_way_handle_direction": { + "closed": "Tutup", + "open": "Buka" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.pt-BR.json b/homeassistant/components/overkiz/translations/sensor.pt-BR.json index 3ec82aa83d5..0f391de9027 100644 --- a/homeassistant/components/overkiz/translations/sensor.pt-BR.json +++ b/homeassistant/components/overkiz/translations/sensor.pt-BR.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Limpo", "dirty": "Sujo" + }, + "overkiz__three_way_handle_direction": { + "closed": "Fechado", + "open": "Aberto", + "tilt": "Inclinar" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.sv.json b/homeassistant/components/overkiz/translations/sensor.sv.json new file mode 100644 index 00000000000..024e5fe1037 --- /dev/null +++ b/homeassistant/components/overkiz/translations/sensor.sv.json @@ -0,0 +1,9 @@ +{ + "state": { + "overkiz__three_way_handle_direction": { + "closed": "St\u00e4ngd", + "open": "\u00d6ppen", + "tilt": "Vinkla" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.zh-Hant.json b/homeassistant/components/overkiz/translations/sensor.zh-Hant.json index 785c02cbfbc..661dfa5d313 100644 --- a/homeassistant/components/overkiz/translations/sensor.zh-Hant.json +++ b/homeassistant/components/overkiz/translations/sensor.zh-Hant.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "\u826f\u597d", "dirty": "\u4e0d\u4f73" + }, + "overkiz__three_way_handle_direction": { + "closed": "\u95dc\u9589", + "open": "\u958b\u555f", + "tilt": "\u50be\u659c" } } } \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/sv.json b/homeassistant/components/panasonic_viera/translations/sv.json index f70336fae9f..2c863b587ec 100644 --- a/homeassistant/components/panasonic_viera/translations/sv.json +++ b/homeassistant/components/panasonic_viera/translations/sv.json @@ -4,6 +4,11 @@ "unknown": "Ett ov\u00e4ntat fel intr\u00e4ffade. Kontrollera loggarna f\u00f6r mer information." }, "step": { + "pairing": { + "data": { + "pin": "Pin-kod" + } + }, "user": { "data": { "host": "IP-adress", diff --git a/homeassistant/components/pi_hole/translations/sv.json b/homeassistant/components/pi_hole/translations/sv.json new file mode 100644 index 00000000000..0459a7596f3 --- /dev/null +++ b/homeassistant/components/pi_hole/translations/sv.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "api_key": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel", + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plugwise/translations/sv.json b/homeassistant/components/plugwise/translations/sv.json index acbb205a11f..affb73f907e 100644 --- a/homeassistant/components/plugwise/translations/sv.json +++ b/homeassistant/components/plugwise/translations/sv.json @@ -1,6 +1,11 @@ { "config": { "step": { + "user": { + "data": { + "port": "Port" + } + }, "user_gateway": { "data": { "host": "IP address", diff --git a/homeassistant/components/prosegur/translations/sv.json b/homeassistant/components/prosegur/translations/sv.json index 8a60ea1a5dc..4ae0ae62971 100644 --- a/homeassistant/components/prosegur/translations/sv.json +++ b/homeassistant/components/prosegur/translations/sv.json @@ -8,6 +8,7 @@ }, "user": { "data": { + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/pvoutput/translations/sv.json b/homeassistant/components/pvoutput/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/pvoutput/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/qnap_qsw/translations/sv.json b/homeassistant/components/qnap_qsw/translations/sv.json index 0234fcb9860..ec6c8842dca 100644 --- a/homeassistant/components/qnap_qsw/translations/sv.json +++ b/homeassistant/components/qnap_qsw/translations/sv.json @@ -4,6 +4,9 @@ "already_configured": "Enheten \u00e4r redan konfigurerad", "invalid_id": "Enheten returnerade ett ogiltigt unikt ID" }, + "error": { + "invalid_auth": "Ogiltig autentisering" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/radiotherm/translations/sv.json b/homeassistant/components/radiotherm/translations/sv.json new file mode 100644 index 00000000000..f341a6314ee --- /dev/null +++ b/homeassistant/components/radiotherm/translations/sv.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/sv.json b/homeassistant/components/rfxtrx/translations/sv.json index 6cab9bd0b37..28f5c911c3a 100644 --- a/homeassistant/components/rfxtrx/translations/sv.json +++ b/homeassistant/components/rfxtrx/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, "step": { "setup_network": { "data": { diff --git a/homeassistant/components/ruckus_unleashed/translations/sv.json b/homeassistant/components/ruckus_unleashed/translations/sv.json index 23c825f256f..a265d988aaa 100644 --- a/homeassistant/components/ruckus_unleashed/translations/sv.json +++ b/homeassistant/components/ruckus_unleashed/translations/sv.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "host": "V\u00e4rd", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/scrape/translations/sv.json b/homeassistant/components/scrape/translations/sv.json index 291fbedbcfb..c70f08008dc 100644 --- a/homeassistant/components/scrape/translations/sv.json +++ b/homeassistant/components/scrape/translations/sv.json @@ -1,9 +1,14 @@ { "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats" + }, "step": { "user": { "data": { - "username": "Anv\u00e4ndarnamn" + "password": "L\u00f6senord", + "username": "Anv\u00e4ndarnamn", + "verify_ssl": "Verifiera SSL-certifikat" } } } @@ -12,6 +17,7 @@ "step": { "init": { "data": { + "password": "L\u00f6senord", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/season/translations/sv.json b/homeassistant/components/season/translations/sv.json new file mode 100644 index 00000000000..c0b662beebe --- /dev/null +++ b/homeassistant/components/season/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.sv.json b/homeassistant/components/sensibo/translations/sensor.sv.json new file mode 100644 index 00000000000..ead64d63cd6 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.sv.json @@ -0,0 +1,7 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "Normal" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sv.json b/homeassistant/components/sensibo/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/sensibo/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sia/translations/sv.json b/homeassistant/components/sia/translations/sv.json new file mode 100644 index 00000000000..67fd98a8827 --- /dev/null +++ b/homeassistant/components/sia/translations/sv.json @@ -0,0 +1,7 @@ +{ + "config": { + "error": { + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/sv.json b/homeassistant/components/skybell/translations/sv.json new file mode 100644 index 00000000000..bb5b9d188a6 --- /dev/null +++ b/homeassistant/components/skybell/translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, + "step": { + "user": { + "data": { + "email": "E-post", + "password": "L\u00f6senord" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/slack/translations/sv.json b/homeassistant/components/slack/translations/sv.json index 23c825f256f..34e67b311a2 100644 --- a/homeassistant/components/slack/translations/sv.json +++ b/homeassistant/components/slack/translations/sv.json @@ -1,8 +1,17 @@ { "config": { + "abort": { + "already_configured": "Tj\u00e4nsten \u00e4r redan konfigurerad" + }, + "error": { + "cannot_connect": "Det gick inte att ansluta.", + "invalid_auth": "Ogiltig autentisering", + "unknown": "Ov\u00e4ntat fel" + }, "step": { "user": { "data": { + "api_key": "API-nyckel", "username": "Anv\u00e4ndarnamn" } } diff --git a/homeassistant/components/solaredge/translations/sv.json b/homeassistant/components/solaredge/translations/sv.json index f09320388f2..df7408b43c2 100644 --- a/homeassistant/components/solaredge/translations/sv.json +++ b/homeassistant/components/solaredge/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_api_key": "Ogiltig API-nyckel" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/squeezebox/translations/sv.json b/homeassistant/components/squeezebox/translations/sv.json index 8dbe191c902..796ddb0da2e 100644 --- a/homeassistant/components/squeezebox/translations/sv.json +++ b/homeassistant/components/squeezebox/translations/sv.json @@ -5,6 +5,11 @@ "data": { "username": "Anv\u00e4ndarnamn" } + }, + "user": { + "data": { + "host": "V\u00e4rd" + } } } } diff --git a/homeassistant/components/system_bridge/translations/sv.json b/homeassistant/components/system_bridge/translations/sv.json new file mode 100644 index 00000000000..5fa635734cb --- /dev/null +++ b/homeassistant/components/system_bridge/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tailscale/translations/sv.json b/homeassistant/components/tailscale/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/tailscale/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tankerkoenig/translations/sv.json b/homeassistant/components/tankerkoenig/translations/sv.json new file mode 100644 index 00000000000..4b9b566c7d1 --- /dev/null +++ b/homeassistant/components/tankerkoenig/translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tautulli/translations/sv.json b/homeassistant/components/tautulli/translations/sv.json index 3354c6053dc..0058ba541bf 100644 --- a/homeassistant/components/tautulli/translations/sv.json +++ b/homeassistant/components/tautulli/translations/sv.json @@ -3,6 +3,11 @@ "step": { "reauth_confirm": { "description": "F\u00f6r att hitta din API-nyckel, \u00f6ppna Tautullis webbsida och navigera till Inst\u00e4llningar och sedan till webbgr\u00e4nssnitt. API-nyckeln finns l\u00e4ngst ner p\u00e5 sidan." + }, + "user": { + "data": { + "api_key": "API-nyckel" + } } } } diff --git a/homeassistant/components/tibber/translations/sv.json b/homeassistant/components/tibber/translations/sv.json new file mode 100644 index 00000000000..1fda5b91f5a --- /dev/null +++ b/homeassistant/components/tibber/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "access_token": "\u00c5tkomstnyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/translations/sv.json b/homeassistant/components/tomorrowio/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_ferry/translations/sv.json b/homeassistant/components/trafikverket_ferry/translations/sv.json new file mode 100644 index 00000000000..ad82dbe221d --- /dev/null +++ b/homeassistant/components/trafikverket_ferry/translations/sv.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "invalid_auth": "Ogiltig autentisering" + }, + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_train/translations/sv.json b/homeassistant/components/trafikverket_train/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/trafikverket_train/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/trafikverket_weatherstation/translations/sv.json b/homeassistant/components/trafikverket_weatherstation/translations/sv.json new file mode 100644 index 00000000000..f4a63bb449d --- /dev/null +++ b/homeassistant/components/trafikverket_weatherstation/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/translations/id.json b/homeassistant/components/transmission/translations/id.json index a96524f5165..7b5fa3a703f 100644 --- a/homeassistant/components/transmission/translations/id.json +++ b/homeassistant/components/transmission/translations/id.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Perangkat sudah dikonfigurasi" + "already_configured": "Perangkat sudah dikonfigurasi", + "reauth_successful": "Autentikasi ulang berhasil" }, "error": { "cannot_connect": "Gagal terhubung", @@ -9,6 +10,13 @@ "name_exists": "Nama sudah ada" }, "step": { + "reauth_confirm": { + "data": { + "password": "Kata Sandi" + }, + "description": "Kata sandi untuk {username} tidak valid.", + "title": "Autentikasi Ulang Integrasi" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/sv.json b/homeassistant/components/transmission/translations/sv.json index 848fa71de60..0ffbebed9f6 100644 --- a/homeassistant/components/transmission/translations/sv.json +++ b/homeassistant/components/transmission/translations/sv.json @@ -1,13 +1,20 @@ { "config": { "abort": { - "already_configured": "V\u00e4rden \u00e4r redan konfigurerad." + "already_configured": "V\u00e4rden \u00e4r redan konfigurerad.", + "reauth_successful": "\u00c5terautentisering lyckades" }, "error": { "cannot_connect": "Det g\u00e5r inte att ansluta till v\u00e4rden", "name_exists": "Namnet finns redan" }, "step": { + "reauth_confirm": { + "data": { + "password": "L\u00f6senord" + }, + "title": "\u00c5terautenticera integration" + }, "user": { "data": { "host": "V\u00e4rd", diff --git a/homeassistant/components/tuya/translations/select.sv.json b/homeassistant/components/tuya/translations/select.sv.json new file mode 100644 index 00000000000..05092fe808c --- /dev/null +++ b/homeassistant/components/tuya/translations/select.sv.json @@ -0,0 +1,17 @@ +{ + "state": { + "tuya__humidifier_spray_mode": { + "humidity": "Luftfuktighet" + }, + "tuya__light_mode": { + "none": "Av" + }, + "tuya__relay_status": { + "on": "P\u00e5", + "power_on": "P\u00e5" + }, + "tuya__vacuum_mode": { + "standby": "Standby" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ukraine_alarm/translations/sv.json b/homeassistant/components/ukraine_alarm/translations/sv.json new file mode 100644 index 00000000000..4a9945525c8 --- /dev/null +++ b/homeassistant/components/ukraine_alarm/translations/sv.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "cannot_connect": "Det gick inte att ansluta.", + "unknown": "Ov\u00e4ntat fel" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uptimerobot/translations/sv.json b/homeassistant/components/uptimerobot/translations/sv.json new file mode 100644 index 00000000000..5ad5b5b6db4 --- /dev/null +++ b/homeassistant/components/uptimerobot/translations/sv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "reauth_confirm": { + "data": { + "api_key": "API-nyckel" + } + }, + "user": { + "data": { + "api_key": "API-nyckel" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/translations/sv.json b/homeassistant/components/vesync/translations/sv.json index b9eedc2f747..4621636cecc 100644 --- a/homeassistant/components/vesync/translations/sv.json +++ b/homeassistant/components/vesync/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Redan konfigurerad. Endast en konfiguration m\u00f6jlig." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/vicare/translations/sv.json b/homeassistant/components/vicare/translations/sv.json index 26e9f2d6a49..80588906063 100644 --- a/homeassistant/components/vicare/translations/sv.json +++ b/homeassistant/components/vicare/translations/sv.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "client_id": "API-nyckel", "username": "E-postadress" } } diff --git a/homeassistant/components/vlc_telnet/translations/sv.json b/homeassistant/components/vlc_telnet/translations/sv.json new file mode 100644 index 00000000000..eba844f6c03 --- /dev/null +++ b/homeassistant/components/vlc_telnet/translations/sv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "V\u00e4rd" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/water_heater/translations/sv.json b/homeassistant/components/water_heater/translations/sv.json index 37de0012a79..cb4826c461a 100644 --- a/homeassistant/components/water_heater/translations/sv.json +++ b/homeassistant/components/water_heater/translations/sv.json @@ -1,7 +1,8 @@ { "state": { "_": { - "heat_pump": "V\u00e4rmepump" + "heat_pump": "V\u00e4rmepump", + "off": "Av" } } } \ No newline at end of file diff --git a/homeassistant/components/whirlpool/translations/sv.json b/homeassistant/components/whirlpool/translations/sv.json index 23c825f256f..f7461922566 100644 --- a/homeassistant/components/whirlpool/translations/sv.json +++ b/homeassistant/components/whirlpool/translations/sv.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/wled/translations/select.sv.json b/homeassistant/components/wled/translations/select.sv.json new file mode 100644 index 00000000000..1c3bb4c1ff4 --- /dev/null +++ b/homeassistant/components/wled/translations/select.sv.json @@ -0,0 +1,7 @@ +{ + "state": { + "wled__live_override": { + "1": "P\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/yolink/translations/sv.json b/homeassistant/components/yolink/translations/sv.json new file mode 100644 index 00000000000..3c6db089e0e --- /dev/null +++ b/homeassistant/components/yolink/translations/sv.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "already_configured": "Konto har redan konfigurerats", + "already_in_progress": "Konfigurationsfl\u00f6det p\u00e5g\u00e5r redan", + "authorize_url_timeout": "Timeout vid generering av en auktoriserings-URL.", + "missing_configuration": "Komponenten har inte konfigurerats. F\u00f6lj dokumentationen.", + "no_url_available": "Ingen webbadress tillg\u00e4nglig. F\u00f6r information om detta fel, [kolla hj\u00e4lpavsnittet]({docs_url})", + "oauth_error": "Mottog ogiltiga tokendata.", + "reauth_successful": "\u00c5terautentisering lyckades" + }, + "create_entry": { + "default": "Autentiserats" + }, + "step": { + "pick_implementation": { + "title": "V\u00e4lj autentiseringsmetod" + }, + "reauth_confirm": { + "title": "\u00c5terautenticera integration" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zwave_js/translations/sv.json b/homeassistant/components/zwave_js/translations/sv.json index ecbacaaa3b3..907924e08ac 100644 --- a/homeassistant/components/zwave_js/translations/sv.json +++ b/homeassistant/components/zwave_js/translations/sv.json @@ -3,5 +3,10 @@ "condition_type": { "value": "Nuvarande v\u00e4rde f\u00f6r ett Z-Wave v\u00e4rde" } + }, + "options": { + "error": { + "cannot_connect": "Det gick inte att ansluta." + } } } \ No newline at end of file From ab30d38469db9629525ee331822890dc8ba0eccb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jun 2022 20:12:48 -0500 Subject: [PATCH 618/947] Switch rest to use the json helper (#73867) --- homeassistant/components/rest/sensor.py | 6 +++--- homeassistant/helpers/json.py | 3 +++ 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 2965504dc4a..93a96aeb94f 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -1,7 +1,6 @@ """Support for RESTful API sensors.""" from __future__ import annotations -import json import logging from xml.parsers.expat import ExpatError @@ -26,6 +25,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import json_dumps, json_loads from homeassistant.helpers.template_entity import TemplateSensor from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -141,7 +141,7 @@ class RestSensor(BaseRestEntity, TemplateSensor): or content_type.startswith("application/rss+xml") ): try: - value = json.dumps(xmltodict.parse(value)) + value = json_dumps(xmltodict.parse(value)) _LOGGER.debug("JSON converted from XML: %s", value) except ExpatError: _LOGGER.warning( @@ -153,7 +153,7 @@ class RestSensor(BaseRestEntity, TemplateSensor): self._attributes = {} if value: try: - json_dict = json.loads(value) + json_dict = json_loads(value) if self._json_attrs_path is not None: json_dict = jsonpath(json_dict, self._json_attrs_path) # jsonpath will always store the result in json_dict[0] diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 9248c613b95..43e97060f5d 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -86,4 +86,7 @@ def json_dumps(data: Any) -> str: ).decode("utf-8") +json_loads = orjson.loads + + JSON_DUMP: Final = json_dumps From 6c41a101421bce22b403ad341bb72de4c97d9332 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jun 2022 20:13:02 -0500 Subject: [PATCH 619/947] Switch api and event stream to use json helper (#73868) --- homeassistant/components/api/__init__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index ab43632e25c..ec1e13e07fc 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -1,7 +1,6 @@ """Rest API for Home Assistant.""" import asyncio from http import HTTPStatus -import json import logging from aiohttp import web @@ -29,7 +28,7 @@ import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized from homeassistant.helpers import template -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import json_dumps, json_loads from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType @@ -108,7 +107,7 @@ class APIEventStream(HomeAssistantView): if event.event_type == EVENT_HOMEASSISTANT_STOP: data = stop_obj else: - data = json.dumps(event, cls=JSONEncoder) + data = json_dumps(event) await to_write.put(data) @@ -261,7 +260,7 @@ class APIEventView(HomeAssistantView): raise Unauthorized() body = await request.text() try: - event_data = json.loads(body) if body else None + event_data = json_loads(body) if body else None except ValueError: return self.json_message( "Event data should be valid JSON.", HTTPStatus.BAD_REQUEST @@ -314,7 +313,7 @@ class APIDomainServicesView(HomeAssistantView): hass: ha.HomeAssistant = request.app["hass"] body = await request.text() try: - data = json.loads(body) if body else None + data = json_loads(body) if body else None except ValueError: return self.json_message( "Data should be valid JSON.", HTTPStatus.BAD_REQUEST From 168065a9a0cb800130324f359e0971532b0d5afb Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 22 Jun 2022 22:10:41 -0400 Subject: [PATCH 620/947] Bump version of pyunifiprotect to 4.0.7 (#73875) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 2b779c77629..e28386d73a8 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==4.0.6", "unifi-discovery==1.1.4"], + "requirements": ["pyunifiprotect==4.0.7", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index c40ecd7dc6d..962a246c2d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.6 +pyunifiprotect==4.0.7 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f523f9e9b7..4a48872ddee 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.6 +pyunifiprotect==4.0.7 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From b5f6f785d5c5e0c85da65cea60ac4fb7b37a9191 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jun 2022 21:32:48 -0500 Subject: [PATCH 621/947] Switch mobile_app to use the json helper (#73870) --- homeassistant/components/mobile_app/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 545c3511fc9..e1c0841984f 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -13,7 +13,7 @@ from nacl.secret import SecretBox from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.entity import DeviceInfo -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import JSONEncoder, json_loads from .const import ( ATTR_APP_DATA, @@ -85,7 +85,7 @@ def _decrypt_payload_helper( key_bytes = get_key_bytes(key, keylen) msg_bytes = decrypt(ciphertext, key_bytes) - message = json.loads(msg_bytes.decode("utf-8")) + message = json_loads(msg_bytes) _LOGGER.debug("Successfully decrypted mobile_app payload") return message From 164eba7e5d3b76844821f19164ca4330c9d44b0a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 22 Jun 2022 21:57:38 -0500 Subject: [PATCH 622/947] Switch loader to use json helper (#73872) --- homeassistant/helpers/json.py | 1 + homeassistant/loader.py | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 43e97060f5d..fd1153711ad 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -7,6 +7,7 @@ from typing import Any, Final import orjson JSON_ENCODE_EXCEPTIONS = (TypeError, ValueError) +JSON_DECODE_EXCEPTIONS = (orjson.JSONDecodeError,) class JSONEncoder(json.JSONEncoder): diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 589f316532b..ab681d7c42d 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -11,7 +11,6 @@ from collections.abc import Callable from contextlib import suppress import functools as ft import importlib -import json import logging import pathlib import sys @@ -30,6 +29,7 @@ from .generated.mqtt import MQTT from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF +from .helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from .util.async_ import gather_with_concurrency # Typing imports that create a circular dependency @@ -366,8 +366,8 @@ class Integration: continue try: - manifest = json.loads(manifest_path.read_text()) - except ValueError as err: + manifest = json_loads(manifest_path.read_text()) + except JSON_DECODE_EXCEPTIONS as err: _LOGGER.error( "Error parsing manifest.json file at %s: %s", manifest_path, err ) From 303ce715edb6d3a338547382302d7e5d2c697505 Mon Sep 17 00:00:00 2001 From: gigatexel <65073191+gigatexel@users.noreply.github.com> Date: Thu, 23 Jun 2022 09:15:16 +0200 Subject: [PATCH 623/947] Adapt DSMR integration to changes in dsmr_parser for Belgian/Dutch meters (#73817) --- homeassistant/components/dsmr/const.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/dsmr/const.py b/homeassistant/components/dsmr/const.py index 43c0e66e945..9f08e812e04 100644 --- a/homeassistant/components/dsmr/const.py +++ b/homeassistant/components/dsmr/const.py @@ -245,10 +245,28 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + DSMRSensorEntityDescription( + key=obis_references.BELGIUM_MAX_POWER_PER_PHASE, + name="Max power per phase", + dsmr_versions={"5B"}, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), + DSMRSensorEntityDescription( + key=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE, + name="Max current per phase", + dsmr_versions={"5B"}, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + ), DSMRSensorEntityDescription( key=obis_references.ELECTRICITY_IMPORTED_TOTAL, name="Energy Consumption (total)", - dsmr_versions={"5", "5B", "5L", "5S", "Q3D"}, + dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, From bccec77e1939baf61bd4a11a361f691eafb3d1f9 Mon Sep 17 00:00:00 2001 From: henryptung Date: Thu, 23 Jun 2022 00:38:39 -0700 Subject: [PATCH 624/947] Fix Broadlink discovery for new RM Mini3 (#73822) --- homeassistant/components/broadlink/manifest.json | 3 +++ homeassistant/generated/dhcp.py | 1 + 2 files changed, 4 insertions(+) diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index 949f8add20b..4ae0e39cf04 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -18,6 +18,9 @@ }, { "macaddress": "B4430D*" + }, + { + "macaddress": "C8F742*" } ], "iot_class": "local_polling", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 9f2438aafa1..91398ed00ef 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -23,6 +23,7 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'broadlink', 'macaddress': '24DFA7*'}, {'domain': 'broadlink', 'macaddress': 'A043B0*'}, {'domain': 'broadlink', 'macaddress': 'B4430D*'}, + {'domain': 'broadlink', 'macaddress': 'C8F742*'}, {'domain': 'elkm1', 'registered_devices': True}, {'domain': 'elkm1', 'macaddress': '00409D*'}, {'domain': 'emonitor', 'hostname': 'emonitor*', 'macaddress': '0090C2*'}, From 90e1fb6ce2faadb9a35fdbe1774fce7b4456364f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 23 Jun 2022 10:48:30 +0200 Subject: [PATCH 625/947] Weather unit conversion (#73441) Co-authored-by: Erik --- homeassistant/components/demo/weather.py | 51 +- homeassistant/components/weather/__init__.py | 711 ++++++++++++++-- tests/components/accuweather/test_weather.py | 6 +- tests/components/aemet/test_weather.py | 6 +- tests/components/climacell/test_weather.py | 20 +- tests/components/demo/test_weather.py | 19 +- tests/components/ipma/test_weather.py | 4 +- tests/components/knx/test_weather.py | 4 +- tests/components/tomorrowio/test_weather.py | 6 +- tests/components/weather/test_init.py | 797 ++++++++++++++++-- .../custom_components/test/weather.py | 101 ++- 11 files changed, 1531 insertions(+), 194 deletions(-) diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 916083c5ad1..eed3e970b12 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -27,7 +27,14 @@ from homeassistant.components.weather import ( WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -77,6 +84,8 @@ def setup_platform( 1099, 0.5, TEMP_CELSIUS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, [ [ATTR_CONDITION_RAINY, 1, 22, 15, 60], [ATTR_CONDITION_RAINY, 5, 19, 8, 30], @@ -95,6 +104,8 @@ def setup_platform( 987, 4.8, TEMP_FAHRENHEIT, + PRESSURE_INHG, + SPEED_MILES_PER_HOUR, [ [ATTR_CONDITION_SNOWY, 2, -10, -15, 60], [ATTR_CONDITION_PARTLYCLOUDY, 1, -13, -14, 25], @@ -121,16 +132,20 @@ class DemoWeather(WeatherEntity): pressure, wind_speed, temperature_unit, + pressure_unit, + wind_speed_unit, forecast, ): """Initialize the Demo weather.""" self._name = name self._condition = condition - self._temperature = temperature - self._temperature_unit = temperature_unit + self._native_temperature = temperature + self._native_temperature_unit = temperature_unit self._humidity = humidity - self._pressure = pressure - self._wind_speed = wind_speed + self._native_pressure = pressure + self._native_pressure_unit = pressure_unit + self._native_wind_speed = wind_speed + self._native_wind_speed_unit = wind_speed_unit self._forecast = forecast @property @@ -144,14 +159,14 @@ class DemoWeather(WeatherEntity): return False @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" - return self._temperature + return self._native_temperature @property - def temperature_unit(self): + def native_temperature_unit(self): """Return the unit of measurement.""" - return self._temperature_unit + return self._native_temperature_unit @property def humidity(self): @@ -159,14 +174,24 @@ class DemoWeather(WeatherEntity): return self._humidity @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - return self._wind_speed + return self._native_wind_speed @property - def pressure(self): + def native_wind_speed_unit(self): + """Return the wind speed.""" + return self._native_wind_speed_unit + + @property + def native_pressure(self): """Return the pressure.""" - return self._pressure + return self._native_pressure + + @property + def native_pressure_unit(self): + """Return the pressure.""" + return self._native_pressure_unit @property def condition(self): diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 2e0f8912867..3e72c7ad931 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -1,22 +1,47 @@ """Weather component that handles meteorological data for your location.""" from __future__ import annotations +from collections.abc import Callable +from contextlib import suppress from dataclasses import dataclass from datetime import timedelta +import inspect import logging -from typing import Final, TypedDict, final +from typing import Any, Final, TypedDict, final from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, TEMP_CELSIUS -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_MILLIMETERS, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + PRESSURE_HPA, + PRESSURE_INHG, + PRESSURE_MBAR, + PRESSURE_MMHG, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.temperature import display_temp as show_temp from homeassistant.helpers.typing import ConfigType +from homeassistant.util import ( + distance as distance_util, + pressure as pressure_util, + speed as speed_util, + temperature as temperature_util, +) # mypy: allow-untyped-defs, no-check-untyped-defs @@ -40,21 +65,31 @@ ATTR_CONDITION_WINDY = "windy" ATTR_CONDITION_WINDY_VARIANT = "windy-variant" ATTR_FORECAST = "forecast" ATTR_FORECAST_CONDITION: Final = "condition" +ATTR_FORECAST_NATIVE_PRECIPITATION: Final = "native_precipitation" ATTR_FORECAST_PRECIPITATION: Final = "precipitation" ATTR_FORECAST_PRECIPITATION_PROBABILITY: Final = "precipitation_probability" +ATTR_FORECAST_NATIVE_PRESSURE: Final = "native_pressure" ATTR_FORECAST_PRESSURE: Final = "pressure" +ATTR_FORECAST_NATIVE_TEMP: Final = "native_temperature" ATTR_FORECAST_TEMP: Final = "temperature" +ATTR_FORECAST_NATIVE_TEMP_LOW: Final = "native_templow" ATTR_FORECAST_TEMP_LOW: Final = "templow" ATTR_FORECAST_TIME: Final = "datetime" ATTR_FORECAST_WIND_BEARING: Final = "wind_bearing" +ATTR_FORECAST_NATIVE_WIND_SPEED: Final = "native_wind_speed" ATTR_FORECAST_WIND_SPEED: Final = "wind_speed" ATTR_WEATHER_HUMIDITY = "humidity" ATTR_WEATHER_OZONE = "ozone" ATTR_WEATHER_PRESSURE = "pressure" +ATTR_WEATHER_PRESSURE_UNIT = "pressure_unit" ATTR_WEATHER_TEMPERATURE = "temperature" +ATTR_WEATHER_TEMPERATURE_UNIT = "temperature_unit" ATTR_WEATHER_VISIBILITY = "visibility" +ATTR_WEATHER_VISIBILITY_UNIT = "visibility_unit" ATTR_WEATHER_WIND_BEARING = "wind_bearing" ATTR_WEATHER_WIND_SPEED = "wind_speed" +ATTR_WEATHER_WIND_SPEED_UNIT = "wind_speed_unit" +ATTR_WEATHER_PRECIPITATION_UNIT = "precipitation_unit" DOMAIN = "weather" @@ -64,18 +99,83 @@ SCAN_INTERVAL = timedelta(seconds=30) ROUNDING_PRECISION = 2 +VALID_UNITS_PRESSURE: tuple[str, ...] = ( + PRESSURE_HPA, + PRESSURE_MBAR, + PRESSURE_INHG, + PRESSURE_MMHG, +) +VALID_UNITS_TEMPERATURE: tuple[str, ...] = ( + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +VALID_UNITS_PRECIPITATION: tuple[str, ...] = ( + LENGTH_MILLIMETERS, + LENGTH_INCHES, +) +VALID_UNITS_VISIBILITY: tuple[str, ...] = ( + LENGTH_KILOMETERS, + LENGTH_MILES, +) +VALID_UNITS_WIND_SPEED: tuple[str, ...] = ( + SPEED_METERS_PER_SECOND, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, +) + +UNIT_CONVERSIONS: dict[str, Callable[[float, str, str], float]] = { + ATTR_WEATHER_PRESSURE_UNIT: pressure_util.convert, + ATTR_WEATHER_TEMPERATURE_UNIT: temperature_util.convert, + ATTR_WEATHER_VISIBILITY_UNIT: distance_util.convert, + ATTR_WEATHER_PRECIPITATION_UNIT: distance_util.convert, + ATTR_WEATHER_WIND_SPEED_UNIT: speed_util.convert, +} + +VALID_UNITS: dict[str, tuple[str, ...]] = { + ATTR_WEATHER_PRESSURE_UNIT: VALID_UNITS_PRESSURE, + ATTR_WEATHER_TEMPERATURE_UNIT: VALID_UNITS_TEMPERATURE, + ATTR_WEATHER_VISIBILITY_UNIT: VALID_UNITS_VISIBILITY, + ATTR_WEATHER_PRECIPITATION_UNIT: VALID_UNITS_PRECIPITATION, + ATTR_WEATHER_WIND_SPEED_UNIT: VALID_UNITS_WIND_SPEED, +} + + +def round_temperature(temperature: float | None, precision: float) -> float | None: + """Convert temperature into preferred precision for display.""" + if temperature is None: + return None + + # Round in the units appropriate + if precision == PRECISION_HALVES: + temperature = round(temperature * 2) / 2.0 + elif precision == PRECISION_TENTHS: + temperature = round(temperature, 1) + # Integer as a fall back (PRECISION_WHOLE) + else: + temperature = round(temperature) + + return temperature + class Forecast(TypedDict, total=False): - """Typed weather forecast dict.""" + """Typed weather forecast dict. + + All attributes are in native units and old attributes kept for backwards compatibility. + """ condition: str | None datetime: str precipitation_probability: int | None + native_precipitation: float | None precipitation: float | None + native_pressure: float | None pressure: float | None + native_temperature: float | None temperature: float | None + native_templow: float | None templow: float | None wind_bearing: float | str | None + native_wind_speed: float | None wind_speed: float | None @@ -114,38 +214,219 @@ class WeatherEntity(Entity): _attr_humidity: float | None = None _attr_ozone: float | None = None _attr_precision: float - _attr_pressure: float | None = None - _attr_pressure_unit: str | None = None + _attr_pressure: float | None = ( + None # Provide backwards compatibility. Use _attr_native_pressure + ) + _attr_pressure_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_pressure_unit + ) _attr_state: None = None - _attr_temperature_unit: str - _attr_temperature: float | None - _attr_visibility: float | None = None - _attr_visibility_unit: str | None = None - _attr_precipitation_unit: str | None = None + _attr_temperature: float | None = ( + None # Provide backwards compatibility. Use _attr_native_temperature + ) + _attr_temperature_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_temperature_unit + ) + _attr_visibility: float | None = ( + None # Provide backwards compatibility. Use _attr_native_visibility + ) + _attr_visibility_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_visibility_unit + ) + _attr_precipitation_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_precipitation_unit + ) _attr_wind_bearing: float | str | None = None - _attr_wind_speed: float | None = None - _attr_wind_speed_unit: str | None = None + _attr_wind_speed: float | None = ( + None # Provide backwards compatibility. Use _attr_native_wind_speed + ) + _attr_wind_speed_unit: str | None = ( + None # Provide backwards compatibility. Use _attr_native_wind_speed_unit + ) + + _attr_native_pressure: float | None = None + _attr_native_pressure_unit: str | None = None + _attr_native_temperature: float | None = None + _attr_native_temperature_unit: str | None = None + _attr_native_visibility: float | None = None + _attr_native_visibility_unit: str | None = None + _attr_native_precipitation_unit: str | None = None + _attr_native_wind_speed: float | None = None + _attr_native_wind_speed_unit: str | None = None + + _weather_option_temperature_unit: str | None = None + _weather_option_pressure_unit: str | None = None + _weather_option_visibility_unit: str | None = None + _weather_option_precipitation_unit: str | None = None + _weather_option_wind_speed_unit: str | None = None + + def __init_subclass__(cls, **kwargs: Any) -> None: + """Post initialisation processing.""" + super().__init_subclass__(**kwargs) + _reported = False + if any( + method in cls.__dict__ + for method in ( + "_attr_temperature", + "temperature", + "_attr_temperature_unit", + "temperature_unit", + "_attr_pressure", + "pressure", + "_attr_pressure_unit", + "pressure_unit", + "_attr_wind_speed", + "wind_speed", + "_attr_wind_speed_unit", + "wind_speed_unit", + "_attr_visibility", + "visibility", + "_attr_visibility_unit", + "visibility_unit", + "_attr_precipitation_unit", + "precipitation_unit", + ) + ): + if _reported is False: + module = inspect.getmodule(cls) + _reported = True + if ( + module + and module.__file__ + and "custom_components" in module.__file__ + ): + report_issue = "report it to the custom component author." + else: + report_issue = ( + "create a bug report at " + "https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" + ) + _LOGGER.warning( + "%s::%s is overriding deprecated methods on an instance of " + "WeatherEntity, this is not valid and will be unsupported " + "from Home Assistant 2022.10. Please %s", + cls.__module__, + cls.__name__, + report_issue, + ) + + async def async_internal_added_to_hass(self) -> None: + """Call when the sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self.async_registry_entry_updated() @property def temperature(self) -> float | None: - """Return the platform temperature in native units (i.e. not converted).""" + """Return the temperature for backward compatibility. + + Should not be set by integrations. + """ return self._attr_temperature @property - def temperature_unit(self) -> str: + def native_temperature(self) -> float | None: + """Return the temperature in native units.""" + if (temperature := self.temperature) is not None: + return temperature + + return self._attr_native_temperature + + @property + def native_temperature_unit(self) -> str | None: """Return the native unit of measurement for temperature.""" + if (temperature_unit := self.temperature_unit) is not None: + return temperature_unit + + return self._attr_native_temperature_unit + + @property + def temperature_unit(self) -> str | None: + """Return the temperature unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_temperature_unit + @final + @property + def _default_temperature_unit(self) -> str: + """Return the default unit of measurement for temperature. + + Should not be set by integrations. + """ + return self.hass.config.units.temperature_unit + + @final + @property + def _temperature_unit(self) -> str: + """Return the converted unit of measurement for temperature. + + Should not be set by integrations. + """ + if ( + weather_option_temperature_unit := self._weather_option_temperature_unit + ) is not None: + return weather_option_temperature_unit + + return self._default_temperature_unit + @property def pressure(self) -> float | None: - """Return the pressure in native units.""" + """Return the pressure for backward compatibility. + + Should not be set by integrations. + """ return self._attr_pressure @property - def pressure_unit(self) -> str | None: + def native_pressure(self) -> float | None: + """Return the pressure in native units.""" + if (pressure := self.pressure) is not None: + return pressure + + return self._attr_native_pressure + + @property + def native_pressure_unit(self) -> str | None: """Return the native unit of measurement for pressure.""" + if (pressure_unit := self.pressure_unit) is not None: + return pressure_unit + + return self._attr_native_pressure_unit + + @property + def pressure_unit(self) -> str | None: + """Return the pressure unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_pressure_unit + @final + @property + def _default_pressure_unit(self) -> str: + """Return the default unit of measurement for pressure. + + Should not be set by integrations. + """ + return PRESSURE_HPA if self.hass.config.units.is_metric else PRESSURE_INHG + + @final + @property + def _pressure_unit(self) -> str: + """Return the converted unit of measurement for pressure. + + Should not be set by integrations. + """ + if ( + weather_option_pressure_unit := self._weather_option_pressure_unit + ) is not None: + return weather_option_pressure_unit + + return self._default_pressure_unit + @property def humidity(self) -> float | None: """Return the humidity in native units.""" @@ -153,14 +434,63 @@ class WeatherEntity(Entity): @property def wind_speed(self) -> float | None: - """Return the wind speed in native units.""" + """Return the wind_speed for backward compatibility. + + Should not be set by integrations. + """ return self._attr_wind_speed @property - def wind_speed_unit(self) -> str | None: + def native_wind_speed(self) -> float | None: + """Return the wind speed in native units.""" + if (wind_speed := self.wind_speed) is not None: + return wind_speed + + return self._attr_native_wind_speed + + @property + def native_wind_speed_unit(self) -> str | None: """Return the native unit of measurement for wind speed.""" + if (wind_speed_unit := self.wind_speed_unit) is not None: + return wind_speed_unit + + return self._attr_native_wind_speed_unit + + @property + def wind_speed_unit(self) -> str | None: + """Return the wind_speed unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_wind_speed_unit + @final + @property + def _default_wind_speed_unit(self) -> str: + """Return the default unit of measurement for wind speed. + + Should not be set by integrations. + """ + return ( + SPEED_KILOMETERS_PER_HOUR + if self.hass.config.units.is_metric + else SPEED_MILES_PER_HOUR + ) + + @final + @property + def _wind_speed_unit(self) -> str: + """Return the converted unit of measurement for wind speed. + + Should not be set by integrations. + """ + if ( + weather_option_wind_speed_unit := self._weather_option_wind_speed_unit + ) is not None: + return weather_option_wind_speed_unit + + return self._default_wind_speed_unit + @property def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" @@ -173,24 +503,103 @@ class WeatherEntity(Entity): @property def visibility(self) -> float | None: - """Return the visibility in native units.""" + """Return the visibility for backward compatibility. + + Should not be set by integrations. + """ return self._attr_visibility @property - def visibility_unit(self) -> str | None: + def native_visibility(self) -> float | None: + """Return the visibility in native units.""" + if (visibility := self.visibility) is not None: + return visibility + + return self._attr_native_visibility + + @property + def native_visibility_unit(self) -> str | None: """Return the native unit of measurement for visibility.""" + if (visibility_unit := self.visibility_unit) is not None: + return visibility_unit + + return self._attr_native_visibility_unit + + @property + def visibility_unit(self) -> str | None: + """Return the visibility unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_visibility_unit + @final + @property + def _default_visibility_unit(self) -> str: + """Return the default unit of measurement for visibility. + + Should not be set by integrations. + """ + return self.hass.config.units.length_unit + + @final + @property + def _visibility_unit(self) -> str: + """Return the converted unit of measurement for visibility. + + Should not be set by integrations. + """ + if ( + weather_option_visibility_unit := self._weather_option_visibility_unit + ) is not None: + return weather_option_visibility_unit + + return self._default_visibility_unit + @property def forecast(self) -> list[Forecast] | None: """Return the forecast in native units.""" return self._attr_forecast @property - def precipitation_unit(self) -> str | None: + def native_precipitation_unit(self) -> str | None: """Return the native unit of measurement for accumulated precipitation.""" + if (precipitation_unit := self.precipitation_unit) is not None: + return precipitation_unit + + return self._attr_native_precipitation_unit + + @property + def precipitation_unit(self) -> str | None: + """Return the precipitation unit for backward compatibility. + + Should not be set by integrations. + """ return self._attr_precipitation_unit + @final + @property + def _default_precipitation_unit(self) -> str: + """Return the default unit of measurement for precipitation. + + Should not be set by integrations. + """ + return self.hass.config.units.accumulated_precipitation_unit + + @final + @property + def _precipitation_unit(self) -> str: + """Return the converted unit of measurement for precipitation. + + Should not be set by integrations. + """ + if ( + weather_option_precipitation_unit := self._weather_option_precipitation_unit + ) is not None: + return weather_option_precipitation_unit + + return self._default_precipitation_unit + @property def precision(self) -> float: """Return the precision of the temperature value, after unit conversion.""" @@ -198,7 +607,7 @@ class WeatherEntity(Entity): return self._attr_precision return ( PRECISION_TENTHS - if self.hass.config.units.temperature_unit == TEMP_CELSIUS + if self._temperature_unit == TEMP_CELSIUS else PRECISION_WHOLE ) @@ -207,13 +616,24 @@ class WeatherEntity(Entity): def state_attributes(self): """Return the state attributes, converted from native units to user-configured units.""" data = {} - if self.temperature is not None: - data[ATTR_WEATHER_TEMPERATURE] = show_temp( - self.hass, - self.temperature, - self.temperature_unit, - self.precision, - ) + + precision = self.precision + + if (temperature := self.native_temperature) is not None: + from_unit = self.native_temperature_unit or self._default_temperature_unit + to_unit = self._temperature_unit + try: + temperature_f = float(temperature) + value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + temperature_f, from_unit, to_unit + ) + data[ATTR_WEATHER_TEMPERATURE] = round_temperature( + value_temp, precision + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_TEMPERATURE] = temperature + + data[ATTR_WEATHER_TEMPERATURE_UNIT] = self._temperature_unit if (humidity := self.humidity) is not None: data[ATTR_WEATHER_HUMIDITY] = round(humidity) @@ -221,77 +641,159 @@ class WeatherEntity(Entity): if (ozone := self.ozone) is not None: data[ATTR_WEATHER_OZONE] = ozone - if (pressure := self.pressure) is not None: - if (unit := self.pressure_unit) is not None: - pressure = round( - self.hass.config.units.pressure(pressure, unit), ROUNDING_PRECISION + if (pressure := self.native_pressure) is not None: + from_unit = self.native_pressure_unit or self._default_pressure_unit + to_unit = self._pressure_unit + try: + pressure_f = float(pressure) + value_pressure = UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( + pressure_f, from_unit, to_unit ) - data[ATTR_WEATHER_PRESSURE] = pressure + data[ATTR_WEATHER_PRESSURE] = round(value_pressure, ROUNDING_PRECISION) + except (TypeError, ValueError): + data[ATTR_WEATHER_PRESSURE] = pressure + + data[ATTR_WEATHER_PRESSURE_UNIT] = self._pressure_unit if (wind_bearing := self.wind_bearing) is not None: data[ATTR_WEATHER_WIND_BEARING] = wind_bearing - if (wind_speed := self.wind_speed) is not None: - if (unit := self.wind_speed_unit) is not None: - wind_speed = round( - self.hass.config.units.wind_speed(wind_speed, unit), - ROUNDING_PRECISION, + if (wind_speed := self.native_wind_speed) is not None: + from_unit = self.native_wind_speed_unit or self._default_wind_speed_unit + to_unit = self._wind_speed_unit + try: + wind_speed_f = float(wind_speed) + value_wind_speed = UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + wind_speed_f, from_unit, to_unit ) - data[ATTR_WEATHER_WIND_SPEED] = wind_speed + data[ATTR_WEATHER_WIND_SPEED] = round( + value_wind_speed, ROUNDING_PRECISION + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_WIND_SPEED] = wind_speed - if (visibility := self.visibility) is not None: - if (unit := self.visibility_unit) is not None: - visibility = round( - self.hass.config.units.length(visibility, unit), ROUNDING_PRECISION + data[ATTR_WEATHER_WIND_SPEED_UNIT] = self._wind_speed_unit + + if (visibility := self.native_visibility) is not None: + from_unit = self.native_visibility_unit or self._default_visibility_unit + to_unit = self._visibility_unit + try: + visibility_f = float(visibility) + value_visibility = UNIT_CONVERSIONS[ATTR_WEATHER_VISIBILITY_UNIT]( + visibility_f, from_unit, to_unit ) - data[ATTR_WEATHER_VISIBILITY] = visibility + data[ATTR_WEATHER_VISIBILITY] = round( + value_visibility, ROUNDING_PRECISION + ) + except (TypeError, ValueError): + data[ATTR_WEATHER_VISIBILITY] = visibility + + data[ATTR_WEATHER_VISIBILITY_UNIT] = self._visibility_unit + data[ATTR_WEATHER_PRECIPITATION_UNIT] = self._precipitation_unit if self.forecast is not None: forecast = [] for forecast_entry in self.forecast: forecast_entry = dict(forecast_entry) - forecast_entry[ATTR_FORECAST_TEMP] = show_temp( - self.hass, - forecast_entry[ATTR_FORECAST_TEMP], - self.temperature_unit, - self.precision, + + temperature = forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP, forecast_entry.get(ATTR_FORECAST_TEMP) ) - if ATTR_FORECAST_TEMP_LOW in forecast_entry: - forecast_entry[ATTR_FORECAST_TEMP_LOW] = show_temp( - self.hass, - forecast_entry[ATTR_FORECAST_TEMP_LOW], - self.temperature_unit, - self.precision, + + from_temp_unit = ( + self.native_temperature_unit or self._default_temperature_unit + ) + to_temp_unit = self._temperature_unit + + if temperature is None: + forecast_entry[ATTR_FORECAST_TEMP] = None + else: + with suppress(TypeError, ValueError): + temperature_f = float(temperature) + value_temp = UNIT_CONVERSIONS[ATTR_WEATHER_TEMPERATURE_UNIT]( + temperature_f, + from_temp_unit, + to_temp_unit, + ) + forecast_entry[ATTR_FORECAST_TEMP] = round_temperature( + value_temp, precision + ) + + if forecast_temp_low := forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP_LOW, + forecast_entry.get(ATTR_FORECAST_TEMP_LOW), + ): + with suppress(TypeError, ValueError): + forecast_temp_low_f = float(forecast_temp_low) + value_temp_low = UNIT_CONVERSIONS[ + ATTR_WEATHER_TEMPERATURE_UNIT + ]( + forecast_temp_low_f, + from_temp_unit, + to_temp_unit, + ) + + forecast_entry[ATTR_FORECAST_TEMP_LOW] = round_temperature( + value_temp_low, precision + ) + + if forecast_pressure := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRESSURE, + forecast_entry.get(ATTR_FORECAST_PRESSURE), + ): + from_pressure_unit = ( + self.native_pressure_unit or self._default_pressure_unit ) - if ( - native_pressure := forecast_entry.get(ATTR_FORECAST_PRESSURE) - ) is not None: - if (unit := self.pressure_unit) is not None: - pressure = round( - self.hass.config.units.pressure(native_pressure, unit), - ROUNDING_PRECISION, - ) - forecast_entry[ATTR_FORECAST_PRESSURE] = pressure - if ( - native_wind_speed := forecast_entry.get(ATTR_FORECAST_WIND_SPEED) - ) is not None: - if (unit := self.wind_speed_unit) is not None: - wind_speed = round( - self.hass.config.units.wind_speed(native_wind_speed, unit), - ROUNDING_PRECISION, - ) - forecast_entry[ATTR_FORECAST_WIND_SPEED] = wind_speed - if ( - native_precip := forecast_entry.get(ATTR_FORECAST_PRECIPITATION) - ) is not None: - if (unit := self.precipitation_unit) is not None: - precipitation = round( - self.hass.config.units.accumulated_precipitation( - native_precip, unit + to_pressure_unit = self._pressure_unit + with suppress(TypeError, ValueError): + forecast_pressure_f = float(forecast_pressure) + forecast_entry[ATTR_FORECAST_PRESSURE] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRESSURE_UNIT]( + forecast_pressure_f, + from_pressure_unit, + to_pressure_unit, + ), + ROUNDING_PRECISION, + ) + + if forecast_wind_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_SPEED, + forecast_entry.get(ATTR_FORECAST_WIND_SPEED), + ): + from_wind_speed_unit = ( + self.native_wind_speed_unit or self._default_wind_speed_unit + ) + to_wind_speed_unit = self._wind_speed_unit + with suppress(TypeError, ValueError): + forecast_wind_speed_f = float(forecast_wind_speed) + forecast_entry[ATTR_FORECAST_WIND_SPEED] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_WIND_SPEED_UNIT]( + forecast_wind_speed_f, + from_wind_speed_unit, + to_wind_speed_unit, + ), + ROUNDING_PRECISION, + ) + + if forecast_precipitation := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRECIPITATION, + forecast_entry.get(ATTR_FORECAST_PRECIPITATION), + ): + from_precipitation_unit = ( + self.native_precipitation_unit + or self._default_precipitation_unit + ) + to_precipitation_unit = self._precipitation_unit + with suppress(TypeError, ValueError): + forecast_precipitation_f = float(forecast_precipitation) + forecast_entry[ATTR_FORECAST_PRECIPITATION] = round( + UNIT_CONVERSIONS[ATTR_WEATHER_PRECIPITATION_UNIT]( + forecast_precipitation_f, + from_precipitation_unit, + to_precipitation_unit, ), ROUNDING_PRECISION, ) - forecast_entry[ATTR_FORECAST_PRECIPITATION] = precipitation forecast.append(forecast_entry) @@ -309,3 +811,44 @@ class WeatherEntity(Entity): def condition(self) -> str | None: """Return the current condition.""" return self._attr_condition + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + assert self.registry_entry + self._weather_option_temperature_unit = None + self._weather_option_pressure_unit = None + self._weather_option_precipitation_unit = None + self._weather_option_wind_speed_unit = None + self._weather_option_visibility_unit = None + if weather_options := self.registry_entry.options.get(DOMAIN): + if ( + custom_unit_temperature := weather_options.get( + ATTR_WEATHER_TEMPERATURE_UNIT + ) + ) and custom_unit_temperature in VALID_UNITS[ATTR_WEATHER_TEMPERATURE_UNIT]: + self._weather_option_temperature_unit = custom_unit_temperature + if ( + custom_unit_pressure := weather_options.get(ATTR_WEATHER_PRESSURE_UNIT) + ) and custom_unit_pressure in VALID_UNITS[ATTR_WEATHER_PRESSURE_UNIT]: + self._weather_option_pressure_unit = custom_unit_pressure + if ( + custom_unit_precipitation := weather_options.get( + ATTR_WEATHER_PRECIPITATION_UNIT + ) + ) and custom_unit_precipitation in VALID_UNITS[ + ATTR_WEATHER_PRECIPITATION_UNIT + ]: + self._weather_option_precipitation_unit = custom_unit_precipitation + if ( + custom_unit_wind_speed := weather_options.get( + ATTR_WEATHER_WIND_SPEED_UNIT + ) + ) and custom_unit_wind_speed in VALID_UNITS[ATTR_WEATHER_WIND_SPEED_UNIT]: + self._weather_option_wind_speed_unit = custom_unit_wind_speed + if ( + custom_unit_visibility := weather_options.get( + ATTR_WEATHER_VISIBILITY_UNIT + ) + ) and custom_unit_visibility in VALID_UNITS[ATTR_WEATHER_VISIBILITY_UNIT]: + self._weather_option_visibility_unit = custom_unit_visibility diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index 02ace5d3f1d..97f588cb477 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -46,7 +46,7 @@ async def test_weather_without_forecast(hass): assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6 assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 4.03 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5 # 4.03 m/s -> km/h assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION entry = registry.async_get("weather.home") @@ -68,7 +68,7 @@ async def test_weather_with_forecast(hass): assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6 assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 4.03 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5 # 4.03 m/s -> km/h assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" @@ -78,7 +78,7 @@ async def test_weather_with_forecast(hass): assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 15.4 assert forecast.get(ATTR_FORECAST_TIME) == "2020-07-26T05:00:00+00:00" assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 166 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 3.61 + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 13.0 # 3.61 m/s -> km/h entry = registry.async_get("weather.home") assert entry diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index 809b61e0bda..ee021cc7f6d 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -42,10 +42,10 @@ async def test_aemet_weather(hass): assert state.state == ATTR_CONDITION_SNOWY assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 99.0 - assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 100440.0 + assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 - assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 4.17 + assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None @@ -57,7 +57,7 @@ async def test_aemet_weather(hass): == dt_util.parse_datetime("2021-01-10 00:00:00+00:00").isoformat() ) assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 45.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 5.56 + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 20.0 # 5.56 m/s -> km/h state = hass.states.get("weather.aemet_hourly") assert state is None diff --git a/tests/components/climacell/test_weather.py b/tests/components/climacell/test_weather.py index 3c02f6b9b1f..593caa7755f 100644 --- a/tests/components/climacell/test_weather.py +++ b/tests/components/climacell/test_weather.py @@ -132,7 +132,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-12T00:00:00-08:00", - ATTR_FORECAST_PRECIPITATION: 0.0457, + ATTR_FORECAST_PRECIPITATION: 0.05, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 25, ATTR_FORECAST_TEMP: 19.9, ATTR_FORECAST_TEMP_LOW: 12.1, @@ -148,7 +148,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, ATTR_FORECAST_TIME: "2021-03-14T00:00:00-08:00", - ATTR_FORECAST_PRECIPITATION: 1.0744, + ATTR_FORECAST_PRECIPITATION: 1.07, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 75, ATTR_FORECAST_TEMP: 6.4, ATTR_FORECAST_TEMP_LOW: 3.2, @@ -156,7 +156,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, ATTR_FORECAST_TIME: "2021-03-15T00:00:00-07:00", # DST starts - ATTR_FORECAST_PRECIPITATION: 7.3050, + ATTR_FORECAST_PRECIPITATION: 7.3, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, ATTR_FORECAST_TEMP: 1.2, ATTR_FORECAST_TEMP_LOW: 0.2, @@ -164,7 +164,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-16T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 0.0051, + ATTR_FORECAST_PRECIPITATION: 0.01, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 5, ATTR_FORECAST_TEMP: 6.1, ATTR_FORECAST_TEMP_LOW: -1.6, @@ -188,7 +188,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-19T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 0.1778, + ATTR_FORECAST_PRECIPITATION: 0.18, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 45, ATTR_FORECAST_TEMP: 9.4, ATTR_FORECAST_TEMP_LOW: 4.7, @@ -196,7 +196,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_RAINY, ATTR_FORECAST_TIME: "2021-03-20T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 1.2319, + ATTR_FORECAST_PRECIPITATION: 1.23, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 55, ATTR_FORECAST_TEMP: 5.0, ATTR_FORECAST_TEMP_LOW: 3.1, @@ -204,7 +204,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_CLOUDY, ATTR_FORECAST_TIME: "2021-03-21T00:00:00-07:00", - ATTR_FORECAST_PRECIPITATION: 0.0432, + ATTR_FORECAST_PRECIPITATION: 0.04, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 20, ATTR_FORECAST_TEMP: 6.8, ATTR_FORECAST_TEMP_LOW: 0.9, @@ -213,11 +213,11 @@ async def test_v3_weather( assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "ClimaCell - Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 24 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 52.625 - assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1028.1246 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 1028.12 assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 6.6 - assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.9940 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.99 assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 320.31 - assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.6289 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.63 assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758 assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" diff --git a/tests/components/demo/test_weather.py b/tests/components/demo/test_weather.py index db3f3441df1..8c93219f8e6 100644 --- a/tests/components/demo/test_weather.py +++ b/tests/components/demo/test_weather.py @@ -36,7 +36,7 @@ async def test_attributes(hass): assert data.get(ATTR_WEATHER_TEMPERATURE) == 21.6 assert data.get(ATTR_WEATHER_HUMIDITY) == 92 assert data.get(ATTR_WEATHER_PRESSURE) == 1099 - assert data.get(ATTR_WEATHER_WIND_SPEED) == 0.5 + assert data.get(ATTR_WEATHER_WIND_SPEED) == 1.8 # 0.5 m/s -> km/h assert data.get(ATTR_WEATHER_WIND_BEARING) is None assert data.get(ATTR_WEATHER_OZONE) is None assert data.get(ATTR_ATTRIBUTION) == "Powered by Home Assistant" @@ -53,20 +53,3 @@ async def test_attributes(hass): data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 100 ) assert len(data.get(ATTR_FORECAST)) == 7 - - -async def test_temperature_convert(hass): - """Test temperature conversion.""" - assert await async_setup_component( - hass, weather.DOMAIN, {"weather": {"platform": "demo"}} - ) - hass.config.units = METRIC_SYSTEM - await hass.async_block_till_done() - - state = hass.states.get("weather.demo_weather_north") - assert state is not None - - assert state.state == "rainy" - - data = state.attributes - assert data.get(ATTR_WEATHER_TEMPERATURE) == -24.4 diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py index 7ed1c4d3723..e6469043474 100644 --- a/tests/components/ipma/test_weather.py +++ b/tests/components/ipma/test_weather.py @@ -198,7 +198,7 @@ async def test_daily_forecast(hass): assert forecast.get(ATTR_FORECAST_TEMP) == 16.2 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6 assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == "100.0" - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "10" + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 10.0 assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" @@ -222,5 +222,5 @@ async def test_hourly_forecast(hass): assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy" assert forecast.get(ATTR_FORECAST_TEMP) == 7.7 assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 80.0 - assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "32.7" + assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 32.7 assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S" diff --git a/tests/components/knx/test_weather.py b/tests/components/knx/test_weather.py index 21d80248b97..c4a7c5de7a4 100644 --- a/tests/components/knx/test_weather.py +++ b/tests/components/knx/test_weather.py @@ -85,8 +85,8 @@ async def test_weather(hass: HomeAssistant, knx: KNXTestKit): state = hass.states.get("weather.test") assert state.attributes["temperature"] == 0.4 assert state.attributes["wind_bearing"] == 270 - assert state.attributes["wind_speed"] == 1.4400000000000002 - assert state.attributes["pressure"] == 980.5824 + assert state.attributes["wind_speed"] == 1.44 + assert state.attributes["pressure"] == 980.58 assert state.state is ATTR_CONDITION_SUNNY # update from KNX - set rain alarm diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index f9c7e00b7cd..52c29161452 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -99,13 +99,13 @@ async def test_v4_weather(hass: HomeAssistant) -> None: ATTR_FORECAST_TEMP: 45.9, ATTR_FORECAST_TEMP_LOW: 26.1, ATTR_FORECAST_WIND_BEARING: 239.6, - ATTR_FORECAST_WIND_SPEED: 9.49, + ATTR_FORECAST_WIND_SPEED: 34.16, # 9.49 m/s -> km/h } assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 - assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 3035.0 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 30.35 assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 44.1 assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 8.15 assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 - assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 9.33 + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 33.59 # 9.33 m/s ->km/h diff --git a/tests/components/weather/test_init.py b/tests/components/weather/test_init.py index 9849a6abe18..814d3b7857c 100644 --- a/tests/components/weather/test_init.py +++ b/tests/components/weather/test_init.py @@ -1,4 +1,6 @@ """The test for weather entity.""" +from datetime import datetime + import pytest from pytest import approx @@ -9,19 +11,44 @@ from homeassistant.components.weather import ( ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_WIND_BEARING, ATTR_FORECAST_WIND_SPEED, + ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_VISIBILITY_UNIT, + ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, + ROUNDING_PRECISION, + Forecast, + WeatherEntity, + round_temperature, ) from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + LENGTH_INCHES, + LENGTH_KILOMETERS, LENGTH_MILES, LENGTH_MILLIMETERS, + PRECISION_HALVES, + PRECISION_TENTHS, + PRECISION_WHOLE, + PRESSURE_HPA, PRESSURE_INHG, + PRESSURE_PA, + SPEED_KILOMETERS_PER_HOUR, SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + TEMP_CELSIUS, TEMP_FAHRENHEIT, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from homeassistant.util.distance import convert as convert_distance from homeassistant.util.pressure import convert as convert_pressure @@ -29,11 +56,75 @@ from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM +from tests.testing_config.custom_components.test import weather as WeatherPlatform -async def create_entity(hass, **kwargs): + +class MockWeatherEntity(WeatherEntity): + """Mock a Weather Entity.""" + + def __init__(self) -> None: + """Initiate Entity.""" + super().__init__() + self._attr_condition = ATTR_CONDITION_SUNNY + self._attr_native_precipitation_unit = LENGTH_MILLIMETERS + self._attr_native_pressure = 10 + self._attr_native_pressure_unit = PRESSURE_HPA + self._attr_native_temperature = 20 + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_native_visibility = 30 + self._attr_native_visibility_unit = LENGTH_KILOMETERS + self._attr_native_wind_speed = 3 + self._attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + self._attr_forecast = [ + Forecast( + datetime=datetime(2022, 6, 20, 20, 00, 00), + native_precipitation=1, + native_temperature=20, + ) + ] + + +class MockWeatherEntityPrecision(WeatherEntity): + """Mock a Weather Entity with precision.""" + + def __init__(self) -> None: + """Initiate Entity.""" + super().__init__() + self._attr_condition = ATTR_CONDITION_SUNNY + self._attr_native_temperature = 20.3 + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_precision = PRECISION_HALVES + + +class MockWeatherEntityCompat(WeatherEntity): + """Mock a Weather Entity using old attributes.""" + + def __init__(self) -> None: + """Initiate Entity.""" + super().__init__() + self._attr_condition = ATTR_CONDITION_SUNNY + self._attr_precipitation_unit = LENGTH_MILLIMETERS + self._attr_pressure = 10 + self._attr_pressure_unit = PRESSURE_HPA + self._attr_temperature = 20 + self._attr_temperature_unit = TEMP_CELSIUS + self._attr_visibility = 30 + self._attr_visibility_unit = LENGTH_KILOMETERS + self._attr_wind_speed = 3 + self._attr_wind_speed_unit = SPEED_METERS_PER_SECOND + self._attr_forecast = [ + Forecast( + datetime=datetime(2022, 6, 20, 20, 00, 00), + precipitation=1, + temperature=20, + ) + ] + + +async def create_entity(hass: HomeAssistant, **kwargs): """Create the weather entity to run tests on.""" - kwargs = {"temperature": None, "temperature_unit": None, **kwargs} - platform = getattr(hass.components, "test.weather") + kwargs = {"native_temperature": None, "native_temperature_unit": None, **kwargs} + platform: WeatherPlatform = getattr(hass.components, "test.weather") platform.init(empty=True) platform.ENTITIES.append( platform.MockWeatherMockForecast( @@ -49,145 +140,741 @@ async def create_entity(hass, **kwargs): return entity0 -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_temperature_conversion( - hass, +@pytest.mark.parametrize("native_unit", (TEMP_FAHRENHEIT, TEMP_CELSIUS)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), +) +async def test_temperature( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test temperature conversion.""" + """Test temperature.""" hass.config.units = unit_system native_value = 38 - native_unit = TEMP_FAHRENHEIT + state_value = convert_temperature(native_value, native_unit, state_unit) entity0 = await create_entity( - hass, temperature=native_value, temperature_unit=native_unit + hass, native_temperature=native_value, native_temperature_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_temperature( - native_value, native_unit, unit_system.temperature_unit - ) + expected = state_value assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( expected, rel=0.1 ) + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit assert float(forecast[ATTR_FORECAST_TEMP]) == approx(expected, rel=0.1) assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == approx(expected, rel=0.1) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_pressure_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((TEMP_CELSIUS, METRIC_SYSTEM), (TEMP_FAHRENHEIT, IMPERIAL_SYSTEM)), +) +async def test_temperature_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test pressure conversion.""" + """Test temperature when the entity does not declare a native unit.""" hass.config.units = unit_system - native_value = 30 - native_unit = PRESSURE_INHG + native_value = 38 + state_value = native_value entity0 = await create_entity( - hass, pressure=native_value, pressure_unit=native_unit + hass, native_temperature=native_value, native_temperature_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + expected, rel=0.1 + ) + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == state_unit + assert float(forecast[ATTR_FORECAST_TEMP]) == approx(expected, rel=0.1) + assert float(forecast[ATTR_FORECAST_TEMP_LOW]) == approx(expected, rel=0.1) + + +@pytest.mark.parametrize("native_unit", (PRESSURE_INHG, PRESSURE_INHG)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), +) +async def test_pressure( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test pressure.""" + hass.config.units = unit_system + native_value = 30 + state_value = convert_pressure(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_pressure=native_value, native_pressure_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_pressure(native_value, native_unit, unit_system.pressure_unit) + expected = state_value assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected, rel=1e-2) assert float(forecast[ATTR_FORECAST_PRESSURE]) == approx(expected, rel=1e-2) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_wind_speed_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ((PRESSURE_HPA, METRIC_SYSTEM), (PRESSURE_INHG, IMPERIAL_SYSTEM)), +) +async def test_pressure_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test wind speed conversion.""" + """Test pressure when the entity does not declare a native unit.""" hass.config.units = unit_system - native_value = 10 - native_unit = SPEED_METERS_PER_SECOND + native_value = 30 + state_value = native_value entity0 = await create_entity( - hass, wind_speed=native_value, wind_speed_unit=native_unit + hass, native_pressure=native_value, native_pressure_unit=native_unit + ) + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected, rel=1e-2) + assert float(forecast[ATTR_FORECAST_PRESSURE]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize( + "native_unit", + (SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, SPEED_METERS_PER_SECOND), +) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), + (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + ), +) +async def test_wind_speed( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test wind speed.""" + hass.config.units = unit_system + native_value = 10 + state_value = convert_speed(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_speed(native_value, native_unit, unit_system.wind_speed_unit) + expected = state_value assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( expected, rel=1e-2 ) assert float(forecast[ATTR_FORECAST_WIND_SPEED]) == approx(expected, rel=1e-2) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_visibility_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (SPEED_KILOMETERS_PER_HOUR, METRIC_SYSTEM), + (SPEED_MILES_PER_HOUR, IMPERIAL_SYSTEM), + ), +) +async def test_wind_speed_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test visibility conversion.""" + """Test wind speed when the entity does not declare a native unit.""" hass.config.units = unit_system native_value = 10 - native_unit = LENGTH_MILES + state_value = native_value entity0 = await create_entity( - hass, visibility=native_value, visibility_unit=native_unit + hass, native_wind_speed=native_value, native_wind_speed_unit=native_unit ) state = hass.states.get(entity0.entity_id) - expected = convert_distance(native_value, native_unit, unit_system.length_unit) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + expected, rel=1e-2 + ) + assert float(forecast[ATTR_FORECAST_WIND_SPEED]) == approx(expected, rel=1e-2) + + +@pytest.mark.parametrize("native_unit", (LENGTH_MILES, LENGTH_KILOMETERS)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_KILOMETERS, METRIC_SYSTEM), + (LENGTH_MILES, IMPERIAL_SYSTEM), + ), +) +async def test_visibility( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test visibility.""" + hass.config.units = unit_system + native_value = 10 + state_value = convert_distance(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_visibility=native_value, native_visibility_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + expected = state_value assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( expected, rel=1e-2 ) -@pytest.mark.parametrize("unit_system", [IMPERIAL_SYSTEM, METRIC_SYSTEM]) -async def test_precipitation_conversion( - hass, +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_KILOMETERS, METRIC_SYSTEM), + (LENGTH_MILES, IMPERIAL_SYSTEM), + ), +) +async def test_visibility_no_unit( + hass: HomeAssistant, enable_custom_integrations, + native_unit: str, + state_unit: str, unit_system, ): - """Test precipitation conversion.""" + """Test visibility when the entity does not declare a native unit.""" hass.config.units = unit_system - native_value = 30 - native_unit = LENGTH_MILLIMETERS + native_value = 10 + state_value = native_value entity0 = await create_entity( - hass, precipitation=native_value, precipitation_unit=native_unit + hass, native_visibility=native_value, native_visibility_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + expected = state_value + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( + expected, rel=1e-2 + ) + + +@pytest.mark.parametrize("native_unit", (LENGTH_INCHES, LENGTH_MILLIMETERS)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_MILLIMETERS, METRIC_SYSTEM), + (LENGTH_INCHES, IMPERIAL_SYSTEM), + ), +) +async def test_precipitation( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test precipitation.""" + hass.config.units = unit_system + native_value = 30 + state_value = convert_distance(native_value, native_unit, state_unit) + + entity0 = await create_entity( + hass, native_precipitation=native_value, native_precipitation_unit=native_unit ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - expected = convert_distance( - native_value, native_unit, unit_system.accumulated_precipitation_unit - ) + expected = state_value assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx(expected, rel=1e-2) +@pytest.mark.parametrize("native_unit", (None,)) +@pytest.mark.parametrize( + "state_unit, unit_system", + ( + (LENGTH_MILLIMETERS, METRIC_SYSTEM), + (LENGTH_INCHES, IMPERIAL_SYSTEM), + ), +) +async def test_precipitation_no_unit( + hass: HomeAssistant, + enable_custom_integrations, + native_unit: str, + state_unit: str, + unit_system, +): + """Test precipitation when the entity does not declare a native unit.""" + hass.config.units = unit_system + native_value = 30 + state_value = native_value + + entity0 = await create_entity( + hass, native_precipitation=native_value, native_precipitation_unit=native_unit + ) + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected = state_value + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx(expected, rel=1e-2) + + +async def test_wind_bearing_and_ozone( + hass: HomeAssistant, + enable_custom_integrations, +): + """Test wind bearing.""" + wind_bearing_value = 180 + ozone_value = 10 + + entity0 = await create_entity( + hass, wind_bearing=wind_bearing_value, ozone=ozone_value + ) + + state = hass.states.get(entity0.entity_id) + assert float(state.attributes[ATTR_WEATHER_WIND_BEARING]) == 180 + assert float(state.attributes[ATTR_WEATHER_OZONE]) == 10 + + async def test_none_forecast( - hass, + hass: HomeAssistant, enable_custom_integrations, ): """Test that conversion with None values succeeds.""" entity0 = await create_entity( hass, - pressure=None, - pressure_unit=PRESSURE_INHG, - wind_speed=None, - wind_speed_unit=SPEED_METERS_PER_SECOND, - precipitation=None, - precipitation_unit=LENGTH_MILLIMETERS, + native_pressure=None, + native_pressure_unit=PRESSURE_INHG, + native_wind_speed=None, + native_wind_speed_unit=SPEED_METERS_PER_SECOND, + native_precipitation=None, + native_precipitation_unit=LENGTH_MILLIMETERS, ) state = hass.states.get(entity0.entity_id) forecast = state.attributes[ATTR_FORECAST][0] - assert forecast[ATTR_FORECAST_PRESSURE] is None - assert forecast[ATTR_FORECAST_WIND_SPEED] is None - assert forecast[ATTR_FORECAST_PRECIPITATION] is None + assert forecast.get(ATTR_FORECAST_PRESSURE) is None + assert forecast.get(ATTR_FORECAST_WIND_SPEED) is None + assert forecast.get(ATTR_FORECAST_PRECIPITATION) is None + + +async def test_custom_units(hass: HomeAssistant, enable_custom_integrations) -> None: + """Test custom unit.""" + wind_speed_value = 5 + wind_speed_unit = SPEED_METERS_PER_SECOND + pressure_value = 110 + pressure_unit = PRESSURE_HPA + temperature_value = 20 + temperature_unit = TEMP_CELSIUS + visibility_value = 11 + visibility_unit = LENGTH_KILOMETERS + precipitation_value = 1.1 + precipitation_unit = LENGTH_MILLIMETERS + + set_options = { + "wind_speed_unit": SPEED_MILES_PER_HOUR, + "precipitation_unit": LENGTH_INCHES, + "pressure_unit": PRESSURE_INHG, + "temperature_unit": TEMP_FAHRENHEIT, + "visibility_unit": LENGTH_MILES, + } + + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("weather", "test", "very_unique") + entity_registry.async_update_entity_options(entry.entity_id, "weather", set_options) + await hass.async_block_till_done() + + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecast( + name="Test", + condition=ATTR_CONDITION_SUNNY, + native_temperature=temperature_value, + native_temperature_unit=temperature_unit, + native_wind_speed=wind_speed_value, + native_wind_speed_unit=wind_speed_unit, + native_pressure=pressure_value, + native_pressure_unit=pressure_unit, + native_visibility=visibility_value, + native_visibility_unit=visibility_unit, + native_precipitation=precipitation_value, + native_precipitation_unit=precipitation_unit, + unique_id="very_unique", + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + + expected_wind_speed = round( + convert_speed(wind_speed_value, wind_speed_unit, SPEED_MILES_PER_HOUR), + ROUNDING_PRECISION, + ) + expected_temperature = convert_temperature( + temperature_value, temperature_unit, TEMP_FAHRENHEIT + ) + expected_pressure = round( + convert_pressure(pressure_value, pressure_unit, PRESSURE_INHG), + ROUNDING_PRECISION, + ) + expected_visibility = round( + convert_distance(visibility_value, visibility_unit, LENGTH_MILES), + ROUNDING_PRECISION, + ) + expected_precipitation = round( + convert_distance(precipitation_value, precipitation_unit, LENGTH_INCHES), + ROUNDING_PRECISION, + ) + + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + expected_wind_speed + ) + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + expected_temperature, rel=0.1 + ) + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx(expected_pressure) + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx( + expected_visibility + ) + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx( + expected_precipitation, rel=1e-2 + ) + + assert ( + state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] + == set_options["precipitation_unit"] + ) + assert state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == set_options["pressure_unit"] + assert ( + state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] + == set_options["temperature_unit"] + ) + assert ( + state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == set_options["visibility_unit"] + ) + assert ( + state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == set_options["wind_speed_unit"] + ) + + +async def test_backwards_compatibility( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test backwards compatibility.""" + wind_speed_value = 5 + wind_speed_unit = SPEED_METERS_PER_SECOND + pressure_value = 110000 + pressure_unit = PRESSURE_PA + temperature_value = 20 + temperature_unit = TEMP_CELSIUS + visibility_value = 11 + visibility_unit = LENGTH_KILOMETERS + precipitation_value = 1 + precipitation_unit = LENGTH_MILLIMETERS + + hass.config.units = METRIC_SYSTEM + + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecastCompat( + name="Test", + condition=ATTR_CONDITION_SUNNY, + temperature=temperature_value, + temperature_unit=temperature_unit, + wind_speed=wind_speed_value, + wind_speed_unit=wind_speed_unit, + pressure=pressure_value, + pressure_unit=pressure_unit, + visibility=visibility_value, + visibility_unit=visibility_unit, + precipitation=precipitation_value, + precipitation_unit=precipitation_unit, + unique_id="very_unique", + ) + ) + platform.ENTITIES.append( + platform.MockWeatherMockForecastCompat( + name="Test2", + condition=ATTR_CONDITION_SUNNY, + temperature=temperature_value, + temperature_unit=temperature_unit, + wind_speed=wind_speed_value, + pressure=pressure_value, + visibility=visibility_value, + precipitation=precipitation_value, + unique_id="very_unique2", + ) + ) + + entity0 = platform.ENTITIES[0] + entity1 = platform.ENTITIES[1] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test2"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + forecast = state.attributes[ATTR_FORECAST][0] + state1 = hass.states.get(entity1.entity_id) + forecast1 = state1.attributes[ATTR_FORECAST][0] + + assert float(state.attributes[ATTR_WEATHER_WIND_SPEED]) == approx( + wind_speed_value * 3.6 + ) + assert state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == SPEED_KILOMETERS_PER_HOUR + assert float(state.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + temperature_value, rel=0.1 + ) + assert state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == TEMP_CELSIUS + assert float(state.attributes[ATTR_WEATHER_PRESSURE]) == approx( + pressure_value / 100 + ) + assert state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == PRESSURE_HPA + assert float(state.attributes[ATTR_WEATHER_VISIBILITY]) == approx(visibility_value) + assert state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == LENGTH_KILOMETERS + assert float(forecast[ATTR_FORECAST_PRECIPITATION]) == approx( + precipitation_value, rel=1e-2 + ) + assert state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == LENGTH_MILLIMETERS + + assert float(state1.attributes[ATTR_WEATHER_WIND_SPEED]) == approx(wind_speed_value) + assert state1.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == SPEED_KILOMETERS_PER_HOUR + assert float(state1.attributes[ATTR_WEATHER_TEMPERATURE]) == approx( + temperature_value, rel=0.1 + ) + assert state1.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == TEMP_CELSIUS + assert float(state1.attributes[ATTR_WEATHER_PRESSURE]) == approx(pressure_value) + assert state1.attributes[ATTR_WEATHER_PRESSURE_UNIT] == PRESSURE_HPA + assert float(state1.attributes[ATTR_WEATHER_VISIBILITY]) == approx(visibility_value) + assert state1.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == LENGTH_KILOMETERS + assert float(forecast1[ATTR_FORECAST_PRECIPITATION]) == approx( + precipitation_value, rel=1e-2 + ) + assert state1.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == LENGTH_MILLIMETERS + + +async def test_backwards_compatibility_convert_values( + hass: HomeAssistant, enable_custom_integrations +) -> None: + """Test backward compatibility for converting values.""" + wind_speed_value = 5 + wind_speed_unit = SPEED_METERS_PER_SECOND + pressure_value = 110000 + pressure_unit = PRESSURE_PA + temperature_value = 20 + temperature_unit = TEMP_CELSIUS + visibility_value = 11 + visibility_unit = LENGTH_KILOMETERS + precipitation_value = 1 + precipitation_unit = LENGTH_MILLIMETERS + + hass.config.units = IMPERIAL_SYSTEM + + platform: WeatherPlatform = getattr(hass.components, "test.weather") + platform.init(empty=True) + platform.ENTITIES.append( + platform.MockWeatherMockForecastCompat( + name="Test", + condition=ATTR_CONDITION_SUNNY, + temperature=temperature_value, + temperature_unit=temperature_unit, + wind_speed=wind_speed_value, + wind_speed_unit=wind_speed_unit, + pressure=pressure_value, + pressure_unit=pressure_unit, + visibility=visibility_value, + visibility_unit=visibility_unit, + precipitation=precipitation_value, + precipitation_unit=precipitation_unit, + unique_id="very_unique", + ) + ) + + entity0 = platform.ENTITIES[0] + assert await async_setup_component( + hass, "weather", {"weather": {"platform": "test"}} + ) + await hass.async_block_till_done() + + state = hass.states.get(entity0.entity_id) + + expected_wind_speed = round( + convert_speed(wind_speed_value, wind_speed_unit, SPEED_MILES_PER_HOUR), + ROUNDING_PRECISION, + ) + expected_temperature = convert_temperature( + temperature_value, temperature_unit, TEMP_FAHRENHEIT + ) + expected_pressure = round( + convert_pressure(pressure_value, pressure_unit, PRESSURE_INHG), + ROUNDING_PRECISION, + ) + expected_visibility = round( + convert_distance(visibility_value, visibility_unit, LENGTH_MILES), + ROUNDING_PRECISION, + ) + expected_precipitation = round( + convert_distance(precipitation_value, precipitation_unit, LENGTH_INCHES), + ROUNDING_PRECISION, + ) + + assert state.attributes == { + ATTR_FORECAST: [ + { + ATTR_FORECAST_PRECIPITATION: approx(expected_precipitation, rel=0.1), + ATTR_FORECAST_PRESSURE: approx(expected_pressure, rel=0.1), + ATTR_FORECAST_TEMP: approx(expected_temperature, rel=0.1), + ATTR_FORECAST_TEMP_LOW: approx(expected_temperature, rel=0.1), + ATTR_FORECAST_WIND_BEARING: None, + ATTR_FORECAST_WIND_SPEED: approx(expected_wind_speed, rel=0.1), + } + ], + ATTR_FRIENDLY_NAME: "Test", + ATTR_WEATHER_PRECIPITATION_UNIT: LENGTH_INCHES, + ATTR_WEATHER_PRESSURE: approx(expected_pressure, rel=0.1), + ATTR_WEATHER_PRESSURE_UNIT: PRESSURE_INHG, + ATTR_WEATHER_TEMPERATURE: approx(expected_temperature, rel=0.1), + ATTR_WEATHER_TEMPERATURE_UNIT: TEMP_FAHRENHEIT, + ATTR_WEATHER_VISIBILITY: approx(expected_visibility, rel=0.1), + ATTR_WEATHER_VISIBILITY_UNIT: LENGTH_MILES, + ATTR_WEATHER_WIND_SPEED: approx(expected_wind_speed, rel=0.1), + ATTR_WEATHER_WIND_SPEED_UNIT: SPEED_MILES_PER_HOUR, + } + + +async def test_backwards_compatibility_round_temperature(hass: HomeAssistant) -> None: + """Test backward compatibility for rounding temperature.""" + + assert round_temperature(20.3, PRECISION_HALVES) == 20.5 + assert round_temperature(20.3, PRECISION_TENTHS) == 20.3 + assert round_temperature(20.3, PRECISION_WHOLE) == 20 + assert round_temperature(None, PRECISION_WHOLE) is None + + +async def test_attr(hass: HomeAssistant) -> None: + """Test the _attr attributes.""" + + weather = MockWeatherEntity() + weather.hass = hass + + assert weather.condition == ATTR_CONDITION_SUNNY + assert weather.native_precipitation_unit == LENGTH_MILLIMETERS + assert weather._precipitation_unit == LENGTH_MILLIMETERS + assert weather.native_pressure == 10 + assert weather.native_pressure_unit == PRESSURE_HPA + assert weather._pressure_unit == PRESSURE_HPA + assert weather.native_temperature == 20 + assert weather.native_temperature_unit == TEMP_CELSIUS + assert weather._temperature_unit == TEMP_CELSIUS + assert weather.native_visibility == 30 + assert weather.native_visibility_unit == LENGTH_KILOMETERS + assert weather._visibility_unit == LENGTH_KILOMETERS + assert weather.native_wind_speed == 3 + assert weather.native_wind_speed_unit == SPEED_METERS_PER_SECOND + assert weather._wind_speed_unit == SPEED_KILOMETERS_PER_HOUR + + +async def test_attr_compatibility(hass: HomeAssistant) -> None: + """Test the _attr attributes in compatibility mode.""" + + weather = MockWeatherEntityCompat() + weather.hass = hass + + assert weather.condition == ATTR_CONDITION_SUNNY + assert weather._precipitation_unit == LENGTH_MILLIMETERS + assert weather.pressure == 10 + assert weather._pressure_unit == PRESSURE_HPA + assert weather.temperature == 20 + assert weather._temperature_unit == TEMP_CELSIUS + assert weather.visibility == 30 + assert weather.visibility_unit == LENGTH_KILOMETERS + assert weather.wind_speed == 3 + assert weather._wind_speed_unit == SPEED_KILOMETERS_PER_HOUR + + forecast_entry = [ + Forecast( + datetime=datetime(2022, 6, 20, 20, 00, 00), + precipitation=1, + temperature=20, + ) + ] + + assert weather.forecast == forecast_entry + + assert weather.state_attributes == { + ATTR_FORECAST: forecast_entry, + ATTR_WEATHER_PRESSURE: 10.0, + ATTR_WEATHER_PRESSURE_UNIT: PRESSURE_HPA, + ATTR_WEATHER_TEMPERATURE: 20.0, + ATTR_WEATHER_TEMPERATURE_UNIT: TEMP_CELSIUS, + ATTR_WEATHER_VISIBILITY: 30.0, + ATTR_WEATHER_VISIBILITY_UNIT: LENGTH_KILOMETERS, + ATTR_WEATHER_WIND_SPEED: 3.0 * 3.6, + ATTR_WEATHER_WIND_SPEED_UNIT: SPEED_KILOMETERS_PER_HOUR, + ATTR_WEATHER_PRECIPITATION_UNIT: LENGTH_MILLIMETERS, + } + + +async def test_precision_for_temperature(hass: HomeAssistant) -> None: + """Test the precision for temperature.""" + + weather = MockWeatherEntityPrecision() + weather.hass = hass + + assert weather.condition == ATTR_CONDITION_SUNNY + assert weather.native_temperature == 20.3 + assert weather._temperature_unit == TEMP_CELSIUS + assert weather.precision == PRECISION_HALVES + + assert weather.state_attributes[ATTR_WEATHER_TEMPERATURE] == 20.5 diff --git a/tests/testing_config/custom_components/test/weather.py b/tests/testing_config/custom_components/test/weather.py index 224d6495548..23a9569c785 100644 --- a/tests/testing_config/custom_components/test/weather.py +++ b/tests/testing_config/custom_components/test/weather.py @@ -6,6 +6,11 @@ Call init before using it in your tests to ensure clean test data. from __future__ import annotations from homeassistant.components.weather import ( + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, @@ -37,6 +42,80 @@ async def async_setup_platform( class MockWeather(MockEntity, WeatherEntity): """Mock weather class.""" + @property + def native_temperature(self) -> float | None: + """Return the platform temperature.""" + return self._handle("native_temperature") + + @property + def native_temperature_unit(self) -> str | None: + """Return the unit of measurement for temperature.""" + return self._handle("native_temperature_unit") + + @property + def native_pressure(self) -> float | None: + """Return the pressure.""" + return self._handle("native_pressure") + + @property + def native_pressure_unit(self) -> str | None: + """Return the unit of measurement for pressure.""" + return self._handle("native_pressure_unit") + + @property + def humidity(self) -> float | None: + """Return the humidity.""" + return self._handle("humidity") + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return self._handle("native_wind_speed") + + @property + def native_wind_speed_unit(self) -> str | None: + """Return the unit of measurement for wind speed.""" + return self._handle("native_wind_speed_unit") + + @property + def wind_bearing(self) -> float | str | None: + """Return the wind bearing.""" + return self._handle("wind_bearing") + + @property + def ozone(self) -> float | None: + """Return the ozone level.""" + return self._handle("ozone") + + @property + def native_visibility(self) -> float | None: + """Return the visibility.""" + return self._handle("native_visibility") + + @property + def native_visibility_unit(self) -> str | None: + """Return the unit of measurement for visibility.""" + return self._handle("native_visibility_unit") + + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return self._handle("forecast") + + @property + def native_precipitation_unit(self) -> str | None: + """Return the native unit of measurement for accumulated precipitation.""" + return self._handle("native_precipitation_unit") + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self._handle("condition") + + +class MockWeatherCompat(MockEntity, WeatherEntity): + """Mock weather class for backwards compatibility check.""" + @property def temperature(self) -> float | None: """Return the platform temperature.""" @@ -99,7 +178,7 @@ class MockWeather(MockEntity, WeatherEntity): @property def precipitation_unit(self) -> str | None: - """Return the native unit of measurement for accumulated precipitation.""" + """Return the unit of measurement for accumulated precipitation.""" return self._handle("precipitation_unit") @property @@ -111,6 +190,26 @@ class MockWeather(MockEntity, WeatherEntity): class MockWeatherMockForecast(MockWeather): """Mock weather class with mocked forecast.""" + @property + def forecast(self) -> list[Forecast] | None: + """Return the forecast.""" + return [ + { + ATTR_FORECAST_NATIVE_TEMP: self.native_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: self.native_temperature, + ATTR_FORECAST_NATIVE_PRESSURE: self.native_pressure, + ATTR_FORECAST_NATIVE_WIND_SPEED: self.native_wind_speed, + ATTR_FORECAST_WIND_BEARING: self.wind_bearing, + ATTR_FORECAST_NATIVE_PRECIPITATION: self._values.get( + "native_precipitation" + ), + } + ] + + +class MockWeatherMockForecastCompat(MockWeatherCompat): + """Mock weather class with mocked forecast for compatibility check.""" + @property def forecast(self) -> list[Forecast] | None: """Return the forecast.""" From f91a2220348395c71986be1b879cae4d29c6b2ea Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 11:12:22 +0200 Subject: [PATCH 626/947] Fix compensation (numpy) tests (#73890) --- tests/components/compensation/test_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/compensation/test_sensor.py b/tests/components/compensation/test_sensor.py index 65741fd86ba..6d504d03b6a 100644 --- a/tests/components/compensation/test_sensor.py +++ b/tests/components/compensation/test_sensor.py @@ -163,7 +163,7 @@ async def test_numpy_errors(hass, caplog): await hass.async_start() await hass.async_block_till_done() - assert "invalid value encountered in true_divide" in caplog.text + assert "invalid value encountered in divide" in caplog.text async def test_datapoints_greater_than_degree(hass, caplog): From 4ee92f3953d5caa66131a1c2586289221a633793 Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Thu, 23 Jun 2022 11:34:34 +0200 Subject: [PATCH 627/947] Improve hvac_mode compatibility of vicare (#66454) --- homeassistant/components/vicare/climate.py | 48 ++++++++++++++-------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 4773101f1b9..fb8e60c3318 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -71,19 +71,13 @@ VICARE_TEMP_HEATING_MIN = 3 VICARE_TEMP_HEATING_MAX = 37 VICARE_TO_HA_HVAC_HEATING = { - VICARE_MODE_DHW: HVACMode.OFF, - VICARE_MODE_HEATING: HVACMode.HEAT, - VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, - VICARE_MODE_DHWANDHEATINGCOOLING: HVACMode.AUTO, VICARE_MODE_FORCEDREDUCED: HVACMode.OFF, - VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, VICARE_MODE_OFF: HVACMode.OFF, -} - -HA_TO_VICARE_HVAC_HEATING = { - HVACMode.HEAT: VICARE_MODE_FORCEDNORMAL, - HVACMode.OFF: VICARE_MODE_FORCEDREDUCED, - HVACMode.AUTO: VICARE_MODE_DHWANDHEATING, + VICARE_MODE_DHW: HVACMode.OFF, + VICARE_MODE_DHWANDHEATINGCOOLING: HVACMode.AUTO, + VICARE_MODE_DHWANDHEATING: HVACMode.AUTO, + VICARE_MODE_HEATING: HVACMode.AUTO, + VICARE_MODE_FORCEDNORMAL: HVACMode.HEAT, } VICARE_TO_HA_PRESET_HEATING = { @@ -276,19 +270,41 @@ class ViCareClimate(ClimateEntity): def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set a new hvac mode on the ViCare API.""" - vicare_mode = HA_TO_VICARE_HVAC_HEATING.get(hvac_mode) + if "vicare_modes" not in self._attributes: + raise ValueError("Cannot set hvac mode when vicare_modes are not known") + + vicare_mode = self.vicare_mode_from_hvac_mode(hvac_mode) if vicare_mode is None: - raise ValueError( - f"Cannot set invalid vicare mode: {hvac_mode} / {vicare_mode}" - ) + raise ValueError(f"Cannot set invalid hvac mode: {hvac_mode}") _LOGGER.debug("Setting hvac mode to %s / %s", hvac_mode, vicare_mode) self._circuit.setMode(vicare_mode) + def vicare_mode_from_hvac_mode(self, hvac_mode): + """Return the corresponding vicare mode for an hvac_mode.""" + if "vicare_modes" not in self._attributes: + return None + + supported_modes = self._attributes["vicare_modes"] + for key, value in VICARE_TO_HA_HVAC_HEATING.items(): + if key in supported_modes and value == hvac_mode: + return key + return None + @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available hvac modes.""" - return list(HA_TO_VICARE_HVAC_HEATING) + if "vicare_modes" not in self._attributes: + return [] + + supported_modes = self._attributes["vicare_modes"] + hvac_modes = [] + for key, value in VICARE_TO_HA_HVAC_HEATING.items(): + if value in hvac_modes: + continue + if key in supported_modes: + hvac_modes.append(value) + return hvac_modes @property def hvac_action(self) -> HVACAction: From 0dd181f9221155f8004a0bb6203bc2ab5bf3525c Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 23 Jun 2022 12:35:47 +0300 Subject: [PATCH 628/947] Remove deprecated YAML for Islamic prayer times (#72483) --- .../islamic_prayer_times/__init__.py | 41 ++----------------- .../islamic_prayer_times/config_flow.py | 4 -- .../islamic_prayer_times/test_config_flow.py | 13 ------ .../islamic_prayer_times/test_init.py | 17 -------- 4 files changed, 4 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py index c88e26e1c90..406eaf23670 100644 --- a/homeassistant/components/islamic_prayer_times/__init__.py +++ b/homeassistant/components/islamic_prayer_times/__init__.py @@ -4,63 +4,30 @@ import logging from prayer_times_calculator import PrayerTimesCalculator, exceptions from requests.exceptions import ConnectionError as ConnError -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later, async_track_point_in_time -from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util -from .const import ( - CALC_METHODS, - CONF_CALC_METHOD, - DATA_UPDATED, - DEFAULT_CALC_METHOD, - DOMAIN, -) +from .const import CONF_CALC_METHOD, DATA_UPDATED, DEFAULT_CALC_METHOD, DOMAIN _LOGGER = logging.getLogger(__name__) PLATFORMS = [Platform.SENSOR] -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: { - vol.Optional(CONF_CALC_METHOD, default=DEFAULT_CALC_METHOD): vol.In( - CALC_METHODS - ), - } - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Import the Islamic Prayer component from config.""" - if DOMAIN in config: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Islamic Prayer Component.""" client = IslamicPrayerClient(hass, config_entry) - if not await client.async_setup(): - return False + await client.async_setup() hass.data.setdefault(DOMAIN, client) return True diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 3379af3860f..5278750d36e 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -32,10 +32,6 @@ class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=NAME, data=user_input) - async def async_step_import(self, import_config): - """Import from config.""" - return await self.async_step_user(user_input=import_config) - class IslamicPrayerOptionsFlowHandler(config_entries.OptionsFlow): """Handle Islamic Prayer client options.""" diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index 18d64842c65..730c5634770 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -59,19 +59,6 @@ async def test_options(hass): assert result["data"][CONF_CALC_METHOD] == "makkah" -async def test_import(hass): - """Test import step.""" - result = await hass.config_entries.flow.async_init( - islamic_prayer_times.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_CALC_METHOD: "makkah"}, - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Islamic Prayer Times" - assert result["data"][CONF_CALC_METHOD] == "makkah" - - async def test_integration_already_configured(hass): """Test integration is already configured.""" entry = MockConfigEntry( diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py index e40af8c89ff..5a092373eef 100644 --- a/tests/components/islamic_prayer_times/test_init.py +++ b/tests/components/islamic_prayer_times/test_init.py @@ -9,7 +9,6 @@ import pytest from homeassistant import config_entries from homeassistant.components import islamic_prayer_times -from homeassistant.setup import async_setup_component from . import ( NEW_PRAYER_TIMES, @@ -28,22 +27,6 @@ def set_utc(hass): hass.config.set_time_zone("UTC") -async def test_setup_with_config(hass): - """Test that we import the config and setup the client.""" - config = { - islamic_prayer_times.DOMAIN: {islamic_prayer_times.CONF_CALC_METHOD: "isna"} - } - with patch( - "prayer_times_calculator.PrayerTimesCalculator.fetch_prayer_times", - return_value=PRAYER_TIMES, - ): - assert ( - await async_setup_component(hass, islamic_prayer_times.DOMAIN, config) - is True - ) - await hass.async_block_till_done() - - async def test_successful_config_entry(hass): """Test that Islamic Prayer Times is configured successfully.""" From 10b083bbf59bf24e8099fc3002a8fbc6460bc2f8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 23 Jun 2022 05:41:34 -0400 Subject: [PATCH 629/947] Sync empty entities when Google is disabled in cloud (#72806) --- homeassistant/components/cloud/client.py | 6 +++--- .../components/cloud/google_config.py | 1 + .../components/google_assistant/helpers.py | 8 ++++--- .../components/google_assistant/smart_home.py | 21 ++++++++++++++++--- tests/components/cloud/test_client.py | 21 +++++++++++++++---- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c47544f9d99..6011e9bf551 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -210,11 +210,11 @@ class CloudClient(Interface): async def async_google_message(self, payload: dict[Any, Any]) -> dict[Any, Any]: """Process cloud google message to client.""" - if not self._prefs.google_enabled: - return ga.turned_off_response(payload) - gconf = await self.get_google_config() + if not self._prefs.google_enabled: + return ga.api_disabled_response(payload, gconf.agent_user_id) + return await ga.async_handle_message( self._hass, gconf, gconf.cloud_user, payload, gc.SOURCE_CLOUD ) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 81f00b69b23..9bb2e405dca 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -219,6 +219,7 @@ class CloudGoogleConfig(AbstractConfig): sync_entities = True elif not self.enabled and self.is_local_sdk_active: self.async_disable_local_sdk() + sync_entities = True self._cur_entity_prefs = prefs.google_entity_configs self._cur_default_expose = prefs.google_default_expose diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 15a8d832403..2ed91b42ec6 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -356,9 +356,6 @@ class AbstractConfig(ABC): pprint.pformat(payload), ) - if not self.enabled: - return json_response(smart_home.turned_off_response(payload)) - if (agent_user_id := self.get_local_agent_user_id(webhook_id)) is None: # No agent user linked to this webhook, means that the user has somehow unregistered # removing webhook and stopping processing of this request. @@ -370,6 +367,11 @@ class AbstractConfig(ABC): webhook.async_unregister(self.hass, webhook_id) return None + if not self.enabled: + return json_response( + smart_home.api_disabled_response(payload, agent_user_id) + ) + result = await smart_home.async_handle_message( self.hass, self, diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 805c9100d9f..227b033bcaa 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -99,7 +99,7 @@ async def async_devices_sync(hass, data, payload): except Exception: # pylint: disable=broad-except _LOGGER.exception("Error serializing %s", entity.entity_id) - response = {"agentUserId": agent_user_id, "devices": devices} + response = create_sync_response(agent_user_id, devices) _LOGGER.debug("Syncing entities response: %s", response) @@ -300,9 +300,24 @@ async def async_devices_proxy_selected(hass, data: RequestData, payload): return {} -def turned_off_response(message): +def create_sync_response(agent_user_id: str, devices: list): + """Return an empty sync response.""" + return { + "agentUserId": agent_user_id, + "devices": devices, + } + + +def api_disabled_response(message, agent_user_id): """Return a device turned off response.""" + inputs: list = message.get("inputs") + + if inputs and inputs[0].get("intent") == "action.devices.SYNC": + payload = create_sync_response(agent_user_id, []) + else: + payload = {"errorCode": "deviceTurnedOff"} + return { "requestId": message.get("requestId"), - "payload": {"errorCode": "deviceTurnedOff"}, + "payload": payload, } diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py index f56a1c86d4d..c125f5c252a 100644 --- a/tests/components/cloud/test_client.py +++ b/tests/components/cloud/test_client.py @@ -134,7 +134,16 @@ async def test_handler_google_actions(hass): assert device["roomHint"] == "living room" -async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): +@pytest.mark.parametrize( + "intent,response_payload", + [ + ("action.devices.SYNC", {"agentUserId": "myUserName", "devices": []}), + ("action.devices.QUERY", {"errorCode": "deviceTurnedOff"}), + ], +) +async def test_handler_google_actions_disabled( + hass, mock_cloud_fixture, intent, response_payload +): """Test handler Google Actions when user has disabled it.""" mock_cloud_fixture._prefs[PREF_ENABLE_GOOGLE] = False @@ -142,13 +151,17 @@ async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): assert await async_setup_component(hass, "cloud", {}) reqid = "5711642932632160983" - data = {"requestId": reqid, "inputs": [{"intent": "action.devices.SYNC"}]} + data = {"requestId": reqid, "inputs": [{"intent": intent}]} cloud = hass.data["cloud"] - resp = await cloud.client.async_google_message(data) + with patch( + "hass_nabucasa.Cloud._decode_claims", + return_value={"cognito:username": "myUserName"}, + ): + resp = await cloud.client.async_google_message(data) assert resp["requestId"] == reqid - assert resp["payload"]["errorCode"] == "deviceTurnedOff" + assert resp["payload"] == response_payload async def test_webhook_msg(hass, caplog): From a3ce80baed4ced985a39e7cfc7d5bf6cb52eb3cc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 11:44:25 +0200 Subject: [PATCH 630/947] Improve nuki type hints (#73891) --- homeassistant/components/nuki/__init__.py | 11 +++++++---- homeassistant/components/nuki/lock.py | 17 +++++++++-------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 6976c2dc682..e9cef7aa6cd 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -3,8 +3,9 @@ from datetime import timedelta import logging import async_timeout -from pynuki import NukiBridge +from pynuki import NukiBridge, NukiLock, NukiOpener from pynuki.bridge import InvalidCredentialsException +from pynuki.device import NukiDevice from requests.exceptions import RequestException from homeassistant import exceptions @@ -34,11 +35,11 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.LOCK] UPDATE_INTERVAL = timedelta(seconds=30) -def _get_bridge_devices(bridge): +def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOpener]]: return bridge.locks, bridge.openers -def _update_devices(devices): +def _update_devices(devices: list[NukiDevice]) -> None: for device in devices: for level in (False, True): try: @@ -136,7 +137,9 @@ class NukiEntity(CoordinatorEntity): """ - def __init__(self, coordinator, nuki_device): + def __init__( + self, coordinator: DataUpdateCoordinator[None], nuki_device: NukiDevice + ) -> None: """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self._nuki_device = nuki_device diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 33d7465e12d..8b6c843f48a 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -1,4 +1,6 @@ """Nuki.io lock platform.""" +from __future__ import annotations + from abc import ABC, abstractmethod from typing import Any @@ -65,23 +67,22 @@ class NukiDeviceEntity(NukiEntity, LockEntity, ABC): _attr_supported_features = LockEntityFeature.OPEN @property - def name(self): + def name(self) -> str | None: """Return the name of the lock.""" return self._nuki_device.name @property - def unique_id(self) -> str: + def unique_id(self) -> str | None: """Return a unique ID.""" return self._nuki_device.nuki_id @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" - data = { + return { ATTR_BATTERY_CRITICAL: self._nuki_device.battery_critical, ATTR_NUKI_ID: self._nuki_device.nuki_id, } - return data @property def available(self) -> bool: @@ -123,7 +124,7 @@ class NukiLockEntity(NukiDeviceEntity): """Open the door latch.""" self._nuki_device.unlatch() - def lock_n_go(self, unlatch): + def lock_n_go(self, unlatch: bool) -> None: """Lock and go. This will first unlock the door, then wait for 20 seconds (or another @@ -157,10 +158,10 @@ class NukiOpenerEntity(NukiDeviceEntity): """Buzz open the door.""" self._nuki_device.electric_strike_actuation() - def lock_n_go(self, unlatch): + def lock_n_go(self, unlatch: bool) -> None: """Stub service.""" - def set_continuous_mode(self, enable): + def set_continuous_mode(self, enable: bool) -> None: """Continuous Mode. This feature will cause the door to automatically open when anyone From 48bd7cf5e1679bda3fa0fb266d25b5903fbe806f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 12:01:05 +0200 Subject: [PATCH 631/947] Add missing ToggleEntity type hints in fans (#73887) --- homeassistant/components/demo/fan.py | 4 ++-- homeassistant/components/fjaraskupan/fan.py | 2 +- homeassistant/components/insteon/fan.py | 2 +- homeassistant/components/lutron_caseta/fan.py | 2 +- homeassistant/components/mqtt/fan.py | 2 +- homeassistant/components/smartthings/fan.py | 2 +- homeassistant/components/smarty/fan.py | 2 +- homeassistant/components/wilight/fan.py | 2 +- homeassistant/components/xiaomi_miio/fan.py | 2 +- homeassistant/components/zha/fan.py | 2 +- 10 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index ef6875cb7c6..a36e7a35cff 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -208,7 +208,7 @@ class DemoPercentageFan(BaseDemoFan, FanEntity): self.set_percentage(percentage) - def turn_off(self, **kwargs) -> None: + def turn_off(self, **kwargs: Any) -> None: """Turn off the entity.""" self.set_percentage(0) @@ -278,7 +278,7 @@ class AsyncDemoPercentageFan(BaseDemoFan, FanEntity): await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the entity.""" await self.async_oscillate(False) await self.async_set_percentage(0) diff --git a/homeassistant/components/fjaraskupan/fan.py b/homeassistant/components/fjaraskupan/fan.py index ae5da3d189c..e372d540f54 100644 --- a/homeassistant/components/fjaraskupan/fan.py +++ b/homeassistant/components/fjaraskupan/fan.py @@ -135,7 +135,7 @@ class Fan(CoordinatorEntity[Coordinator], FanEntity): else: raise UnsupportedPreset(f"The preset {preset_mode} is unsupported") - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self._device.send_command(COMMAND_STOP_FAN) self.coordinator.async_set_updated_data(self._device.state) diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index 8fe5fc9346e..c7512ba0278 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -70,7 +70,7 @@ class InsteonFanEntity(InsteonEntity, FanEntity): """Turn on the fan.""" await self.async_set_percentage(percentage or 67) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" await self._insteon_device.async_fan_off() diff --git a/homeassistant/components/lutron_caseta/fan.py b/homeassistant/components/lutron_caseta/fan.py index 46eee1bde6b..bf2328565d4 100644 --- a/homeassistant/components/lutron_caseta/fan.py +++ b/homeassistant/components/lutron_caseta/fan.py @@ -70,7 +70,7 @@ class LutronCasetaFan(LutronCasetaDeviceUpdatableEntity, FanEntity): await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self.async_set_percentage(0) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 4e7e58afb9f..f1a1dbed657 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -568,7 +568,7 @@ class MqttFan(MqttEntity, FanEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the entity. This method is a coroutine. diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index abcd5d12f75..7278f350dc1 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -78,7 +78,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): """Turn the fan on.""" await self._async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self._device.switch_off(set_status=True) # State is set optimistically in the command above, therefore update diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index a51d6783245..ceb3b17e030 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -107,7 +107,7 @@ class SmartyFan(FanEntity): _LOGGER.debug("Turning on fan. percentage is %s", percentage) self.set_percentage(percentage or DEFAULT_ON_PERCENTAGE) - def turn_off(self, **kwargs): + def turn_off(self, **kwargs: Any) -> None: """Turn off the fan.""" _LOGGER.debug("Turning off fan") if not self._smarty.turn_off(): diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index 7a60fa8ab62..a93e0eb9447 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -130,6 +130,6 @@ class WiLightFan(WiLightDevice, FanEntity): wl_direction = WL_DIRECTION_FORWARD await self._client.set_fan_direction(self._index, wl_direction) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the fan off.""" await self._client.set_fan_direction(self._index, WL_DIRECTION_OFF) diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index 31908ba373f..b70ddc945ea 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -334,7 +334,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): self._state = True self.async_write_ha_state() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" result = await self._try_command( "Turning the miio device off failed.", self._device.off diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 298f9e47296..1873c906a6f 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -99,7 +99,7 @@ class BaseFan(FanEntity): percentage = DEFAULT_ON_PERCENTAGE await self.async_set_percentage(percentage) - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.async_set_percentage(0) From b4cc9367cf9db2209b0e657927c2f2579d79dd42 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Thu, 23 Jun 2022 12:18:01 +0200 Subject: [PATCH 632/947] Bump bimmer_connected to 0.9.5 (#73888) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index cd6daa83705..40af0b9e210 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.4"], + "requirements": ["bimmer_connected==0.9.5"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index 962a246c2d2..10e4ce115da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -393,7 +393,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.4 +bimmer_connected==0.9.5 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4a48872ddee..00f04afa482 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -308,7 +308,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.4 +bimmer_connected==0.9.5 # homeassistant.components.blebox blebox_uniapi==1.3.3 From 0787ee134504f8563d8600430632ee900d8e843f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 12:42:05 +0200 Subject: [PATCH 633/947] Use attributes in comfoconnect fan (#73892) --- homeassistant/components/comfoconnect/fan.py | 26 ++++---------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py index f52c065a547..dd2c9632d7c 100644 --- a/homeassistant/components/comfoconnect/fan.py +++ b/homeassistant/components/comfoconnect/fan.py @@ -53,12 +53,16 @@ def setup_platform( class ComfoConnectFan(FanEntity): """Representation of the ComfoConnect fan platform.""" + _attr_icon = "mdi:air-conditioner" + _attr_should_poll = False _attr_supported_features = FanEntityFeature.SET_SPEED current_speed = None def __init__(self, ccb: ComfoConnectBridge) -> None: """Initialize the ComfoConnect fan.""" self._ccb = ccb + self._attr_name = ccb.name + self._attr_unique_id = ccb.unique_id async def async_added_to_hass(self) -> None: """Register for sensor updates.""" @@ -74,7 +78,7 @@ class ComfoConnectFan(FanEntity): self._ccb.comfoconnect.register_sensor, SENSOR_FAN_SPEED_MODE ) - def _handle_update(self, value): + def _handle_update(self, value: float) -> None: """Handle update callbacks.""" _LOGGER.debug( "Handle update for fan speed (%d): %s", SENSOR_FAN_SPEED_MODE, value @@ -82,26 +86,6 @@ class ComfoConnectFan(FanEntity): self.current_speed = value self.schedule_update_ha_state() - @property - def should_poll(self) -> bool: - """Do not poll.""" - return False - - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return self._ccb.unique_id - - @property - def name(self): - """Return the name of the fan.""" - return self._ccb.name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:air-conditioner" - @property def percentage(self) -> int | None: """Return the current speed percentage.""" From 95eeb8eff3e80a7e3d94f7ffc28ca93ffcd52dda Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 23 Jun 2022 13:58:24 +0200 Subject: [PATCH 634/947] Update Builder & Wheels + support yellow (#73896) --- .github/workflows/builder.yml | 5 +++-- .github/workflows/wheels.yml | 4 ++-- machine/yellow | 14 ++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 machine/yellow diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 62cbee9321c..6eea7cea953 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -135,7 +135,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.06.1 + uses: home-assistant/builder@2022.06.2 with: args: | $BUILD_ARGS \ @@ -171,6 +171,7 @@ jobs: - raspberrypi4 - raspberrypi4-64 - tinker + - yellow steps: - name: Checkout the repository uses: actions/checkout@v3.0.2 @@ -200,7 +201,7 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Build base image - uses: home-assistant/builder@2022.06.1 + uses: home-assistant/builder@2022.06.2 with: args: | $BUILD_ARGS \ diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 27307388546..e65b0e7091a 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -88,7 +88,7 @@ jobs: name: requirements_diff - name: Build wheels - uses: home-assistant/wheels@2022.06.6 + uses: home-assistant/wheels@2022.06.7 with: abi: cp310 tag: musllinux_1_2 @@ -147,7 +147,7 @@ jobs: fi - name: Build wheels - uses: home-assistant/wheels@2022.06.6 + uses: home-assistant/wheels@2022.06.7 with: abi: cp310 tag: musllinux_1_2 diff --git a/machine/yellow b/machine/yellow new file mode 100644 index 00000000000..d8e7421f9b1 --- /dev/null +++ b/machine/yellow @@ -0,0 +1,14 @@ +ARG BUILD_VERSION +FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION + +RUN apk --no-cache add \ + raspberrypi \ + raspberrypi-libs \ + usbutils + +## +# Set symlinks for raspberry pi binaries. +RUN ln -sv /opt/vc/bin/raspistill /usr/local/bin/raspistill \ + && ln -sv /opt/vc/bin/raspivid /usr/local/bin/raspivid \ + && ln -sv /opt/vc/bin/raspividyuv /usr/local/bin/raspividyuv \ + && ln -sv /opt/vc/bin/raspiyuv /usr/local/bin/raspiyuv From 2742bf86e3724c64c3eefa5c3a5b0a13be840f30 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 07:29:09 -0500 Subject: [PATCH 635/947] Switch mqtt to use json helper (#73871) * Switch mqtt to use json helper * whitespace --- homeassistant/components/mqtt/cover.py | 4 ++-- homeassistant/components/mqtt/discovery.py | 4 ++-- .../components/mqtt/light/schema_json.py | 8 ++++---- homeassistant/components/mqtt/mixins.py | 4 ++-- homeassistant/components/mqtt/siren.py | 8 ++++---- homeassistant/components/mqtt/trigger.py | 4 ++-- .../components/mqtt/vacuum/schema_legacy.py | 5 ++--- .../components/mqtt/vacuum/schema_state.py | 7 +++---- tests/components/mqtt/test_light_json.py | 16 ++++++++-------- tests/components/mqtt/test_siren.py | 10 +++++----- 10 files changed, 34 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 0901a4f63a6..754fcb7ec44 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -2,7 +2,6 @@ from __future__ import annotations import functools -from json import JSONDecodeError, loads as json_loads import logging import voluptuous as vol @@ -29,6 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -418,7 +418,7 @@ class MqttCover(MqttEntity, CoverEntity): try: payload = json_loads(payload) - except JSONDecodeError: + except JSON_DECODE_EXCEPTIONS: pass if isinstance(payload, dict): diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 04dd2d7917f..5b39e8fa1b5 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections import deque import functools -import json import logging import re import time @@ -17,6 +16,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) +from homeassistant.helpers.json import json_loads from homeassistant.loader import async_get_mqtt from .. import mqtt @@ -117,7 +117,7 @@ async def async_start( # noqa: C901 if payload: try: - payload = json.loads(payload) + payload = json_loads(payload) except ValueError: _LOGGER.warning("Unable to parse JSON %s: '%s'", object_id, payload) return diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index be49f1ad2e3..716366cbe22 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -1,6 +1,5 @@ """Support for MQTT JSON lights.""" from contextlib import suppress -import json import logging import voluptuous as vol @@ -46,6 +45,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import json_dumps, json_loads from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType import homeassistant.util.color as color_util @@ -317,7 +317,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): @log_messages(self.hass, self.entity_id) def state_received(msg): """Handle new MQTT messages.""" - values = json.loads(msg.payload) + values = json_loads(msg.payload) if values["state"] == "ON": self._state = True @@ -644,7 +644,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): await self.async_publish( self._topic[CONF_COMMAND_TOPIC], - json.dumps(message), + json_dumps(message), self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], @@ -669,7 +669,7 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): await self.async_publish( self._topic[CONF_COMMAND_TOPIC], - json.dumps(message), + json_dumps(message), self._config[CONF_QOS], self._config[CONF_RETAIN], self._config[CONF_ENCODING], diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index dcf387eb360..10fe6cb6cc5 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,7 +4,6 @@ from __future__ import annotations from abc import abstractmethod import asyncio from collections.abc import Callable -import json import logging from typing import Any, Protocol, cast, final @@ -47,6 +46,7 @@ from homeassistant.helpers.entity import ( async_generate_entity_id, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import json_loads from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import debug_info, subscription @@ -393,7 +393,7 @@ class MqttAttributes(Entity): def attributes_message_received(msg: ReceiveMessage) -> None: try: payload = attr_tpl(msg.payload) - json_dict = json.loads(payload) if isinstance(payload, str) else None + json_dict = json_loads(payload) if isinstance(payload, str) else None if isinstance(json_dict, dict): filtered_dict = { k: v diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index e7b91274f4f..a6cff4cf91d 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -3,7 +3,6 @@ from __future__ import annotations import copy import functools -import json import logging from typing import Any @@ -32,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.json import JSON_DECODE_EXCEPTIONS, json_dumps, json_loads from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription @@ -253,13 +253,13 @@ class MqttSiren(MqttEntity, SirenEntity): json_payload = {STATE: payload} else: try: - json_payload = json.loads(payload) + json_payload = json_loads(payload) _LOGGER.debug( "JSON payload detected after processing payload '%s' on topic %s", json_payload, msg.topic, ) - except json.decoder.JSONDecodeError: + except JSON_DECODE_EXCEPTIONS: _LOGGER.warning( "No valid (JSON) payload detected after processing payload '%s' on topic %s", json_payload, @@ -344,7 +344,7 @@ class MqttSiren(MqttEntity, SirenEntity): payload = ( self._command_templates[template](value, template_variables) if self._command_templates[template] - else json.dumps(template_variables) + else json_dumps(template_variables) ) if payload and payload not in PAYLOAD_NONE: await self.async_publish( diff --git a/homeassistant/components/mqtt/trigger.py b/homeassistant/components/mqtt/trigger.py index 7d1f93d30eb..aca4cf0a480 100644 --- a/homeassistant/components/mqtt/trigger.py +++ b/homeassistant/components/mqtt/trigger.py @@ -1,6 +1,5 @@ """Offer MQTT listening automation rules.""" from contextlib import suppress -import json import logging import voluptuous as vol @@ -12,6 +11,7 @@ from homeassistant.components.automation import ( from homeassistant.const import CONF_PAYLOAD, CONF_PLATFORM, CONF_VALUE_TEMPLATE from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.json import json_loads from homeassistant.helpers.typing import ConfigType from .. import mqtt @@ -89,7 +89,7 @@ async def async_attach_trigger( } with suppress(ValueError): - data["payload_json"] = json.loads(mqttmsg.payload) + data["payload_json"] = json_loads(mqttmsg.payload) hass.async_run_hass_job(job, {"trigger": data}) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index f25131c43b7..6b957aded5c 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -1,6 +1,4 @@ """Support for Legacy MQTT vacuum.""" -import json - import voluptuous as vol from homeassistant.components.vacuum import ( @@ -14,6 +12,7 @@ from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.helpers.json import json_dumps from .. import subscription from ..config import MQTT_BASE_SCHEMA @@ -511,7 +510,7 @@ class MqttVacuum(MqttEntity, VacuumEntity): if params: message = {"command": command} message.update(params) - message = json.dumps(message) + message = json_dumps(message) else: message = command await self.async_publish( diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 3d670780994..af6c8d289d8 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -1,6 +1,4 @@ """Support for a State MQTT vacuum.""" -import json - import voluptuous as vol from homeassistant.components.vacuum import ( @@ -21,6 +19,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import json_dumps, json_loads from .. import subscription from ..config import MQTT_BASE_SCHEMA @@ -203,7 +202,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): @log_messages(self.hass, self.entity_id) def state_message_received(msg): """Handle state MQTT message.""" - payload = json.loads(msg.payload) + payload = json_loads(msg.payload) if STATE in payload and ( payload[STATE] in POSSIBLE_STATES or payload[STATE] is None ): @@ -347,7 +346,7 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): if params: message = {"command": command} message.update(params) - message = json.dumps(message) + message = json_dumps(message) else: message = command await self.async_publish( diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 64733b4f0f7..b930de9b6c3 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -688,7 +688,7 @@ async def test_sending_mqtt_commands_and_optimistic( await common.async_turn_on(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "ON"}', 2, False + "test_light_rgb/set", '{"state":"ON"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -709,7 +709,7 @@ async def test_sending_mqtt_commands_and_optimistic( await common.async_turn_off(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "OFF"}', 2, False + "test_light_rgb/set", '{"state":"OFF"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -838,7 +838,7 @@ async def test_sending_mqtt_commands_and_optimistic2( # Turn the light on await common.async_turn_on(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "ON"}', 2, False + "test_light_rgb/set", '{"state":"ON"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -848,7 +848,7 @@ async def test_sending_mqtt_commands_and_optimistic2( await common.async_turn_on(hass, "light.test", color_temp=90) mqtt_mock.async_publish.assert_called_once_with( "test_light_rgb/set", - JsonValidator('{"state": "ON", "color_temp": 90}'), + JsonValidator('{"state":"ON","color_temp":90}'), 2, False, ) @@ -859,7 +859,7 @@ async def test_sending_mqtt_commands_and_optimistic2( # Turn the light off await common.async_turn_off(hass, "light.test") mqtt_mock.async_publish.assert_called_once_with( - "test_light_rgb/set", '{"state": "OFF"}', 2, False + "test_light_rgb/set", '{"state":"OFF"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("light.test") @@ -2004,7 +2004,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): light.DOMAIN, DEFAULT_CONFIG, light.SERVICE_TURN_ON, - command_payload='{"state": "ON"}', + command_payload='{"state":"ON"}', state_payload='{"state":"ON"}', ) @@ -2038,7 +2038,7 @@ async def test_max_mireds(hass, mqtt_mock_entry_with_yaml_config): light.SERVICE_TURN_ON, "command_topic", None, - '{"state": "ON"}', + '{"state":"ON"}', None, None, None, @@ -2047,7 +2047,7 @@ async def test_max_mireds(hass, mqtt_mock_entry_with_yaml_config): light.SERVICE_TURN_OFF, "command_topic", None, - '{"state": "OFF"}', + '{"state":"OFF"}', None, None, None, diff --git a/tests/components/mqtt/test_siren.py b/tests/components/mqtt/test_siren.py index c3916acb34b..6da9682c1c7 100644 --- a/tests/components/mqtt/test_siren.py +++ b/tests/components/mqtt/test_siren.py @@ -140,7 +140,7 @@ async def test_sending_mqtt_commands_and_optimistic( await async_turn_on(hass, entity_id="siren.test") mqtt_mock.async_publish.assert_called_once_with( - "command-topic", '{"state": "beer on"}', 2, False + "command-topic", '{"state":"beer on"}', 2, False ) mqtt_mock.async_publish.reset_mock() state = hass.states.get("siren.test") @@ -149,7 +149,7 @@ async def test_sending_mqtt_commands_and_optimistic( await async_turn_off(hass, entity_id="siren.test") mqtt_mock.async_publish.assert_called_once_with( - "command-topic", '{"state": "beer off"}', 2, False + "command-topic", '{"state":"beer off"}', 2, False ) state = hass.states.get("siren.test") assert state.state == STATE_OFF @@ -870,7 +870,7 @@ async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): siren.DOMAIN, DEFAULT_CONFIG, siren.SERVICE_TURN_ON, - command_payload='{"state": "ON"}', + command_payload='{"state":"ON"}', ) @@ -881,14 +881,14 @@ async def test_entity_debug_info_message(hass, mqtt_mock_entry_no_yaml_config): siren.SERVICE_TURN_ON, "command_topic", None, - '{"state": "ON"}', + '{"state":"ON"}', None, ), ( siren.SERVICE_TURN_OFF, "command_topic", None, - '{"state": "OFF"}', + '{"state":"OFF"}', None, ), ], From 8015bb98a909c95d044579a77e6a22abf108e6f8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 07:32:26 -0500 Subject: [PATCH 636/947] Switch recorder and templates to use json helper (#73876) - These were using orjson directly, its a bit cleaner to use the helper so everything is easier to adjust in the future if we need to change anything about the loading --- .../components/recorder/db_schema.py | 32 +++++++++++-------- homeassistant/components/recorder/models.py | 4 +-- homeassistant/helpers/template.py | 8 ++--- 3 files changed, 24 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index f300cc0bae7..36487353e25 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -8,7 +8,6 @@ from typing import Any, cast import ciso8601 from fnvhash import fnv1a_32 -import orjson from sqlalchemy import ( JSON, BigInteger, @@ -39,7 +38,12 @@ from homeassistant.const import ( MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id -from homeassistant.helpers.json import JSON_DUMP, json_bytes +from homeassistant.helpers.json import ( + JSON_DECODE_EXCEPTIONS, + JSON_DUMP, + json_bytes, + json_loads, +) import homeassistant.util.dt as dt_util from .const import ALL_DOMAIN_EXCLUDE_ATTRS @@ -188,15 +192,15 @@ class Events(Base): # type: ignore[misc,valid-type] try: return Event( self.event_type, - orjson.loads(self.event_data) if self.event_data else {}, + json_loads(self.event_data) if self.event_data else {}, EventOrigin(self.origin) if self.origin else EVENT_ORIGIN_ORDER[self.origin_idx], process_timestamp(self.time_fired), context=context, ) - except ValueError: - # When orjson.loads fails + except JSON_DECODE_EXCEPTIONS: + # When json_loads fails _LOGGER.exception("Error converting to event: %s", self) return None @@ -243,8 +247,8 @@ class EventData(Base): # type: ignore[misc,valid-type] def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], orjson.loads(self.shared_data)) - except ValueError: + return cast(dict[str, Any], json_loads(self.shared_data)) + except JSON_DECODE_EXCEPTIONS: _LOGGER.exception("Error converting row to event data: %s", self) return {} @@ -330,9 +334,9 @@ class States(Base): # type: ignore[misc,valid-type] parent_id=self.context_parent_id, ) try: - attrs = orjson.loads(self.attributes) if self.attributes else {} - except ValueError: - # When orjson.loads fails + attrs = json_loads(self.attributes) if self.attributes else {} + except JSON_DECODE_EXCEPTIONS: + # When json_loads fails _LOGGER.exception("Error converting row to state: %s", self) return None if self.last_changed is None or self.last_changed == self.last_updated: @@ -402,15 +406,15 @@ class StateAttributes(Base): # type: ignore[misc,valid-type] @staticmethod def hash_shared_attrs_bytes(shared_attrs_bytes: bytes) -> int: - """Return the hash of orjson encoded shared attributes.""" + """Return the hash of json encoded shared attributes.""" return cast(int, fnv1a_32(shared_attrs_bytes)) def to_native(self) -> dict[str, Any]: """Convert to an HA state object.""" try: - return cast(dict[str, Any], orjson.loads(self.shared_attrs)) - except ValueError: - # When orjson.loads fails + return cast(dict[str, Any], json_loads(self.shared_attrs)) + except JSON_DECODE_EXCEPTIONS: + # When json_loads fails _LOGGER.exception("Error converting row to state attributes: %s", self) return {} diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 64fb44289b0..ff53d9be3d1 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -5,7 +5,6 @@ from datetime import datetime import logging from typing import Any, TypedDict, overload -import orjson from sqlalchemy.engine.row import Row from homeassistant.components.websocket_api.const import ( @@ -15,6 +14,7 @@ from homeassistant.components.websocket_api.const import ( COMPRESSED_STATE_STATE, ) from homeassistant.core import Context, State +from homeassistant.helpers.json import json_loads import homeassistant.util.dt as dt_util # pylint: disable=invalid-name @@ -253,7 +253,7 @@ def decode_attributes_from_row( if not source or source == EMPTY_JSON_OBJECT: return {} try: - attr_cache[source] = attributes = orjson.loads(source) + attr_cache[source] = attributes = json_loads(source) except ValueError: _LOGGER.exception("Error converting row to state attributes: %s", source) attr_cache[source] = attributes = {} diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 02f95254c86..ac5b4c8119f 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -27,7 +27,6 @@ import jinja2 from jinja2 import pass_context, pass_environment from jinja2.sandbox import ImmutableSandboxedEnvironment from jinja2.utils import Namespace -import orjson import voluptuous as vol from homeassistant.const import ( @@ -58,6 +57,7 @@ from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.thread import ThreadWithException from . import area_registry, device_registry, entity_registry, location as loc_helper +from .json import JSON_DECODE_EXCEPTIONS, json_loads from .typing import TemplateVarsType # mypy: allow-untyped-defs, no-check-untyped-defs @@ -566,8 +566,8 @@ class Template: variables = dict(variables or {}) variables["value"] = value - with suppress(ValueError, TypeError): - variables["value_json"] = orjson.loads(value) + with suppress(*JSON_DECODE_EXCEPTIONS): + variables["value_json"] = json_loads(value) try: return _render_with_context( @@ -1744,7 +1744,7 @@ def ordinal(value): def from_json(value): """Convert a JSON string to an object.""" - return orjson.loads(value) + return json_loads(value) def to_json(value, ensure_ascii=True): From 95abfb5748ce24788146c6efdb9098ddfe060211 Mon Sep 17 00:00:00 2001 From: kingy444 Date: Thu, 23 Jun 2022 23:37:28 +1000 Subject: [PATCH 637/947] Powerview polling tdbu (#73899) --- .../components/hunterdouglas_powerview/cover.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 797dded4f76..fe26a100569 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -464,6 +464,15 @@ class PowerViewShadeTDBUTop(PowerViewShadeTDBU): ATTR_POSKIND2: POS_KIND_SECONDARY, } + @property + def should_poll(self) -> bool: + """Certain shades create multiple entities. + + Do not poll shade multiple times. One shade will return data + for both and multiple polling will cause timeouts. + """ + return False + @property def is_closed(self): """Return if the cover is closed.""" From ff7d840a6c556436e01f7d86a922e13275eb1713 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 23 Jun 2022 10:13:36 -0400 Subject: [PATCH 638/947] Bump zwave-js-server-python to 0.39.0 (#73904) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index f2670be7b80..d1097a6cd65 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.38.0"], + "requirements": ["zwave-js-server-python==0.39.0"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["usb", "http", "websocket_api"], "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 10e4ce115da..2ffb5aad412 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2522,7 +2522,7 @@ zigpy==0.46.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.38.0 +zwave-js-server-python==0.39.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00f04afa482..cce7c56108d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1671,7 +1671,7 @@ zigpy-znp==0.7.0 zigpy==0.46.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.38.0 +zwave-js-server-python==0.39.0 # homeassistant.components.zwave_me zwave_me_ws==0.2.4 From 3c82c718cb37fca96002558019e3a0fba7ccde66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 16:34:40 +0200 Subject: [PATCH 639/947] Improve typing in fans and locks (#73901) --- homeassistant/components/august/lock.py | 2 +- homeassistant/components/demo/fan.py | 4 ++-- homeassistant/components/demo/lock.py | 2 +- homeassistant/components/kiwi/lock.py | 4 ++-- homeassistant/components/mqtt/fan.py | 2 +- homeassistant/components/mqtt/lock.py | 4 ++-- homeassistant/components/sesame/lock.py | 2 +- homeassistant/components/smartthings/lock.py | 2 +- homeassistant/components/starline/lock.py | 6 +++--- homeassistant/components/template/fan.py | 2 +- homeassistant/components/template/lock.py | 6 +++--- homeassistant/components/verisure/lock.py | 2 +- homeassistant/components/vesync/fan.py | 2 +- homeassistant/components/xiaomi_aqara/lock.py | 2 +- homeassistant/components/xiaomi_miio/fan.py | 2 +- homeassistant/components/zha/fan.py | 2 +- homeassistant/components/zha/lock.py | 5 +++-- homeassistant/components/zwave_js/fan.py | 2 +- 18 files changed, 27 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 9269dc52c6a..d77a61a0659 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -127,7 +127,7 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockEntity): "keypad_battery_level" ] = self._detail.keypad.battery_level - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" await super().async_added_to_hass() diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py index a36e7a35cff..02623b7b644 100644 --- a/homeassistant/components/demo/fan.py +++ b/homeassistant/components/demo/fan.py @@ -124,7 +124,7 @@ class BaseDemoFan(FanEntity): self._direction = "forward" @property - def unique_id(self): + def unique_id(self) -> str: """Return the unique id.""" return self._unique_id @@ -134,7 +134,7 @@ class BaseDemoFan(FanEntity): return self._name @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed for a demo fan.""" return False diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py index c59dfad2fab..d21d89f238b 100644 --- a/homeassistant/components/demo/lock.py +++ b/homeassistant/components/demo/lock.py @@ -111,7 +111,7 @@ class DemoLock(LockEntity): self.async_write_ha_state() @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" if self._openable: return LockEntityFeature.OPEN diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py index bfd6b430c9f..44dc2bb2521 100644 --- a/homeassistant/components/kiwi/lock.py +++ b/homeassistant/components/kiwi/lock.py @@ -83,7 +83,7 @@ class KiwiLock(LockEntity): } @property - def name(self): + def name(self) -> str | None: """Return the name of the lock.""" name = self._sensor.get("name") specifier = self._sensor["address"].get("specifier") @@ -95,7 +95,7 @@ class KiwiLock(LockEntity): return self._state == STATE_LOCKED @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" return self._device_attrs diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index f1a1dbed657..721fa93f244 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -502,7 +502,7 @@ class MqttFan(MqttEntity, FanEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" return self._optimistic diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 8cf65485a09..1d6a40c2331 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -189,12 +189,12 @@ class MqttLock(MqttEntity, LockEntity): return self._state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" return self._optimistic @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" return LockEntityFeature.OPEN if CONF_PAYLOAD_OPEN in self._config else 0 diff --git a/homeassistant/components/sesame/lock.py b/homeassistant/components/sesame/lock.py index b9a230fed63..c539e7507eb 100644 --- a/homeassistant/components/sesame/lock.py +++ b/homeassistant/components/sesame/lock.py @@ -82,7 +82,7 @@ class SesameDevice(LockEntity): self._responsive = status["responsive"] @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return { ATTR_DEVICE_ID: self._device_id, diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 81866010667..c0fbc32fa19 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -67,7 +67,7 @@ class SmartThingsLock(SmartThingsEntity, LockEntity): return self._device.status.lock == ST_STATE_LOCKED @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" state_attrs = {} status = self._device.status.attributes[Attribute.lock] diff --git a/homeassistant/components/starline/lock.py b/homeassistant/components/starline/lock.py index 3a5c45a2ed1..4fb8457a779 100644 --- a/homeassistant/components/starline/lock.py +++ b/homeassistant/components/starline/lock.py @@ -35,12 +35,12 @@ class StarlineLock(StarlineEntity, LockEntity): super().__init__(account, device, "lock", "Security") @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return super().available and self._device.online @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, bool]: """Return the state attributes of the lock. Possible dictionary keys: @@ -61,7 +61,7 @@ class StarlineLock(StarlineEntity, LockEntity): return self._device.alarm_state @property - def icon(self): + def icon(self) -> str: """Icon to use in the frontend, if any.""" return ( "mdi:shield-check-outline" if self.is_locked else "mdi:shield-alert-outline" diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index 67cbeb07170..b60e7f53364 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -354,7 +354,7 @@ class TemplateFan(TemplateEntity, FanEntity): ) self._state = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.add_template_attribute("_state", self._template, None, self._update_state) if self._preset_mode_template is not None: diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index 3f83e628f71..da8be80d8a4 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -92,9 +92,9 @@ class TemplateLock(TemplateEntity, LockEntity): self._optimistic = config.get(CONF_OPTIMISTIC) @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" - return self._optimistic + return bool(self._optimistic) @property def is_locked(self) -> bool: @@ -133,7 +133,7 @@ class TemplateLock(TemplateEntity, LockEntity): self._state = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.add_template_attribute( "_state", self._state_template, None, self._update_state diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py index f96b99e2a8c..8074cf28f32 100644 --- a/homeassistant/components/verisure/lock.py +++ b/homeassistant/components/verisure/lock.py @@ -119,7 +119,7 @@ class VerisureDoorlock(CoordinatorEntity[VerisureDataUpdateCoordinator], LockEnt ) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes.""" return {"method": self.changed_method} diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index 9932790fa96..f89224aaba8 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -130,7 +130,7 @@ class VeSyncFanHA(VeSyncDevice, FanEntity): return self.smartfan.uuid @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the fan.""" attr = {} diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py index 1885c1aef85..fea729b2b47 100644 --- a/homeassistant/components/xiaomi_aqara/lock.py +++ b/homeassistant/components/xiaomi_aqara/lock.py @@ -58,7 +58,7 @@ class XiaomiAqaraLock(LockEntity, XiaomiDevice): return self._changed_by @property - def extra_state_attributes(self) -> dict: + def extra_state_attributes(self) -> dict[str, int]: """Return the state attributes.""" attributes = {ATTR_VERIFIED_WRONG_TIMES: self._verified_wrong_times} return attributes diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py index b70ddc945ea..ac85955b347 100644 --- a/homeassistant/components/xiaomi_miio/fan.py +++ b/homeassistant/components/xiaomi_miio/fan.py @@ -304,7 +304,7 @@ class XiaomiGenericDevice(XiaomiCoordinatedMiioEntity, FanEntity): return None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the device.""" return self._state_attrs diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 1873c906a6f..3b9793c5137 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -134,7 +134,7 @@ class ZhaFan(BaseFan, ZhaEntity): super().__init__(unique_id, zha_device, channels, **kwargs) self._fan_channel = self.cluster_channels.get(CHANNEL_FAN) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 449fd1089eb..6615141f4d1 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -11,6 +11,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import StateType from .core import discovery from .core.const import ( @@ -96,7 +97,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): super().__init__(unique_id, zha_device, channels, **kwargs) self._doorlock_channel = self.cluster_channels.get(CHANNEL_DOORLOCK) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -116,7 +117,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): return self._state == STATE_LOCKED @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, StateType]: """Return state attributes.""" return self.state_attributes diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index ae9df47b420..27f73353ca8 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -404,7 +404,7 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): return cast(str, self._fan_state.metadata.states[str(value)]) @property - def extra_state_attributes(self) -> dict[str, str] | None: + def extra_state_attributes(self) -> dict[str, str]: """Return the optional state attributes.""" attrs = {} From e874ba2a42d95b9312db9680023aa615f20bf542 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 16:48:49 +0200 Subject: [PATCH 640/947] Improve CoverEntity typing (#73903) --- homeassistant/components/cover/__init__.py | 45 ++++++++++++---------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index aecca5a4029..b66398b3491 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -1,13 +1,15 @@ """Support for Cover devices.""" from __future__ import annotations +from collections.abc import Callable from dataclasses import dataclass from datetime import timedelta from enum import IntEnum import functools as ft import logging -from typing import Any, final +from typing import Any, TypeVar, final +from typing_extensions import ParamSpec import voluptuous as vol from homeassistant.backports.enum import StrEnum @@ -38,8 +40,6 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs - _LOGGER = logging.getLogger(__name__) DOMAIN = "cover" @@ -47,6 +47,9 @@ SCAN_INTERVAL = timedelta(seconds=15) ENTITY_ID_FORMAT = DOMAIN + ".{}" +_P = ParamSpec("_P") +_R = TypeVar("_R") + class CoverDeviceClass(StrEnum): """Device class for cover.""" @@ -112,7 +115,7 @@ ATTR_TILT_POSITION = "tilt_position" @bind_hass -def is_closed(hass, entity_id): +def is_closed(hass: HomeAssistant, entity_id: str) -> bool: """Return if the cover is closed based on the statemachine.""" return hass.states.is_state(entity_id, STATE_CLOSED) @@ -273,7 +276,7 @@ class CoverEntity(Entity): @final @property - def state_attributes(self): + def state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" data = {} @@ -327,7 +330,7 @@ class CoverEntity(Entity): """Open the cover.""" raise NotImplementedError() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.hass.async_add_executor_job(ft.partial(self.open_cover, **kwargs)) @@ -335,7 +338,7 @@ class CoverEntity(Entity): """Close cover.""" raise NotImplementedError() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self.hass.async_add_executor_job(ft.partial(self.close_cover, **kwargs)) @@ -349,7 +352,7 @@ class CoverEntity(Entity): function = self._get_toggle_function(fns) function(**kwargs) - async def async_toggle(self, **kwargs): + async def async_toggle(self, **kwargs: Any) -> None: """Toggle the entity.""" fns = { "open": self.async_open_cover, @@ -359,26 +362,26 @@ class CoverEntity(Entity): function = self._get_toggle_function(fns) await function(**kwargs) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.hass.async_add_executor_job( ft.partial(self.set_cover_position, **kwargs) ) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.hass.async_add_executor_job(ft.partial(self.stop_cover, **kwargs)) def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" await self.hass.async_add_executor_job( ft.partial(self.open_cover_tilt, **kwargs) @@ -387,25 +390,25 @@ class CoverEntity(Entity): def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" await self.hass.async_add_executor_job( ft.partial(self.close_cover_tilt, **kwargs) ) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" await self.hass.async_add_executor_job( ft.partial(self.set_cover_tilt_position, **kwargs) ) - def stop_cover_tilt(self, **kwargs): + def stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" await self.hass.async_add_executor_job( ft.partial(self.stop_cover_tilt, **kwargs) @@ -418,14 +421,16 @@ class CoverEntity(Entity): else: self.close_cover_tilt(**kwargs) - async def async_toggle_tilt(self, **kwargs): + async def async_toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" if self.current_cover_tilt_position == 0: await self.async_open_cover_tilt(**kwargs) else: await self.async_close_cover_tilt(**kwargs) - def _get_toggle_function(self, fns): + def _get_toggle_function( + self, fns: dict[str, Callable[_P, _R]] + ) -> Callable[_P, _R]: if CoverEntityFeature.STOP | self.supported_features and ( self.is_closing or self.is_opening ): From 17d839df79e4c0f20a964170baa106c999ea351e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Jun 2022 19:50:46 +0200 Subject: [PATCH 641/947] Set codeowner of weather to @home-assistant/core (#73915) --- CODEOWNERS | 4 ++-- homeassistant/components/weather/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b8e6a4ab7a7..9de118552aa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1162,8 +1162,8 @@ build.json @home-assistant/supervisor /tests/components/watttime/ @bachya /homeassistant/components/waze_travel_time/ @eifinger /tests/components/waze_travel_time/ @eifinger -/homeassistant/components/weather/ @fabaff -/tests/components/weather/ @fabaff +/homeassistant/components/weather/ @home-assistant/core +/tests/components/weather/ @home-assistant/core /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webostv/ @bendavid @thecode diff --git a/homeassistant/components/weather/manifest.json b/homeassistant/components/weather/manifest.json index c77e8408c83..cbf04af989d 100644 --- a/homeassistant/components/weather/manifest.json +++ b/homeassistant/components/weather/manifest.json @@ -2,6 +2,6 @@ "domain": "weather", "name": "Weather", "documentation": "https://www.home-assistant.io/integrations/weather", - "codeowners": ["@fabaff"], + "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } From 3d59088a62d6affb82d42ac6bb494a2919ac9663 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 13:13:42 -0500 Subject: [PATCH 642/947] Bump sqlalchemy to 1.4.38 (#73916) Changes: https://docs.sqlalchemy.org/en/14/changelog/changelog_14.html#change-1.4.38 --- homeassistant/components/recorder/manifest.json | 2 +- homeassistant/components/sql/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 38897c42e1a..3d22781906a 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -2,7 +2,7 @@ "domain": "recorder", "name": "Recorder", "documentation": "https://www.home-assistant.io/integrations/recorder", - "requirements": ["sqlalchemy==1.4.37", "fnvhash==0.1.0", "lru-dict==1.1.7"], + "requirements": ["sqlalchemy==1.4.38", "fnvhash==0.1.0", "lru-dict==1.1.7"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 4562b945008..0e6c68071cc 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -2,7 +2,7 @@ "domain": "sql", "name": "SQL", "documentation": "https://www.home-assistant.io/integrations/sql", - "requirements": ["sqlalchemy==1.4.37"], + "requirements": ["sqlalchemy==1.4.38"], "codeowners": ["@dgomes", "@gjohansson-ST"], "config_flow": true, "iot_class": "local_polling" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e9599bbe3a9..c9d011f3471 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -30,7 +30,7 @@ pyudev==0.22.0 pyyaml==6.0 requests==2.28.0 scapy==2.4.5 -sqlalchemy==1.4.37 +sqlalchemy==1.4.38 typing-extensions>=3.10.0.2,<5.0 voluptuous-serialize==2.5.0 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 2ffb5aad412..8935161ae1e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2217,7 +2217,7 @@ spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.37 +sqlalchemy==1.4.38 # homeassistant.components.srp_energy srpenergy==1.3.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cce7c56108d..d4f43b284c0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1471,7 +1471,7 @@ spotipy==2.20.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==1.4.37 +sqlalchemy==1.4.38 # homeassistant.components.srp_energy srpenergy==1.3.6 From edb386c73660ffc908c951a3d3237cb40cc8c6c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 13:19:13 -0500 Subject: [PATCH 643/947] Switch frontend to use json helper (#73874) --- homeassistant/components/frontend/__init__.py | 4 ++-- homeassistant/helpers/json.py | 9 +++++++++ tests/helpers/test_json.py | 15 ++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index b3907143eb9..188ecb8ff98 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Iterator from functools import lru_cache -import json import logging import os import pathlib @@ -22,6 +21,7 @@ from homeassistant.const import CONF_MODE, CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import service import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.json import json_dumps_sorted from homeassistant.helpers.storage import Store from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.typing import ConfigType @@ -135,7 +135,7 @@ class Manifest: return self._serialized def _serialize(self) -> None: - self._serialized = json.dumps(self.manifest, sort_keys=True) + self._serialized = json_dumps_sorted(self.manifest) def update_key(self, key: str, val: str) -> None: """Add a keyval to the manifest.json.""" diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index fd1153711ad..dbe3163da08 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -87,6 +87,15 @@ def json_dumps(data: Any) -> str: ).decode("utf-8") +def json_dumps_sorted(data: Any) -> str: + """Dump json string with keys sorted.""" + return orjson.dumps( + data, + option=orjson.OPT_NON_STR_KEYS | orjson.OPT_SORT_KEYS, + default=json_encoder_default, + ).decode("utf-8") + + json_loads = orjson.loads diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 4968c872946..17066b682af 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -1,10 +1,15 @@ """Test Home Assistant remote methods and classes.""" import datetime +import json import pytest from homeassistant import core -from homeassistant.helpers.json import ExtendedJSONEncoder, JSONEncoder +from homeassistant.helpers.json import ( + ExtendedJSONEncoder, + JSONEncoder, + json_dumps_sorted, +) from homeassistant.util import dt as dt_util @@ -64,3 +69,11 @@ def test_extended_json_encoder(hass): # Default method falls back to repr(o) o = object() assert ha_json_enc.default(o) == {"__type": str(type(o)), "repr": repr(o)} + + +def test_json_dumps_sorted(): + """Test the json dumps sorted function.""" + data = {"c": 3, "a": 1, "b": 2} + assert json_dumps_sorted(data) == json.dumps( + data, sort_keys=True, separators=(",", ":") + ) From b3b470757980db546e2c1c131a53bd46039ed81b Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 23 Jun 2022 20:26:51 +0200 Subject: [PATCH 644/947] Fix deCONZ group state regression (#73907) --- homeassistant/components/deconz/gateway.py | 1 - homeassistant/components/deconz/light.py | 25 ++++++++++++++++++- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 27 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 5890e372e66..c94b2c6d86d 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -287,7 +287,6 @@ async def get_deconz_session( config[CONF_HOST], config[CONF_PORT], config[CONF_API_KEY], - legacy_add_device=False, ) try: async with async_timeout.timeout(10): diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 53773369176..0cca007742a 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -105,7 +105,10 @@ async def async_setup_entry( @callback def async_add_group(_: EventType, group_id: str) -> None: - """Add group from deCONZ.""" + """Add group from deCONZ. + + Update group states based on its sum of related lights. + """ if ( not gateway.option_allow_deconz_groups or (group := gateway.api.groups[group_id]) @@ -113,6 +116,16 @@ async def async_setup_entry( ): return + first = True + for light_id in group.lights: + if ( + (light := gateway.api.lights.lights.get(light_id)) + and light.ZHATYPE == Light.ZHATYPE + and light.reachable + ): + group.update_color_state(light, update_all_attributes=first) + first = False + async_add_entities([DeconzGroup(group, gateway)]) config_entry.async_on_unload( @@ -289,6 +302,16 @@ class DeconzLight(DeconzBaseLight[Light]): """Return the coldest color_temp that this light supports.""" return self._device.min_color_temp or super().min_mireds + @callback + def async_update_callback(self) -> None: + """Light state will also reflect in relevant groups.""" + super().async_update_callback() + + if self._device.reachable and "attr" not in self._device.changed_keys: + for group in self.gateway.api.groups.values(): + if self._device.resource_id in group.lights: + group.update_color_state(self._device) + class DeconzGroup(DeconzBaseLight[Group]): """Representation of a deCONZ group.""" diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index ce10845a5b1..09dcc190a4f 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,7 +3,7 @@ "name": "deCONZ", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", - "requirements": ["pydeconz==94"], + "requirements": ["pydeconz==95"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/requirements_all.txt b/requirements_all.txt index 8935161ae1e..705d5341cb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1441,7 +1441,7 @@ pydaikin==2.7.0 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==94 +pydeconz==95 # homeassistant.components.delijn pydelijn==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4f43b284c0..b22bce40639 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -968,7 +968,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.7.0 # homeassistant.components.deconz -pydeconz==94 +pydeconz==95 # homeassistant.components.dexcom pydexcom==0.2.3 From fd9fdc628367be9d0c3daa91e3a63e3ce6199924 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 13:32:45 -0500 Subject: [PATCH 645/947] Fix error reporting with unserializable json (#73908) --- homeassistant/util/json.py | 4 +++- tests/util/test_json.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 82ecfd34d6d..8a9663bb95d 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -57,13 +57,15 @@ def save_json( Returns True on success. """ + dump: Callable[[Any], Any] = json.dumps try: if encoder: json_data = json.dumps(data, indent=2, cls=encoder) else: + dump = orjson.dumps json_data = orjson.dumps(data, option=orjson.OPT_INDENT_2).decode("utf-8") except TypeError as error: - msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data))}" + msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data, dump=dump))}" _LOGGER.error(msg) raise SerializationError(msg) from error diff --git a/tests/util/test_json.py b/tests/util/test_json.py index 461d94d0c67..abf47b0bc53 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -11,6 +11,7 @@ import pytest from homeassistant.core import Event, State from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.template import TupleWrapper from homeassistant.util.json import ( SerializationError, find_paths_unserializable_data, @@ -72,7 +73,7 @@ def test_overwrite_and_reload(atomic_writes): def test_save_bad_data(): - """Test error from trying to save unserialisable data.""" + """Test error from trying to save unserializable data.""" with pytest.raises(SerializationError) as excinfo: save_json("test4", {"hello": set()}) @@ -82,6 +83,17 @@ def test_save_bad_data(): ) +def test_save_bad_data_tuple_wrapper(): + """Test error from trying to save unserializable data.""" + with pytest.raises(SerializationError) as excinfo: + save_json("test4", {"hello": TupleWrapper(("4", "5"))}) + + assert ( + "Failed to serialize to JSON: test4. Bad data at $.hello=('4', '5')(" + in str(excinfo.value) + ) + + def test_load_bad_data(): """Test error from trying to load unserialisable data.""" fname = _path_for("test5") From 01606c34aa29e7f681415e3a5ac5be6d8678b377 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Jun 2022 20:34:16 +0200 Subject: [PATCH 646/947] Correct handling of weather forecast (#73909) --- homeassistant/components/weather/__init__.py | 40 ++++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 3e72c7ad931..f09e76b0073 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -719,10 +719,12 @@ class WeatherEntity(Entity): value_temp, precision ) - if forecast_temp_low := forecast_entry.pop( - ATTR_FORECAST_NATIVE_TEMP_LOW, - forecast_entry.get(ATTR_FORECAST_TEMP_LOW), - ): + if ( + forecast_temp_low := forecast_entry.pop( + ATTR_FORECAST_NATIVE_TEMP_LOW, + forecast_entry.get(ATTR_FORECAST_TEMP_LOW), + ) + ) is not None: with suppress(TypeError, ValueError): forecast_temp_low_f = float(forecast_temp_low) value_temp_low = UNIT_CONVERSIONS[ @@ -737,10 +739,12 @@ class WeatherEntity(Entity): value_temp_low, precision ) - if forecast_pressure := forecast_entry.pop( - ATTR_FORECAST_NATIVE_PRESSURE, - forecast_entry.get(ATTR_FORECAST_PRESSURE), - ): + if ( + forecast_pressure := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRESSURE, + forecast_entry.get(ATTR_FORECAST_PRESSURE), + ) + ) is not None: from_pressure_unit = ( self.native_pressure_unit or self._default_pressure_unit ) @@ -756,10 +760,12 @@ class WeatherEntity(Entity): ROUNDING_PRECISION, ) - if forecast_wind_speed := forecast_entry.pop( - ATTR_FORECAST_NATIVE_WIND_SPEED, - forecast_entry.get(ATTR_FORECAST_WIND_SPEED), - ): + if ( + forecast_wind_speed := forecast_entry.pop( + ATTR_FORECAST_NATIVE_WIND_SPEED, + forecast_entry.get(ATTR_FORECAST_WIND_SPEED), + ) + ) is not None: from_wind_speed_unit = ( self.native_wind_speed_unit or self._default_wind_speed_unit ) @@ -775,10 +781,12 @@ class WeatherEntity(Entity): ROUNDING_PRECISION, ) - if forecast_precipitation := forecast_entry.pop( - ATTR_FORECAST_NATIVE_PRECIPITATION, - forecast_entry.get(ATTR_FORECAST_PRECIPITATION), - ): + if ( + forecast_precipitation := forecast_entry.pop( + ATTR_FORECAST_NATIVE_PRECIPITATION, + forecast_entry.get(ATTR_FORECAST_PRECIPITATION), + ) + ) is not None: from_precipitation_unit = ( self.native_precipitation_unit or self._default_precipitation_unit From 5c193323b240aaa80c1e57da12b2444b03ac2af8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 13:43:56 -0500 Subject: [PATCH 647/947] Bump aiohomekit to 0.7.18 (#73919) Changelog: https://github.com/Jc2k/aiohomekit/compare/0.7.17...0.7.18 --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index ae9b1261bc8..3b3c5e51cf8 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.7.17"], + "requirements": ["aiohomekit==0.7.18"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 705d5341cb1..ba18173bcac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -162,7 +162,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.17 +aiohomekit==0.7.18 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b22bce40639..8545067551e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,7 +146,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.17 +aiohomekit==0.7.18 # homeassistant.components.emulated_hue # homeassistant.components.http From 00a79635c14618e3d2ab798fc228d7c5e1abf208 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 13:59:55 -0500 Subject: [PATCH 648/947] Revert "Remove sqlalchemy lambda_stmt usage from history, logbook, and statistics (#73191)" (#73917) --- .../components/logbook/queries/__init__.py | 26 ++-- .../components/logbook/queries/all.py | 20 +-- .../components/logbook/queries/devices.py | 42 +++--- .../components/logbook/queries/entities.py | 47 ++++--- .../logbook/queries/entities_and_devices.py | 59 ++++---- homeassistant/components/recorder/history.py | 130 ++++++++++-------- .../components/recorder/statistics.py | 99 +++++++------ homeassistant/components/recorder/util.py | 10 +- tests/components/recorder/test_util.py | 23 ++-- 9 files changed, 253 insertions(+), 203 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index a59ebc94b87..3c027823612 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -2,9 +2,8 @@ from __future__ import annotations from datetime import datetime as dt -import json -from sqlalchemy.sql.selectable import Select +from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters @@ -22,7 +21,7 @@ def statement_for_request( device_ids: list[str] | None = None, filters: Filters | None = None, context_id: str | None = None, -) -> Select: +) -> StatementLambdaElement: """Generate the logbook statement for a logbook request.""" # No entities: logbook sends everything for the timeframe @@ -39,36 +38,41 @@ def statement_for_request( context_id, ) + # sqlalchemy caches object quoting, the + # json quotable ones must be a different + # object from the non-json ones to prevent + # sqlalchemy from quoting them incorrectly + # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quoted_entity_ids = [json.dumps(entity_id) for entity_id in entity_ids] - json_quoted_device_ids = [json.dumps(device_id) for device_id in device_ids] + json_quotable_entity_ids = list(entity_ids) + json_quotable_device_ids = list(device_ids) return entities_devices_stmt( start_day, end_day, event_types, entity_ids, - json_quoted_entity_ids, - json_quoted_device_ids, + json_quotable_entity_ids, + json_quotable_device_ids, ) # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quoted_entity_ids = [json.dumps(entity_id) for entity_id in entity_ids] + json_quotable_entity_ids = list(entity_ids) return entities_stmt( start_day, end_day, event_types, entity_ids, - json_quoted_entity_ids, + json_quotable_entity_ids, ) # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quoted_device_ids = [json.dumps(device_id) for device_id in device_ids] + json_quotable_device_ids = list(device_ids) return devices_stmt( start_day, end_day, event_types, - json_quoted_device_ids, + json_quotable_device_ids, ) diff --git a/homeassistant/components/logbook/queries/all.py b/homeassistant/components/logbook/queries/all.py index e0a651c7972..da05aa02fff 100644 --- a/homeassistant/components/logbook/queries/all.py +++ b/homeassistant/components/logbook/queries/all.py @@ -3,9 +3,10 @@ from __future__ import annotations from datetime import datetime as dt +from sqlalchemy import lambda_stmt from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList -from sqlalchemy.sql.selectable import Select +from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.db_schema import ( LAST_UPDATED_INDEX, @@ -28,29 +29,32 @@ def all_stmt( states_entity_filter: ClauseList | None = None, events_entity_filter: ClauseList | None = None, context_id: str | None = None, -) -> Select: +) -> StatementLambdaElement: """Generate a logbook query for all entities.""" - stmt = select_events_without_states(start_day, end_day, event_types) + stmt = lambda_stmt( + lambda: select_events_without_states(start_day, end_day, event_types) + ) if context_id is not None: # Once all the old `state_changed` events # are gone from the database remove the # _legacy_select_events_context_id() - stmt = stmt.where(Events.context_id == context_id).union_all( + stmt += lambda s: s.where(Events.context_id == context_id).union_all( _states_query_for_context_id(start_day, end_day, context_id), legacy_select_events_context_id(start_day, end_day, context_id), ) else: if events_entity_filter is not None: - stmt = stmt.where(events_entity_filter) + stmt += lambda s: s.where(events_entity_filter) if states_entity_filter is not None: - stmt = stmt.union_all( + stmt += lambda s: s.union_all( _states_query_for_all(start_day, end_day).where(states_entity_filter) ) else: - stmt = stmt.union_all(_states_query_for_all(start_day, end_day)) + stmt += lambda s: s.union_all(_states_query_for_all(start_day, end_day)) - return stmt.order_by(Events.time_fired) + stmt += lambda s: s.order_by(Events.time_fired) + return stmt def _states_query_for_all(start_day: dt, end_day: dt) -> Query: diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index cbe766fb02c..f750c552bc4 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -4,10 +4,11 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt -from sqlalchemy import select +from sqlalchemy import lambda_stmt, select from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList -from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import CTE, CompoundSelect from homeassistant.components.recorder.db_schema import ( DEVICE_ID_IN_EVENT, @@ -30,11 +31,11 @@ def _select_device_id_context_ids_sub_query( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quoted_device_ids: list[str], + json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple devices.""" inner = select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quoted_device_ids) + apply_event_device_id_matchers(json_quotable_device_ids) ) return select(inner.c.context_id).group_by(inner.c.context_id) @@ -44,14 +45,14 @@ def _apply_devices_context_union( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quoted_device_ids: list[str], + json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the device context ids and a query to find linked row.""" devices_cte: CTE = _select_device_id_context_ids_sub_query( start_day, end_day, event_types, - json_quoted_device_ids, + json_quotable_device_ids, ).cte() return query.union_all( apply_events_context_hints( @@ -71,22 +72,25 @@ def devices_stmt( start_day: dt, end_day: dt, event_types: tuple[str, ...], - json_quoted_device_ids: list[str], -) -> Select: + json_quotable_device_ids: list[str], +) -> StatementLambdaElement: """Generate a logbook query for multiple devices.""" - return _apply_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( - apply_event_device_id_matchers(json_quoted_device_ids) - ), - start_day, - end_day, - event_types, - json_quoted_device_ids, - ).order_by(Events.time_fired) + stmt = lambda_stmt( + lambda: _apply_devices_context_union( + select_events_without_states(start_day, end_day, event_types).where( + apply_event_device_id_matchers(json_quotable_device_ids) + ), + start_day, + end_day, + event_types, + json_quotable_device_ids, + ).order_by(Events.time_fired) + ) + return stmt def apply_event_device_id_matchers( - json_quoted_device_ids: Iterable[str], + json_quotable_device_ids: Iterable[str], ) -> ClauseList: """Create matchers for the device_ids in the event_data.""" - return DEVICE_ID_IN_EVENT.in_(json_quoted_device_ids) + return DEVICE_ID_IN_EVENT.in_(json_quotable_device_ids) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 4d250fbb0f1..4ef96c100d7 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -5,9 +5,10 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query -from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import CTE, CompoundSelect from homeassistant.components.recorder.db_schema import ( ENTITY_ID_IN_EVENT, @@ -35,12 +36,12 @@ def _select_entities_context_ids_sub_query( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quoted_entity_ids: list[str], + json_quotable_entity_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities.""" union = union_all( select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quoted_entity_ids) + apply_event_entity_id_matchers(json_quotable_entity_ids) ), apply_entities_hints(select(States.context_id)) .filter((States.last_updated > start_day) & (States.last_updated < end_day)) @@ -55,7 +56,7 @@ def _apply_entities_context_union( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quoted_entity_ids: list[str], + json_quotable_entity_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the entity and device context ids and a query to find linked row.""" entities_cte: CTE = _select_entities_context_ids_sub_query( @@ -63,7 +64,7 @@ def _apply_entities_context_union( end_day, event_types, entity_ids, - json_quoted_entity_ids, + json_quotable_entity_ids, ).cte() # We used to optimize this to exclude rows we already in the union with # a States.entity_id.not_in(entity_ids) but that made the @@ -90,19 +91,21 @@ def entities_stmt( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quoted_entity_ids: list[str], -) -> Select: + json_quotable_entity_ids: list[str], +) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" - return _apply_entities_context_union( - select_events_without_states(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quoted_entity_ids) - ), - start_day, - end_day, - event_types, - entity_ids, - json_quoted_entity_ids, - ).order_by(Events.time_fired) + return lambda_stmt( + lambda: _apply_entities_context_union( + select_events_without_states(start_day, end_day, event_types).where( + apply_event_entity_id_matchers(json_quotable_entity_ids) + ), + start_day, + end_day, + event_types, + entity_ids, + json_quotable_entity_ids, + ).order_by(Events.time_fired) + ) def states_query_for_entity_ids( @@ -115,12 +118,12 @@ def states_query_for_entity_ids( def apply_event_entity_id_matchers( - json_quoted_entity_ids: Iterable[str], + json_quotable_entity_ids: Iterable[str], ) -> sqlalchemy.or_: """Create matchers for the entity_id in the event_data.""" - return ENTITY_ID_IN_EVENT.in_(json_quoted_entity_ids) | OLD_ENTITY_ID_IN_EVENT.in_( - json_quoted_entity_ids - ) + return ENTITY_ID_IN_EVENT.in_( + json_quotable_entity_ids + ) | OLD_ENTITY_ID_IN_EVENT.in_(json_quotable_entity_ids) def apply_entities_hints(query: Query) -> Query: diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 8b8051e2966..591918dd653 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -5,9 +5,10 @@ from collections.abc import Iterable from datetime import datetime as dt import sqlalchemy -from sqlalchemy import select, union_all +from sqlalchemy import lambda_stmt, select, union_all from sqlalchemy.orm import Query -from sqlalchemy.sql.selectable import CTE, CompoundSelect, Select +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import CTE, CompoundSelect from homeassistant.components.recorder.db_schema import EventData, Events, States @@ -32,14 +33,14 @@ def _select_entities_device_id_context_ids_sub_query( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quoted_entity_ids: list[str], - json_quoted_device_ids: list[str], + json_quotable_entity_ids: list[str], + json_quotable_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities and multiple devices.""" union = union_all( select_events_context_id_subquery(start_day, end_day, event_types).where( _apply_event_entity_id_device_id_matchers( - json_quoted_entity_ids, json_quoted_device_ids + json_quotable_entity_ids, json_quotable_device_ids ) ), apply_entities_hints(select(States.context_id)) @@ -55,16 +56,16 @@ def _apply_entities_devices_context_union( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quoted_entity_ids: list[str], - json_quoted_device_ids: list[str], + json_quotable_entity_ids: list[str], + json_quotable_device_ids: list[str], ) -> CompoundSelect: devices_entities_cte: CTE = _select_entities_device_id_context_ids_sub_query( start_day, end_day, event_types, entity_ids, - json_quoted_entity_ids, - json_quoted_device_ids, + json_quotable_entity_ids, + json_quotable_device_ids, ).cte() # We used to optimize this to exclude rows we already in the union with # a States.entity_id.not_in(entity_ids) but that made the @@ -91,30 +92,32 @@ def entities_devices_stmt( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quoted_entity_ids: list[str], - json_quoted_device_ids: list[str], -) -> Select: + json_quotable_entity_ids: list[str], + json_quotable_device_ids: list[str], +) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" - stmt = _apply_entities_devices_context_union( - select_events_without_states(start_day, end_day, event_types).where( - _apply_event_entity_id_device_id_matchers( - json_quoted_entity_ids, json_quoted_device_ids - ) - ), - start_day, - end_day, - event_types, - entity_ids, - json_quoted_entity_ids, - json_quoted_device_ids, - ).order_by(Events.time_fired) + stmt = lambda_stmt( + lambda: _apply_entities_devices_context_union( + select_events_without_states(start_day, end_day, event_types).where( + _apply_event_entity_id_device_id_matchers( + json_quotable_entity_ids, json_quotable_device_ids + ) + ), + start_day, + end_day, + event_types, + entity_ids, + json_quotable_entity_ids, + json_quotable_device_ids, + ).order_by(Events.time_fired) + ) return stmt def _apply_event_entity_id_device_id_matchers( - json_quoted_entity_ids: Iterable[str], json_quoted_device_ids: Iterable[str] + json_quotable_entity_ids: Iterable[str], json_quotable_device_ids: Iterable[str] ) -> sqlalchemy.or_: """Create matchers for the device_id and entity_id in the event_data.""" return apply_event_entity_id_matchers( - json_quoted_entity_ids - ) | apply_event_device_id_matchers(json_quoted_device_ids) + json_quotable_entity_ids + ) | apply_event_device_id_matchers(json_quotable_device_ids) diff --git a/homeassistant/components/recorder/history.py b/homeassistant/components/recorder/history.py index 1238b63f3c9..e1eca282a3a 100644 --- a/homeassistant/components/recorder/history.py +++ b/homeassistant/components/recorder/history.py @@ -9,11 +9,13 @@ import logging import time from typing import Any, cast -from sqlalchemy import Column, Text, and_, func, or_, select +from sqlalchemy import Column, Text, and_, func, lambda_stmt, or_, select from sqlalchemy.engine.row import Row +from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal -from sqlalchemy.sql.selectable import Select, Subquery +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Subquery from homeassistant.components import recorder from homeassistant.components.websocket_api.const import ( @@ -32,7 +34,7 @@ from .models import ( process_timestamp_to_utc_isoformat, row_to_compressed_state, ) -from .util import execute_stmt, session_scope +from .util import execute_stmt_lambda_element, session_scope # mypy: allow-untyped-defs, no-check-untyped-defs @@ -112,18 +114,22 @@ def _schema_version(hass: HomeAssistant) -> int: return recorder.get_instance(hass).schema_version -def stmt_and_join_attributes( +def lambda_stmt_and_join_attributes( schema_version: int, no_attributes: bool, include_last_changed: bool = True -) -> tuple[Select, bool]: - """Return the stmt and if StateAttributes should be joined.""" +) -> tuple[StatementLambdaElement, bool]: + """Return the lambda_stmt and if StateAttributes should be joined. + + Because these are lambda_stmt the values inside the lambdas need + to be explicitly written out to avoid caching the wrong values. + """ # If no_attributes was requested we do the query # without the attributes fields and do not join the # state_attributes table if no_attributes: if include_last_changed: - return select(*QUERY_STATE_NO_ATTR), False + return lambda_stmt(lambda: select(*QUERY_STATE_NO_ATTR)), False return ( - select(*QUERY_STATE_NO_ATTR_NO_LAST_CHANGED), + lambda_stmt(lambda: select(*QUERY_STATE_NO_ATTR_NO_LAST_CHANGED)), False, ) # If we in the process of migrating schema we do @@ -132,19 +138,19 @@ def stmt_and_join_attributes( if schema_version < 25: if include_last_changed: return ( - select(*QUERY_STATES_PRE_SCHEMA_25), + lambda_stmt(lambda: select(*QUERY_STATES_PRE_SCHEMA_25)), False, ) return ( - select(*QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED), + lambda_stmt(lambda: select(*QUERY_STATES_PRE_SCHEMA_25_NO_LAST_CHANGED)), False, ) # Finally if no migration is in progress and no_attributes # was not requested, we query both attributes columns and # join state_attributes if include_last_changed: - return select(*QUERY_STATES), True - return select(*QUERY_STATES_NO_LAST_CHANGED), True + return lambda_stmt(lambda: select(*QUERY_STATES)), True + return lambda_stmt(lambda: select(*QUERY_STATES_NO_LAST_CHANGED)), True def get_significant_states( @@ -176,7 +182,7 @@ def get_significant_states( ) -def _ignore_domains_filter(query: Select) -> Select: +def _ignore_domains_filter(query: Query) -> Query: """Add a filter to ignore domains we do not fetch history for.""" return query.filter( and_( @@ -196,9 +202,9 @@ def _significant_states_stmt( filters: Filters | None, significant_changes_only: bool, no_attributes: bool, -) -> Select: +) -> StatementLambdaElement: """Query the database for significant state changes.""" - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=not significant_changes_only ) if ( @@ -207,11 +213,11 @@ def _significant_states_stmt( and significant_changes_only and split_entity_id(entity_ids[0])[0] not in SIGNIFICANT_DOMAINS ): - stmt = stmt.filter( + stmt += lambda q: q.filter( (States.last_changed == States.last_updated) | States.last_changed.is_(None) ) elif significant_changes_only: - stmt = stmt.filter( + stmt += lambda q: q.filter( or_( *[ States.entity_id.like(entity_domain) @@ -225,22 +231,25 @@ def _significant_states_stmt( ) if entity_ids: - stmt = stmt.filter(States.entity_id.in_(entity_ids)) + stmt += lambda q: q.filter(States.entity_id.in_(entity_ids)) else: - stmt = _ignore_domains_filter(stmt) + stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt = stmt.filter(entity_filter) + stmt = stmt.add_criteria( + lambda q: q.filter(entity_filter), track_on=[filters] + ) - stmt = stmt.filter(States.last_updated > start_time) + stmt += lambda q: q.filter(States.last_updated > start_time) if end_time: - stmt = stmt.filter(States.last_updated < end_time) + stmt += lambda q: q.filter(States.last_updated < end_time) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - return stmt.order_by(States.entity_id, States.last_updated) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated) + return stmt def get_significant_states_with_session( @@ -277,7 +286,9 @@ def get_significant_states_with_session( significant_changes_only, no_attributes, ) - states = execute_stmt(session, stmt, None if entity_ids else start_time, end_time) + states = execute_stmt_lambda_element( + session, stmt, None if entity_ids else start_time, end_time + ) return _sorted_states_to_dict( hass, session, @@ -329,28 +340,28 @@ def _state_changed_during_period_stmt( no_attributes: bool, descending: bool, limit: int | None, -) -> Select: - stmt, join_attributes = stmt_and_join_attributes( +) -> StatementLambdaElement: + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=False ) - stmt = stmt.filter( + stmt += lambda q: q.filter( ((States.last_changed == States.last_updated) | States.last_changed.is_(None)) & (States.last_updated > start_time) ) if end_time: - stmt = stmt.filter(States.last_updated < end_time) + stmt += lambda q: q.filter(States.last_updated < end_time) if entity_id: - stmt = stmt.filter(States.entity_id == entity_id) + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) if descending: - stmt = stmt.order_by(States.entity_id, States.last_updated.desc()) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()) else: - stmt = stmt.order_by(States.entity_id, States.last_updated) + stmt += lambda q: q.order_by(States.entity_id, States.last_updated) if limit: - stmt = stmt.limit(limit) + stmt += lambda q: q.limit(limit) return stmt @@ -378,7 +389,7 @@ def state_changes_during_period( descending, limit, ) - states = execute_stmt( + states = execute_stmt_lambda_element( session, stmt, None if entity_id else start_time, end_time ) return cast( @@ -396,22 +407,23 @@ def state_changes_during_period( def _get_last_state_changes_stmt( schema_version: int, number_of_states: int, entity_id: str | None -) -> Select: - stmt, join_attributes = stmt_and_join_attributes( +) -> StatementLambdaElement: + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, False, include_last_changed=False ) - stmt = stmt.filter( + stmt += lambda q: q.filter( (States.last_changed == States.last_updated) | States.last_changed.is_(None) ) if entity_id: - stmt = stmt.filter(States.entity_id == entity_id) + stmt += lambda q: q.filter(States.entity_id == entity_id) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - return stmt.order_by(States.entity_id, States.last_updated.desc()).limit( + stmt += lambda q: q.order_by(States.entity_id, States.last_updated.desc()).limit( number_of_states ) + return stmt def get_last_state_changes( @@ -426,7 +438,7 @@ def get_last_state_changes( stmt = _get_last_state_changes_stmt( _schema_version(hass), number_of_states, entity_id ) - states = list(execute_stmt(session, stmt)) + states = list(execute_stmt_lambda_element(session, stmt)) return cast( MutableMapping[str, list[State]], _sorted_states_to_dict( @@ -446,14 +458,14 @@ def _get_states_for_entites_stmt( utc_point_in_time: datetime, entity_ids: list[str], no_attributes: bool, -) -> Select: +) -> StatementLambdaElement: """Baked query to get states for specific entities.""" - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We got an include-list of entities, accelerate the query by filtering already # in the inner query. - stmt = stmt.where( + stmt += lambda q: q.where( States.state_id == ( select(func.max(States.state_id).label("max_state_id")) @@ -467,7 +479,7 @@ def _get_states_for_entites_stmt( ).c.max_state_id ) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) return stmt @@ -498,9 +510,9 @@ def _get_states_for_all_stmt( utc_point_in_time: datetime, filters: Filters | None, no_attributes: bool, -) -> Select: +) -> StatementLambdaElement: """Baked query to get states for all entities.""" - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) # We did not get an include-list of entities, query all states in the inner @@ -510,7 +522,7 @@ def _get_states_for_all_stmt( most_recent_states_by_date = _generate_most_recent_states_by_date( run_start, utc_point_in_time ) - stmt = stmt.where( + stmt += lambda q: q.where( States.state_id == ( select(func.max(States.state_id).label("max_state_id")) @@ -526,12 +538,12 @@ def _get_states_for_all_stmt( .subquery() ).c.max_state_id, ) - stmt = _ignore_domains_filter(stmt) + stmt += _ignore_domains_filter if filters and filters.has_config: entity_filter = filters.states_entity_filter() - stmt = stmt.filter(entity_filter) + stmt = stmt.add_criteria(lambda q: q.filter(entity_filter), track_on=[filters]) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, (States.attributes_id == StateAttributes.attributes_id) ) return stmt @@ -549,7 +561,7 @@ def _get_rows_with_session( """Return the states at a specific point in time.""" schema_version = _schema_version(hass) if entity_ids and len(entity_ids) == 1: - return execute_stmt( + return execute_stmt_lambda_element( session, _get_single_entity_states_stmt( schema_version, utc_point_in_time, entity_ids[0], no_attributes @@ -574,7 +586,7 @@ def _get_rows_with_session( schema_version, run.start, utc_point_in_time, filters, no_attributes ) - return execute_stmt(session, stmt) + return execute_stmt_lambda_element(session, stmt) def _get_single_entity_states_stmt( @@ -582,14 +594,14 @@ def _get_single_entity_states_stmt( utc_point_in_time: datetime, entity_id: str, no_attributes: bool = False, -) -> Select: +) -> StatementLambdaElement: # Use an entirely different (and extremely fast) query if we only # have a single entity id - stmt, join_attributes = stmt_and_join_attributes( + stmt, join_attributes = lambda_stmt_and_join_attributes( schema_version, no_attributes, include_last_changed=True ) - stmt = ( - stmt.filter( + stmt += ( + lambda q: q.filter( States.last_updated < utc_point_in_time, States.entity_id == entity_id, ) @@ -597,7 +609,7 @@ def _get_single_entity_states_stmt( .limit(1) ) if join_attributes: - stmt = stmt.outerjoin( + stmt += lambda q: q.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) return stmt diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 8d314830ec4..26221aa199b 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -14,12 +14,13 @@ import re from statistics import mean from typing import TYPE_CHECKING, Any, Literal, overload -from sqlalchemy import bindparam, func, select +from sqlalchemy import bindparam, func, lambda_stmt, select from sqlalchemy.engine.row import Row from sqlalchemy.exc import SQLAlchemyError, StatementError from sqlalchemy.orm.session import Session from sqlalchemy.sql.expression import literal_column, true -from sqlalchemy.sql.selectable import Select, Subquery +from sqlalchemy.sql.lambdas import StatementLambdaElement +from sqlalchemy.sql.selectable import Subquery import voluptuous as vol from homeassistant.const import ( @@ -49,7 +50,12 @@ from .models import ( process_timestamp, process_timestamp_to_utc_isoformat, ) -from .util import execute, execute_stmt, retryable_database_job, session_scope +from .util import ( + execute, + execute_stmt_lambda_element, + retryable_database_job, + session_scope, +) if TYPE_CHECKING: from . import Recorder @@ -474,10 +480,10 @@ def delete_statistics_meta_duplicates(session: Session) -> None: def _compile_hourly_statistics_summary_mean_stmt( start_time: datetime, end_time: datetime -) -> Select: +) -> StatementLambdaElement: """Generate the summary mean statement for hourly statistics.""" - return ( - select(*QUERY_STATISTICS_SUMMARY_MEAN) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SUMMARY_MEAN) .filter(StatisticsShortTerm.start >= start_time) .filter(StatisticsShortTerm.start < end_time) .group_by(StatisticsShortTerm.metadata_id) @@ -500,7 +506,7 @@ def compile_hourly_statistics( # Compute last hour's average, min, max summary: dict[str, StatisticData] = {} stmt = _compile_hourly_statistics_summary_mean_stmt(start_time, end_time) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if stats: for stat in stats: @@ -682,17 +688,17 @@ def _generate_get_metadata_stmt( statistic_ids: list[str] | tuple[str] | None = None, statistic_type: Literal["mean"] | Literal["sum"] | None = None, statistic_source: str | None = None, -) -> Select: +) -> StatementLambdaElement: """Generate a statement to fetch metadata.""" - stmt = select(*QUERY_STATISTIC_META) + stmt = lambda_stmt(lambda: select(*QUERY_STATISTIC_META)) if statistic_ids is not None: - stmt = stmt.where(StatisticsMeta.statistic_id.in_(statistic_ids)) + stmt += lambda q: q.where(StatisticsMeta.statistic_id.in_(statistic_ids)) if statistic_source is not None: - stmt = stmt.where(StatisticsMeta.source == statistic_source) + stmt += lambda q: q.where(StatisticsMeta.source == statistic_source) if statistic_type == "mean": - stmt = stmt.where(StatisticsMeta.has_mean == true()) + stmt += lambda q: q.where(StatisticsMeta.has_mean == true()) elif statistic_type == "sum": - stmt = stmt.where(StatisticsMeta.has_sum == true()) + stmt += lambda q: q.where(StatisticsMeta.has_sum == true()) return stmt @@ -714,7 +720,7 @@ def get_metadata_with_session( # Fetch metatadata from the database stmt = _generate_get_metadata_stmt(statistic_ids, statistic_type, statistic_source) - result = execute_stmt(session, stmt) + result = execute_stmt_lambda_element(session, stmt) if not result: return {} @@ -976,30 +982,44 @@ def _statistics_during_period_stmt( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, -) -> Select: - """Prepare a database query for statistics during a given period.""" - stmt = select(*QUERY_STATISTICS).filter(Statistics.start >= start_time) +) -> StatementLambdaElement: + """Prepare a database query for statistics during a given period. + + This prepares a lambda_stmt query, so we don't insert the parameters yet. + """ + stmt = lambda_stmt( + lambda: select(*QUERY_STATISTICS).filter(Statistics.start >= start_time) + ) if end_time is not None: - stmt = stmt.filter(Statistics.start < end_time) + stmt += lambda q: q.filter(Statistics.start < end_time) if metadata_ids: - stmt = stmt.filter(Statistics.metadata_id.in_(metadata_ids)) - return stmt.order_by(Statistics.metadata_id, Statistics.start) + stmt += lambda q: q.filter(Statistics.metadata_id.in_(metadata_ids)) + stmt += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) + return stmt def _statistics_during_period_stmt_short_term( start_time: datetime, end_time: datetime | None, metadata_ids: list[int] | None, -) -> Select: - """Prepare a database query for short term statistics during a given period.""" - stmt = select(*QUERY_STATISTICS_SHORT_TERM).filter( - StatisticsShortTerm.start >= start_time +) -> StatementLambdaElement: + """Prepare a database query for short term statistics during a given period. + + This prepares a lambda_stmt query, so we don't insert the parameters yet. + """ + stmt = lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM).filter( + StatisticsShortTerm.start >= start_time + ) ) if end_time is not None: - stmt = stmt.filter(StatisticsShortTerm.start < end_time) + stmt += lambda q: q.filter(StatisticsShortTerm.start < end_time) if metadata_ids: - stmt = stmt.filter(StatisticsShortTerm.metadata_id.in_(metadata_ids)) - return stmt.order_by(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start) + stmt += lambda q: q.filter(StatisticsShortTerm.metadata_id.in_(metadata_ids)) + stmt += lambda q: q.order_by( + StatisticsShortTerm.metadata_id, StatisticsShortTerm.start + ) + return stmt def statistics_during_period( @@ -1034,7 +1054,7 @@ def statistics_during_period( else: table = Statistics stmt = _statistics_during_period_stmt(start_time, end_time, metadata_ids) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if not stats: return {} @@ -1065,10 +1085,10 @@ def statistics_during_period( def _get_last_statistics_stmt( metadata_id: int, number_of_stats: int, -) -> Select: +) -> StatementLambdaElement: """Generate a statement for number_of_stats statistics for a given statistic_id.""" - return ( - select(*QUERY_STATISTICS) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS) .filter_by(metadata_id=metadata_id) .order_by(Statistics.metadata_id, Statistics.start.desc()) .limit(number_of_stats) @@ -1078,10 +1098,10 @@ def _get_last_statistics_stmt( def _get_last_statistics_short_term_stmt( metadata_id: int, number_of_stats: int, -) -> Select: +) -> StatementLambdaElement: """Generate a statement for number_of_stats short term statistics for a given statistic_id.""" - return ( - select(*QUERY_STATISTICS_SHORT_TERM) + return lambda_stmt( + lambda: select(*QUERY_STATISTICS_SHORT_TERM) .filter_by(metadata_id=metadata_id) .order_by(StatisticsShortTerm.metadata_id, StatisticsShortTerm.start.desc()) .limit(number_of_stats) @@ -1107,7 +1127,7 @@ def _get_last_statistics( stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) else: stmt = _get_last_statistics_short_term_stmt(metadata_id, number_of_stats) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if not stats: return {} @@ -1157,11 +1177,11 @@ def _generate_most_recent_statistic_row(metadata_ids: list[int]) -> Subquery: def _latest_short_term_statistics_stmt( metadata_ids: list[int], -) -> Select: +) -> StatementLambdaElement: """Create the statement for finding the latest short term stat rows.""" - stmt = select(*QUERY_STATISTICS_SHORT_TERM) + stmt = lambda_stmt(lambda: select(*QUERY_STATISTICS_SHORT_TERM)) most_recent_statistic_row = _generate_most_recent_statistic_row(metadata_ids) - return stmt.join( + stmt += lambda s: s.join( most_recent_statistic_row, ( StatisticsShortTerm.metadata_id # pylint: disable=comparison-with-callable @@ -1169,6 +1189,7 @@ def _latest_short_term_statistics_stmt( ) & (StatisticsShortTerm.start == most_recent_statistic_row.c.start_max), ) + return stmt def get_latest_short_term_statistics( @@ -1191,7 +1212,7 @@ def get_latest_short_term_statistics( if statistic_id in metadata ] stmt = _latest_short_term_statistics_stmt(metadata_ids) - stats = execute_stmt(session, stmt) + stats = execute_stmt_lambda_element(session, stmt) if not stats: return {} diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 7e183f7f64f..c1fbc831987 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -22,6 +22,7 @@ from sqlalchemy.engine.row import Row from sqlalchemy.exc import OperationalError, SQLAlchemyError from sqlalchemy.orm.query import Query from sqlalchemy.orm.session import Session +from sqlalchemy.sql.lambdas import StatementLambdaElement from typing_extensions import Concatenate, ParamSpec from homeassistant.core import HomeAssistant @@ -165,9 +166,9 @@ def execute( assert False # unreachable # pragma: no cover -def execute_stmt( +def execute_stmt_lambda_element( session: Session, - query: Query, + stmt: StatementLambdaElement, start_time: datetime | None = None, end_time: datetime | None = None, yield_per: int | None = DEFAULT_YIELD_STATES_ROWS, @@ -183,12 +184,11 @@ def execute_stmt( specific entities) since they are usually faster with .all(). """ + executed = session.execute(stmt) use_all = not start_time or ((end_time or dt_util.utcnow()) - start_time).days <= 1 for tryno in range(0, RETRIES): try: - if use_all: - return session.execute(query).all() # type: ignore[no-any-return] - return session.execute(query).yield_per(yield_per) # type: ignore[no-any-return] + return executed.all() if use_all else executed.yield_per(yield_per) # type: ignore[no-any-return] except SQLAlchemyError as err: _LOGGER.error("Error executing query: %s", err) if tryno == RETRIES - 1: diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py index 97cf4a58b5c..8624719f951 100644 --- a/tests/components/recorder/test_util.py +++ b/tests/components/recorder/test_util.py @@ -9,6 +9,7 @@ from sqlalchemy import text from sqlalchemy.engine.result import ChunkedIteratorResult from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.sql.elements import TextClause +from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components import recorder from homeassistant.components.recorder import history, util @@ -712,8 +713,8 @@ def test_build_mysqldb_conv(): @patch("homeassistant.components.recorder.util.QUERY_RETRY_WAIT", 0) -def test_execute_stmt(hass_recorder): - """Test executing with execute_stmt.""" +def test_execute_stmt_lambda_element(hass_recorder): + """Test executing with execute_stmt_lambda_element.""" hass = hass_recorder() instance = recorder.get_instance(hass) hass.states.set("sensor.on", "on") @@ -724,15 +725,13 @@ def test_execute_stmt(hass_recorder): one_week_from_now = now + timedelta(days=7) class MockExecutor: - - _calls = 0 - def __init__(self, stmt): - """Init the mock.""" + assert isinstance(stmt, StatementLambdaElement) + self.calls = 0 def all(self): - MockExecutor._calls += 1 - if MockExecutor._calls == 2: + self.calls += 1 + if self.calls == 2: return ["mock_row"] raise SQLAlchemyError @@ -741,24 +740,24 @@ def test_execute_stmt(hass_recorder): stmt = history._get_single_entity_states_stmt( instance.schema_version, dt_util.utcnow(), "sensor.on", False ) - rows = util.execute_stmt(session, stmt) + rows = util.execute_stmt_lambda_element(session, stmt) assert isinstance(rows, list) assert rows[0].state == new_state.state assert rows[0].entity_id == new_state.entity_id # Time window >= 2 days, we get a ChunkedIteratorResult - rows = util.execute_stmt(session, stmt, now, one_week_from_now) + rows = util.execute_stmt_lambda_element(session, stmt, now, one_week_from_now) assert isinstance(rows, ChunkedIteratorResult) row = next(rows) assert row.state == new_state.state assert row.entity_id == new_state.entity_id # Time window < 2 days, we get a list - rows = util.execute_stmt(session, stmt, now, tomorrow) + rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) assert isinstance(rows, list) assert rows[0].state == new_state.state assert rows[0].entity_id == new_state.entity_id with patch.object(session, "execute", MockExecutor): - rows = util.execute_stmt(session, stmt, now, tomorrow) + rows = util.execute_stmt_lambda_element(session, stmt, now, tomorrow) assert rows == ["mock_row"] From e57f34f0f2249aa97d3f93339bfe54e88ab4b2bf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Jun 2022 21:01:08 +0200 Subject: [PATCH 649/947] Migrate openweathermap to native_* (#73913) --- .../components/openweathermap/const.py | 16 +++++----- .../components/openweathermap/weather.py | 32 +++++++++---------- .../weather_update_coordinator.py | 28 +++++++++------- 3 files changed, 41 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 8d673507929..027c08fd84b 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -22,11 +22,11 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_PRESSURE, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ) from homeassistant.const import ( @@ -266,7 +266,7 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION, + key=ATTR_FORECAST_NATIVE_PRECIPITATION, name="Precipitation", native_unit_of_measurement=LENGTH_MILLIMETERS, ), @@ -276,19 +276,19 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_PRESSURE, + key=ATTR_FORECAST_NATIVE_PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP, + key=ATTR_FORECAST_NATIVE_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP_LOW, + key=ATTR_FORECAST_NATIVE_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index d4ab99bc30b..fce6efdf3c5 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -3,12 +3,16 @@ from __future__ import annotations from homeassistant.components.weather import Forecast, WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRESSURE_HPA, PRESSURE_INHG, TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.pressure import convert as pressure_convert from .const import ( ATTR_API_CONDITION, @@ -49,7 +53,11 @@ class OpenWeatherMapWeather(WeatherEntity): _attr_attribution = ATTRIBUTION _attr_should_poll = False - _attr_temperature_unit = TEMP_CELSIUS + + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__( self, @@ -74,19 +82,14 @@ class OpenWeatherMapWeather(WeatherEntity): return self._weather_coordinator.data[ATTR_API_CONDITION] @property - def temperature(self) -> float | None: + def native_temperature(self) -> float | None: """Return the temperature.""" return self._weather_coordinator.data[ATTR_API_TEMPERATURE] @property - def pressure(self) -> float | None: + def native_pressure(self) -> float | None: """Return the pressure.""" - pressure = self._weather_coordinator.data[ATTR_API_PRESSURE] - # OpenWeatherMap returns pressure in hPA, so convert to - # inHg if we aren't using metric. - if not self.hass.config.units.is_metric and pressure: - return pressure_convert(pressure, PRESSURE_HPA, PRESSURE_INHG) - return pressure + return self._weather_coordinator.data[ATTR_API_PRESSURE] @property def humidity(self) -> float | None: @@ -94,12 +97,9 @@ class OpenWeatherMapWeather(WeatherEntity): return self._weather_coordinator.data[ATTR_API_HUMIDITY] @property - def wind_speed(self) -> float | None: + def native_wind_speed(self) -> float | None: """Return the wind speed.""" - wind_speed = self._weather_coordinator.data[ATTR_API_WIND_SPEED] - if self.hass.config.units.name == "imperial": - return round(wind_speed * 2.24, 2) - return round(wind_speed * 3.6, 2) + return self._weather_coordinator.data[ATTR_API_WIND_SPEED] @property def wind_bearing(self) -> float | str | None: diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 26341621051..36511424737 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -9,14 +9,14 @@ from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, 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, ) from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -161,14 +161,14 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_FORECAST_TIME: dt.utc_from_timestamp( entry.reference_time("unix") ).isoformat(), - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation( + ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation( entry.rain, entry.snow ), ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( round(entry.precipitation_probability * 100) ), - ATTR_FORECAST_PRESSURE: entry.pressure.get("press"), - ATTR_FORECAST_WIND_SPEED: entry.wind().get("speed"), + ATTR_FORECAST_NATIVE_PRESSURE: entry.pressure.get("press"), + ATTR_FORECAST_NATIVE_WIND_SPEED: entry.wind().get("speed"), ATTR_FORECAST_WIND_BEARING: entry.wind().get("deg"), ATTR_FORECAST_CONDITION: self._get_condition( entry.weather_code, entry.reference_time("unix") @@ -178,10 +178,16 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): temperature_dict = entry.temperature("celsius") if "max" in temperature_dict and "min" in temperature_dict: - forecast[ATTR_FORECAST_TEMP] = entry.temperature("celsius").get("max") - forecast[ATTR_FORECAST_TEMP_LOW] = entry.temperature("celsius").get("min") + forecast[ATTR_FORECAST_NATIVE_TEMP] = entry.temperature("celsius").get( + "max" + ) + forecast[ATTR_FORECAST_NATIVE_TEMP_LOW] = entry.temperature("celsius").get( + "min" + ) else: - forecast[ATTR_FORECAST_TEMP] = entry.temperature("celsius").get("temp") + forecast[ATTR_FORECAST_NATIVE_TEMP] = entry.temperature("celsius").get( + "temp" + ) return forecast From 03f0916e7cc9b1f0eded498c8d6f68f4cc26e5a8 Mon Sep 17 00:00:00 2001 From: tbertonatti Date: Thu, 23 Jun 2022 16:02:48 -0300 Subject: [PATCH 650/947] Add embed image parameter for Discord notify (#73474) --- homeassistant/components/discord/notify.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py index 299919472cf..d97ce7042bc 100644 --- a/homeassistant/components/discord/notify.py +++ b/homeassistant/components/discord/notify.py @@ -27,6 +27,7 @@ ATTR_EMBED_FIELDS = "fields" ATTR_EMBED_FOOTER = "footer" ATTR_EMBED_TITLE = "title" ATTR_EMBED_THUMBNAIL = "thumbnail" +ATTR_EMBED_IMAGE = "image" ATTR_EMBED_URL = "url" ATTR_IMAGES = "images" @@ -94,6 +95,8 @@ class DiscordNotificationService(BaseNotificationService): embed.set_author(**embedding[ATTR_EMBED_AUTHOR]) if ATTR_EMBED_THUMBNAIL in embedding: embed.set_thumbnail(**embedding[ATTR_EMBED_THUMBNAIL]) + if ATTR_EMBED_IMAGE in embedding: + embed.set_image(**embedding[ATTR_EMBED_IMAGE]) embeds.append(embed) if ATTR_IMAGES in data: From 0df0533cd4b1c6743cd48a77354ecd656c187f0e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 21:20:42 +0200 Subject: [PATCH 651/947] Use attributes in smarty fan (#73895) --- .../components/smarty/binary_sensor.py | 23 +++++++++----- homeassistant/components/smarty/fan.py | 29 ++++++------------ homeassistant/components/smarty/sensor.py | 30 +++++++++++-------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/smarty/binary_sensor.py b/homeassistant/components/smarty/binary_sensor.py index f3e2c8bab2a..d9d757a71b5 100644 --- a/homeassistant/components/smarty/binary_sensor.py +++ b/homeassistant/components/smarty/binary_sensor.py @@ -3,6 +3,8 @@ from __future__ import annotations import logging +from pysmarty import Smarty + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -24,8 +26,8 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Smarty Binary Sensor Platform.""" - smarty = hass.data[DOMAIN]["api"] - name = hass.data[DOMAIN]["name"] + smarty: Smarty = hass.data[DOMAIN]["api"] + name: str = hass.data[DOMAIN]["name"] sensors = [ AlarmSensor(name, smarty), @@ -41,18 +43,23 @@ class SmartyBinarySensor(BinarySensorEntity): _attr_should_poll = False - def __init__(self, name, device_class, smarty): + def __init__( + self, + name: str, + device_class: BinarySensorDeviceClass | None, + smarty: Smarty, + ) -> None: """Initialize the entity.""" self._attr_name = name self._attr_device_class = device_class self._smarty = smarty - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" async_dispatcher_connect(self.hass, SIGNAL_UPDATE_SMARTY, self._update_callback) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @@ -60,7 +67,7 @@ class SmartyBinarySensor(BinarySensorEntity): class BoostSensor(SmartyBinarySensor): """Boost State Binary Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Alarm Sensor Init.""" super().__init__(name=f"{name} Boost State", device_class=None, smarty=smarty) @@ -73,7 +80,7 @@ class BoostSensor(SmartyBinarySensor): class AlarmSensor(SmartyBinarySensor): """Alarm Binary Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Alarm Sensor Init.""" super().__init__( name=f"{name} Alarm", @@ -90,7 +97,7 @@ class AlarmSensor(SmartyBinarySensor): class WarningSensor(SmartyBinarySensor): """Warning Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Warning Sensor Init.""" super().__init__( name=f"{name} Warning", diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index ceb3b17e030..cf4b49e6105 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -5,6 +5,8 @@ import logging import math from typing import Any +from pysmarty import Smarty + from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -32,8 +34,8 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Smarty Fan Platform.""" - smarty = hass.data[DOMAIN]["api"] - name = hass.data[DOMAIN]["name"] + smarty: Smarty = hass.data[DOMAIN]["api"] + name: str = hass.data[DOMAIN]["name"] async_add_entities([SmartyFan(name, smarty)], True) @@ -41,29 +43,16 @@ async def async_setup_platform( class SmartyFan(FanEntity): """Representation of a Smarty Fan.""" + _attr_icon = "mdi:air-conditioner" + _attr_should_poll = False _attr_supported_features = FanEntityFeature.SET_SPEED def __init__(self, name, smarty): """Initialize the entity.""" - self._name = name + self._attr_name = name self._smarty_fan_speed = 0 self._smarty = smarty - @property - def should_poll(self): - """Do not poll.""" - return False - - @property - def name(self): - """Return the name of the fan.""" - return self._name - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:air-conditioner" - @property def is_on(self) -> bool: """Return state of the fan.""" @@ -116,7 +105,7 @@ class SmartyFan(FanEntity): self._smarty_fan_speed = 0 self.schedule_update_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update fan.""" self.async_on_remove( async_dispatcher_connect( @@ -125,7 +114,7 @@ class SmartyFan(FanEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" _LOGGER.debug("Updating state") self._smarty_fan_speed = self._smarty.fan_speed diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 2ba5e81f286..1c76fe3bfb9 100644 --- a/homeassistant/components/smarty/sensor.py +++ b/homeassistant/components/smarty/sensor.py @@ -4,6 +4,8 @@ from __future__ import annotations import datetime as dt import logging +from pysmarty import Smarty + from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import TEMP_CELSIUS from homeassistant.core import HomeAssistant, callback @@ -24,8 +26,8 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Smarty Sensor Platform.""" - smarty = hass.data[DOMAIN]["api"] - name = hass.data[DOMAIN]["name"] + smarty: Smarty = hass.data[DOMAIN]["api"] + name: str = hass.data[DOMAIN]["name"] sensors = [ SupplyAirTemperatureSensor(name, smarty), @@ -45,8 +47,12 @@ class SmartySensor(SensorEntity): _attr_should_poll = False def __init__( - self, name: str, device_class: str, smarty, unit_of_measurement: str = "" - ): + self, + name: str, + device_class: SensorDeviceClass | None, + smarty: Smarty, + unit_of_measurement: str | None, + ) -> None: """Initialize the entity.""" self._attr_name = name self._attr_native_value = None @@ -54,12 +60,12 @@ class SmartySensor(SensorEntity): self._attr_native_unit_of_measurement = unit_of_measurement self._smarty = smarty - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call to update.""" async_dispatcher_connect(self.hass, SIGNAL_UPDATE_SMARTY, self._update_callback) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @@ -67,7 +73,7 @@ class SmartySensor(SensorEntity): class SupplyAirTemperatureSensor(SmartySensor): """Supply Air Temperature Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Supply Air Temperature Init.""" super().__init__( name=f"{name} Supply Air Temperature", @@ -85,7 +91,7 @@ class SupplyAirTemperatureSensor(SmartySensor): class ExtractAirTemperatureSensor(SmartySensor): """Extract Air Temperature Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Supply Air Temperature Init.""" super().__init__( name=f"{name} Extract Air Temperature", @@ -103,7 +109,7 @@ class ExtractAirTemperatureSensor(SmartySensor): class OutdoorAirTemperatureSensor(SmartySensor): """Extract Air Temperature Sensor.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Outdoor Air Temperature Init.""" super().__init__( name=f"{name} Outdoor Air Temperature", @@ -121,7 +127,7 @@ class OutdoorAirTemperatureSensor(SmartySensor): class SupplyFanSpeedSensor(SmartySensor): """Supply Fan Speed RPM.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Supply Fan Speed RPM Init.""" super().__init__( name=f"{name} Supply Fan Speed", @@ -139,7 +145,7 @@ class SupplyFanSpeedSensor(SmartySensor): class ExtractFanSpeedSensor(SmartySensor): """Extract Fan Speed RPM.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Extract Fan Speed RPM Init.""" super().__init__( name=f"{name} Extract Fan Speed", @@ -157,7 +163,7 @@ class ExtractFanSpeedSensor(SmartySensor): class FilterDaysLeftSensor(SmartySensor): """Filter Days Left.""" - def __init__(self, name, smarty): + def __init__(self, name: str, smarty: Smarty) -> None: """Filter Days Left Init.""" super().__init__( name=f"{name} Filter Days Left", From d19fc0622b6f99fe19099cb668de15e217314369 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 21:31:30 +0200 Subject: [PATCH 652/947] Add ToggleEntity to pylint fan checks (#73886) --- pylint/plugins/hass_enforce_type_hints.py | 32 ++++++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a241252c9a9..9ec2fa83806 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -435,15 +435,39 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { } # Overriding properties and functions are normally checked by mypy, and will only # be checked by pylint when --ignore-missing-annotations is False +_TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ + TypeHintMatch( + function_name="is_on", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="turn_on", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="turn_off", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="toggle", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), +] _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { "fan": [ + ClassTypeHintMatch( + base_class="ToggleEntity", + matches=_TOGGLE_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="FanEntity", matches=[ - TypeHintMatch( - function_name="is_on", - return_type=["bool", None], - ), TypeHintMatch( function_name="percentage", return_type=["int", None], From 8865a58f744d149a973790545a0785775c1a176e Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Thu, 23 Jun 2022 20:34:08 +0100 Subject: [PATCH 653/947] Improve Glances entity descriptions, add long term statistics (#73049) --- homeassistant/components/glances/const.py | 37 ++++++++++++++++++----- 1 file changed, 30 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index e5a8f1424c2..3fd26165283 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -4,7 +4,11 @@ from __future__ import annotations from dataclasses import dataclass import sys -from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntityDescription, + SensorStateClass, +) from homeassistant.const import DATA_GIBIBYTES, DATA_MEBIBYTES, PERCENTAGE, TEMP_CELSIUS DOMAIN = "glances" @@ -40,6 +44,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="used percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="disk_use", @@ -47,6 +52,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="used", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="disk_free", @@ -54,6 +60,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="free", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="memory_use_percent", @@ -61,6 +68,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="RAM used percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="memory_use", @@ -68,6 +76,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="RAM used", native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="memory_free", @@ -75,6 +84,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="RAM free", native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="swap_use_percent", @@ -82,6 +92,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Swap used percent", native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="swap_use", @@ -89,6 +100,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Swap used", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="swap_free", @@ -96,41 +108,43 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Swap free", native_unit_of_measurement=DATA_GIBIBYTES, icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="processor_load", type="load", name_suffix="CPU load", - native_unit_of_measurement="15 min", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_running", type="processcount", name_suffix="Running", - native_unit_of_measurement="Count", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_total", type="processcount", name_suffix="Total", - native_unit_of_measurement="Count", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_thread", type="processcount", name_suffix="Thread", - native_unit_of_measurement="Count", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="process_sleeping", type="processcount", name_suffix="Sleeping", - native_unit_of_measurement="Count", + native_unit_of_measurement="", icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="cpu_use_percent", @@ -138,6 +152,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="CPU used", native_unit_of_measurement=PERCENTAGE, icon=CPU_ICON, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="temperature_core", @@ -145,6 +160,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="temperature_hdd", @@ -152,6 +168,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="fan_speed", @@ -159,6 +176,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Fan speed", native_unit_of_measurement="RPM", icon="mdi:fan", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="battery", @@ -166,13 +184,14 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Charge", native_unit_of_measurement=PERCENTAGE, icon="mdi:battery", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="docker_active", type="docker", name_suffix="Containers active", - native_unit_of_measurement="", icon="mdi:docker", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="docker_cpu_use", @@ -180,6 +199,7 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Containers CPU used", native_unit_of_measurement=PERCENTAGE, icon="mdi:docker", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="docker_memory_use", @@ -187,17 +207,20 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( name_suffix="Containers RAM used", native_unit_of_measurement=DATA_MEBIBYTES, icon="mdi:docker", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="used", type="raid", name_suffix="Raid used", icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), GlancesSensorEntityDescription( key="available", type="raid", name_suffix="Raid available", icon="mdi:harddisk", + state_class=SensorStateClass.MEASUREMENT, ), ) From 186141ee4df74de2d0b6cb744652360e8b4cb558 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Thu, 23 Jun 2022 21:35:05 +0200 Subject: [PATCH 654/947] Use attributes in keba locks and binary sensors (#73894) Co-authored-by: Franck Nijhof --- .../components/keba/binary_sensor.py | 70 ++++++++----------- homeassistant/components/keba/lock.py | 46 ++++-------- homeassistant/components/keba/sensor.py | 8 +-- 3 files changed, 46 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/keba/binary_sensor.py b/homeassistant/components/keba/binary_sensor.py index 19f3bd428ec..7997130c90a 100644 --- a/homeassistant/components/keba/binary_sensor.py +++ b/homeassistant/components/keba/binary_sensor.py @@ -1,6 +1,8 @@ """Support for KEBA charging station binary sensors.""" from __future__ import annotations +from typing import Any + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -9,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN +from . import DOMAIN, KebaHandler async def async_setup_platform( @@ -22,7 +24,7 @@ async def async_setup_platform( if discovery_info is None: return - keba = hass.data[DOMAIN] + keba: KebaHandler = hass.data[DOMAIN] sensors = [ KebaBinarySensor( @@ -60,53 +62,37 @@ async def async_setup_platform( class KebaBinarySensor(BinarySensorEntity): """Representation of a binary sensor of a KEBA charging station.""" - def __init__(self, keba, key, name, entity_type, device_class): + _attr_should_poll = False + + def __init__( + self, + keba: KebaHandler, + key: str, + name: str, + entity_type: str, + device_class: BinarySensorDeviceClass, + ) -> None: """Initialize the KEBA Sensor.""" self._key = key self._keba = keba - self._name = name - self._entity_type = entity_type - self._device_class = device_class - self._is_on = None - self._attributes = {} + self._attributes: dict[str, Any] = {} + + self._attr_device_class = device_class + self._attr_name = f"{keba.device_name} {name}" + self._attr_unique_id = f"{keba.device_id}_{entity_type}" @property - def should_poll(self): - """Deactivate polling. Data updated by KebaHandler.""" - return False - - @property - def unique_id(self): - """Return the unique ID of the binary sensor.""" - return f"{self._keba.device_id}_{self._entity_type}" - - @property - def name(self): - """Return the name of the device.""" - return f"{self._keba.device_name} {self._name}" - - @property - def device_class(self): - """Return the class of this sensor.""" - return self._device_class - - @property - def is_on(self): - """Return true if sensor is on.""" - return self._is_on - - @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the binary sensor.""" return self._attributes - async def async_update(self): + async def async_update(self) -> None: """Get latest cached states from the device.""" if self._key == "Online": - self._is_on = self._keba.get_value(self._key) + self._attr_is_on = self._keba.get_value(self._key) elif self._key == "Plug": - self._is_on = self._keba.get_value("Plug_plugged") + self._attr_is_on = self._keba.get_value("Plug_plugged") self._attributes["plugged_on_wallbox"] = self._keba.get_value( "Plug_wallbox" ) @@ -114,23 +100,23 @@ class KebaBinarySensor(BinarySensorEntity): self._attributes["plugged_on_EV"] = self._keba.get_value("Plug_EV") elif self._key == "State": - self._is_on = self._keba.get_value("State_on") + self._attr_is_on = self._keba.get_value("State_on") self._attributes["status"] = self._keba.get_value("State_details") self._attributes["max_charging_rate"] = str( self._keba.get_value("Max curr") ) elif self._key == "Tmo FS": - self._is_on = not self._keba.get_value("FS_on") + self._attr_is_on = not self._keba.get_value("FS_on") self._attributes["failsafe_timeout"] = str(self._keba.get_value("Tmo FS")) self._attributes["fallback_current"] = str(self._keba.get_value("Curr FS")) elif self._key == "Authreq": - self._is_on = self._keba.get_value(self._key) == 0 + self._attr_is_on = self._keba.get_value(self._key) == 0 - def update_callback(self): + def update_callback(self) -> None: """Schedule a state update.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add update callback after being added to hass.""" self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keba/lock.py b/homeassistant/components/keba/lock.py index d0316b1e525..de8d28d7739 100644 --- a/homeassistant/components/keba/lock.py +++ b/homeassistant/components/keba/lock.py @@ -8,7 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DOMAIN +from . import DOMAIN, KebaHandler async def async_setup_platform( @@ -21,41 +21,23 @@ async def async_setup_platform( if discovery_info is None: return - keba = hass.data[DOMAIN] + keba: KebaHandler = hass.data[DOMAIN] - sensors = [KebaLock(keba, "Authentication", "authentication")] - async_add_entities(sensors) + locks = [KebaLock(keba, "Authentication", "authentication")] + async_add_entities(locks) class KebaLock(LockEntity): """The entity class for KEBA charging stations switch.""" - def __init__(self, keba, name, entity_type): + _attr_should_poll = False + + def __init__(self, keba: KebaHandler, name: str, entity_type: str) -> None: """Initialize the KEBA switch.""" self._keba = keba - self._name = name - self._entity_type = entity_type - self._state = True - - @property - def should_poll(self): - """Deactivate polling. Data updated by KebaHandler.""" - return False - - @property - def unique_id(self): - """Return the unique ID of the lock.""" - return f"{self._keba.device_id}_{self._entity_type}" - - @property - def name(self): - """Return the name of the device.""" - return f"{self._keba.device_name} {self._name}" - - @property - def is_locked(self) -> bool: - """Return true if lock is locked.""" - return self._state + self._attr_is_locked = True + self._attr_name = f"{keba.device_name} {name}" + self._attr_unique_id = f"{keba.device_id}_{entity_type}" async def async_lock(self, **kwargs: Any) -> None: """Lock wallbox.""" @@ -65,14 +47,14 @@ class KebaLock(LockEntity): """Unlock wallbox.""" await self._keba.async_start() - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve on off state from the switch.""" - self._state = self._keba.get_value("Authreq") == 1 + self._attr_is_locked = self._keba.get_value("Authreq") == 1 - def update_callback(self): + def update_callback(self) -> None: """Schedule a state update.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add update callback after being added to hass.""" self._keba.add_update_listener(self.update_callback) diff --git a/homeassistant/components/keba/sensor.py b/homeassistant/components/keba/sensor.py index 6309f0b79fb..d35c22905f1 100644 --- a/homeassistant/components/keba/sensor.py +++ b/homeassistant/components/keba/sensor.py @@ -109,11 +109,11 @@ class KebaSensor(SensorEntity): self._attributes: dict[str, str] = {} @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, str]: """Return the state attributes of the binary sensor.""" return self._attributes - async def async_update(self): + async def async_update(self) -> None: """Get latest cached states from the device.""" self._attr_native_value = self._keba.get_value(self.entity_description.key) @@ -128,10 +128,10 @@ class KebaSensor(SensorEntity): elif self.entity_description.key == "Curr user": self._attributes["max_current_hardware"] = self._keba.get_value("Curr HW") - def update_callback(self): + def update_callback(self) -> None: """Schedule a state update.""" self.async_schedule_update_ha_state(True) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add update callback after being added to hass.""" self._keba.add_update_listener(self.update_callback) From 9b8c3e37bbee3dbaa949705c7ae7b29f521988e7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 23 Jun 2022 21:38:17 +0200 Subject: [PATCH 655/947] Improve group tests (#73630) --- .../components/group/binary_sensor.py | 2 +- tests/components/group/test_binary_sensor.py | 169 ++++++++++----- tests/components/group/test_cover.py | 192 +++++++++++------- tests/components/group/test_fan.py | 96 ++++++--- tests/components/group/test_light.py | 132 ++++++++++-- tests/components/group/test_lock.py | 122 ++++++++--- tests/components/group/test_media_player.py | 77 +++++-- tests/components/group/test_switch.py | 130 ++++++++++-- 8 files changed, 685 insertions(+), 235 deletions(-) diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index 54a98a68e43..ff0e58badfb 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -132,7 +132,7 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): # filtered_states are members currently in the state machine filtered_states: list[str] = [x.state for x in all_states if x is not None] - # Set group as unavailable if all members are unavailable + # Set group as unavailable if all members are unavailable or missing self._attr_available = any( state != STATE_UNAVAILABLE for state in filtered_states ) diff --git a/tests/components/group/test_binary_sensor.py b/tests/components/group/test_binary_sensor.py index a0872b11f16..fbc19904faa 100644 --- a/tests/components/group/test_binary_sensor.py +++ b/tests/components/group/test_binary_sensor.py @@ -50,7 +50,13 @@ async def test_default_state(hass): async def test_state_reporting_all(hass): - """Test the state reporting.""" + """Test the state reporting in 'all' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is off if at least one group member is off. + Otherwise, the group state is on. + """ await async_setup_component( hass, BINARY_SENSOR_DOMAIN, @@ -68,26 +74,12 @@ async def test_state_reporting_all(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN - - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF - - hass.states.async_set("binary_sensor.test1", STATE_OFF) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF - - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_ON) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + # Initial state with no group member in the state machine -> unavailable + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + # All group members unavailable -> unavailable hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() @@ -95,6 +87,12 @@ async def test_state_reporting_all(hass): hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE ) + # At least one member unknown or unavailable -> group unknown + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) await hass.async_block_till_done() @@ -105,9 +103,55 @@ async def test_state_reporting_all(hass): await hass.async_block_till_done() assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + # At least one member off -> group off + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + # Otherwise -> on + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("binary_sensor.test1") + hass.states.async_remove("binary_sensor.test2") + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + async def test_state_reporting_any(hass): - """Test the state reporting.""" + """Test the state reporting in 'any' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is on if at least one group member is on. + Otherwise, the group state is off. + """ await async_setup_component( hass, BINARY_SENSOR_DOMAIN, @@ -126,26 +170,17 @@ async def test_state_reporting_any(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("binary_sensor.binary_sensor_group") + assert entry + assert entry.unique_id == "unique_identifier" - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON - - hass.states.async_set("binary_sensor.test1", STATE_OFF) - hass.states.async_set("binary_sensor.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF - - hass.states.async_set("binary_sensor.test1", STATE_ON) - hass.states.async_set("binary_sensor.test2", STATE_ON) - await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + # Initial state with no group member in the state machine -> unavailable + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) + # All group members unavailable -> unavailable hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() @@ -153,17 +188,59 @@ async def test_state_reporting_any(hass): hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE ) - entity_registry = er.async_get(hass) - entry = entity_registry.async_get("binary_sensor.binary_sensor_group") - assert entry - assert entry.unique_id == "unique_identifier" + # All group members unknown -> unknown + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + # Group members unknown or unavailable -> unknown + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + + # At least one member on -> group on + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON + + hass.states.async_set("binary_sensor.test1", STATE_ON) + hass.states.async_set("binary_sensor.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON hass.states.async_set("binary_sensor.test1", STATE_ON) hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) await hass.async_block_till_done() assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_ON - hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) - hass.states.async_set("binary_sensor.test2", STATE_UNKNOWN) + # Otherwise -> off + hass.states.async_set("binary_sensor.test1", STATE_OFF) + hass.states.async_set("binary_sensor.test2", STATE_OFF) await hass.async_block_till_done() - assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNKNOWN + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + hass.states.async_set("binary_sensor.test1", STATE_UNAVAILABLE) + hass.states.async_set("binary_sensor.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("binary_sensor.binary_sensor_group").state == STATE_OFF + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("binary_sensor.test1") + hass.states.async_remove("binary_sensor.test2") + await hass.async_block_till_done() + assert ( + hass.states.get("binary_sensor.binary_sensor_group").state == STATE_UNAVAILABLE + ) diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index d090141a9d2..83c85a70b63 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -33,6 +33,7 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.helpers import entity_registry as er @@ -99,7 +100,14 @@ async def setup_comp(hass, config_count): @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) async def test_state(hass, setup_comp): - """Test handling of state.""" + """Test handling of state. + + The group state is unknown if all group members are unknown or unavailable. + Otherwise, the group state is opening if at least one group member is opening. + Otherwise, the group state is closing if at least one group member is closing. + Otherwise, the group state is open if at least one group member is open. + Otherwise, the group state is closed. + """ state = hass.states.get(COVER_GROUP) # No entity has a valid state -> group state unknown assert state.state == STATE_UNKNOWN @@ -115,87 +123,125 @@ async def test_state(hass, setup_comp): assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes - # Set all entities as closed -> group state closed - hass.states.async_set(DEMO_COVER, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_CLOSED + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_UNAVAILABLE, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_UNKNOWN - # Set all entities as open -> group state open - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_OPEN, {}) - hass.states.async_set(DEMO_TILT, STATE_OPEN, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN + for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_UNKNOWN - # Set first entity as open -> group state open - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN + # At least one member opening -> group opening + for state_1 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_2 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_3 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_OPENING, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPENING - # Set last entity as open -> group state open - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_CLOSED, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN + # At least one member closing -> group closing + for state_1 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_2 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + for state_3 in ( + STATE_CLOSED, + STATE_CLOSING, + STATE_OPEN, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_CLOSING, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSING - # Set conflicting valid states -> opening state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_OPENING, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSING, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPENING + # At least one member open -> group open + for state_1 in (STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_OPEN, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_OPEN - # Set all entities to unknown state -> group state unknown - hass.states.async_set(DEMO_COVER, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_TILT, STATE_UNKNOWN, {}) + # At least one member closed -> group closed + for state_1 in (STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(DEMO_COVER, state_1, {}) + hass.states.async_set(DEMO_COVER_POS, state_2, {}) + hass.states.async_set(DEMO_COVER_TILT, state_3, {}) + hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_CLOSED + + # All group members removed from the state machine -> unknown + hass.states.async_remove(DEMO_COVER) + hass.states.async_remove(DEMO_COVER_POS) + hass.states.async_remove(DEMO_COVER_TILT) + hass.states.async_remove(DEMO_TILT) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) assert state.state == STATE_UNKNOWN - # Set one entity to unknown state -> open state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSED, {}) - hass.states.async_set(DEMO_TILT, STATE_OPEN, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPEN - - # Set one entity to unknown state -> opening state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_OPENING, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_OPENING - - # Set one entity to unknown state -> closing state has priority - hass.states.async_set(DEMO_COVER, STATE_OPEN, {}) - hass.states.async_set(DEMO_COVER_POS, STATE_UNKNOWN, {}) - hass.states.async_set(DEMO_COVER_TILT, STATE_CLOSING, {}) - hass.states.async_set(DEMO_TILT, STATE_CLOSED, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_CLOSING - @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) async def test_attributes(hass, setup_comp): diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 19b4fe4670a..8aefd12c93a 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -33,6 +33,8 @@ from homeassistant.const import ( CONF_UNIQUE_ID, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import CoreState from homeassistant.helpers import entity_registry as er @@ -111,7 +113,11 @@ async def setup_comp(hass, config_count): @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) async def test_state(hass, setup_comp): - """Test handling of state.""" + """Test handling of state. + + The group state is on if at least one group member is on. + Otherwise, the group state is off. + """ state = hass.states.get(FAN_GROUP) # No entity has a valid state -> group state off assert state.state == STATE_OFF @@ -123,41 +129,55 @@ async def test_state(hass, setup_comp): assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 - # Set all entities as on -> group state on - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_ON + # The group state is off if all group members are off, unknown or unavailable. + for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_OFF - # Set all entities as off -> group state off - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set( + PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNAVAILABLE, {} + ) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_OFF - # Set first entity as on -> group state on - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_OFF, {}) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_ON + for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set( + PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNKNOWN, {} + ) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_OFF - # Set last entity as on -> group state on - hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_OFF, {}) - hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_ON + # At least one member on -> group on + for state_1 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_ON # now remove an entity hass.states.async_remove(PERCENTAGE_LIMITED_FAN_ENTITY_ID) @@ -167,6 +187,16 @@ async def test_state(hass, setup_comp): assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + # now remove all entities + hass.states.async_remove(CEILING_FAN_ENTITY_ID) + hass.states.async_remove(LIVING_ROOM_FAN_ENTITY_ID) + hass.states.async_remove(PERCENTAGE_FULL_FAN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_OFF + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + # Test entity registry integration entity_registry = er.async_get(hass) entry = entity_registry.async_get(FAN_GROUP) diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index d5f7abedb44..f3083812553 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -88,8 +88,14 @@ async def test_default_state(hass): assert entry.unique_id == "unique_identifier" -async def test_state_reporting(hass): - """Test the state reporting.""" +async def test_state_reporting_any(hass): + """Test the state reporting in 'any' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is on if at least one group member is on. + Otherwise, the group state is off. + """ await async_setup_component( hass, LIGHT_DOMAIN, @@ -105,29 +111,79 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("light.test1", STATE_ON) - hass.states.async_set("light.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("light.light_group").state == STATE_ON - - hass.states.async_set("light.test1", STATE_ON) - hass.states.async_set("light.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("light.light_group").state == STATE_ON - - hass.states.async_set("light.test1", STATE_OFF) - hass.states.async_set("light.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("light.light_group").state == STATE_OFF + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + # All group members unavailable -> unavailable hass.states.async_set("light.test1", STATE_UNAVAILABLE) hass.states.async_set("light.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + # All group members unknown -> unknown + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + # Group members unknown or unavailable -> unknown + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + # At least one member on -> group on + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_ON + + # Otherwise -> off + hass.states.async_set("light.test1", STATE_OFF) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + hass.states.async_set("light.test1", STATE_UNAVAILABLE) + hass.states.async_set("light.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_OFF + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("light.test1") + hass.states.async_remove("light.test2") + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + async def test_state_reporting_all(hass): - """Test the state reporting.""" + """Test the state reporting in 'all' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is off if at least one group member is off. + Otherwise, the group state is on. + """ await async_setup_component( hass, LIGHT_DOMAIN, @@ -143,11 +199,47 @@ async def test_state_reporting_all(hass): await hass.async_start() await hass.async_block_till_done() + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + + # All group members unavailable -> unavailable + hass.states.async_set("light.test1", STATE_UNAVAILABLE) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE + + # At least one member unknown or unavailable -> group unknown hass.states.async_set("light.test1", STATE_ON) hass.states.async_set("light.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_UNKNOWN + hass.states.async_set("light.test1", STATE_ON) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("light.test1", STATE_UNKNOWN) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("light.test1", STATE_OFF) + hass.states.async_set("light.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("light.test1", STATE_OFF) + hass.states.async_set("light.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + hass.states.async_set("binary_sensor.test1", STATE_UNKNOWN) + hass.states.async_set("binary_sensor.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("light.light_group").state == STATE_UNKNOWN + + # At least one member off -> group off hass.states.async_set("light.test1", STATE_ON) hass.states.async_set("light.test2", STATE_OFF) await hass.async_block_till_done() @@ -158,13 +250,15 @@ async def test_state_reporting_all(hass): await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_OFF + # Otherwise -> on hass.states.async_set("light.test1", STATE_ON) hass.states.async_set("light.test2", STATE_ON) await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_ON - hass.states.async_set("light.test1", STATE_UNAVAILABLE) - hass.states.async_set("light.test2", STATE_UNAVAILABLE) + # All group members removed from the state machine -> unavailable + hass.states.async_remove("light.test1") + hass.states.async_remove("light.test2") await hass.async_block_till_done() assert hass.states.get("light.light_group").state == STATE_UNAVAILABLE diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index 8db28fab18e..e76e47577c6 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -57,7 +57,16 @@ async def test_default_state(hass): async def test_state_reporting(hass): - """Test the state reporting.""" + """Test the state reporting. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is jammed if at least one group member is jammed. + Otherwise, the group state is locking if at least one group member is locking. + Otherwise, the group state is unlocking if at least one group member is unlocking. + Otherwise, the group state is unlocked if at least one group member is unlocked. + Otherwise, the group state is locked. + """ await async_setup_component( hass, LOCK_DOMAIN, @@ -72,43 +81,98 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("lock.test1", STATE_LOCKED) + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE + + # All group members unavailable -> unavailable + hass.states.async_set("lock.test1", STATE_UNAVAILABLE) hass.states.async_set("lock.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNKNOWN + assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE - hass.states.async_set("lock.test1", STATE_LOCKED) - hass.states.async_set("lock.test2", STATE_UNLOCKED) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + # At least one member unknown or unavailable -> group unknown + for state_1 in ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNKNOWN, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNKNOWN + for state_1 in ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNKNOWN + + # At least one member jammed -> group jammed + for state_1 in ( + STATE_JAMMED, + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_JAMMED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_JAMMED + + # At least one member locking -> group unlocking + for state_1 in ( + STATE_LOCKED, + STATE_LOCKING, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_LOCKING) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_LOCKING + + # At least one member unlocking -> group unlocking + for state_1 in ( + STATE_LOCKED, + STATE_UNLOCKED, + STATE_UNLOCKING, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_UNLOCKING) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKING + + # At least one member unlocked -> group unlocked + for state_1 in ( + STATE_LOCKED, + STATE_UNLOCKED, + ): + hass.states.async_set("lock.test1", state_1) + hass.states.async_set("lock.test2", STATE_UNLOCKED) + await hass.async_block_till_done() + assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED + + # Otherwise -> locked hass.states.async_set("lock.test1", STATE_LOCKED) hass.states.async_set("lock.test2", STATE_LOCKED) await hass.async_block_till_done() assert hass.states.get("lock.lock_group").state == STATE_LOCKED - hass.states.async_set("lock.test1", STATE_UNLOCKED) - hass.states.async_set("lock.test2", STATE_UNLOCKED) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKED - - hass.states.async_set("lock.test1", STATE_UNLOCKED) - hass.states.async_set("lock.test2", STATE_JAMMED) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_JAMMED - - hass.states.async_set("lock.test1", STATE_LOCKED) - hass.states.async_set("lock.test2", STATE_UNLOCKING) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNLOCKING - - hass.states.async_set("lock.test1", STATE_UNLOCKED) - hass.states.async_set("lock.test2", STATE_LOCKING) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_LOCKING - - hass.states.async_set("lock.test1", STATE_UNAVAILABLE) - hass.states.async_set("lock.test2", STATE_UNAVAILABLE) + # All group members removed from the state machine -> unavailable + hass.states.async_remove("lock.test1") + hass.states.async_remove("lock.test2") await hass.async_block_till_done() assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index f741e2d1a84..85e75ffcba6 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -43,6 +43,8 @@ from homeassistant.const import ( SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP, + STATE_BUFFERING, + STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, @@ -99,7 +101,17 @@ async def test_default_state(hass): async def test_state_reporting(hass): - """Test the state reporting.""" + """Test the state reporting. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is buffering if all group members are buffering. + Otherwise, the group state is idle if all group members are idle. + Otherwise, the group state is paused if all group members are paused. + Otherwise, the group state is playing if all group members are playing. + Otherwise, the group state is on if at least one group member is not off, unavailable or unknown. + Otherwise, the group state is off. + """ await async_setup_component( hass, MEDIA_DOMAIN, @@ -114,27 +126,60 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() + # Initial state with no group member in the state machine -> unknown assert hass.states.get("media_player.media_group").state == STATE_UNKNOWN - hass.states.async_set("media_player.player_1", STATE_ON) - hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_ON + # All group members buffering -> buffering + # All group members idle -> idle + # All group members paused -> paused + # All group members playing -> playing + # All group members unavailable -> unavailable + # All group members unknown -> unknown + for state in ( + STATE_BUFFERING, + STATE_IDLE, + STATE_PAUSED, + STATE_PLAYING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set("media_player.player_1", state) + hass.states.async_set("media_player.player_2", state) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == state - hass.states.async_set("media_player.player_1", STATE_ON) - hass.states.async_set("media_player.player_2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_ON + # At least one member not off, unavailable or unknown -> on + for state_1 in (STATE_BUFFERING, STATE_IDLE, STATE_ON, STATE_PAUSED, STATE_PLAYING): + for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", state_2) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_ON - hass.states.async_set("media_player.player_1", STATE_OFF) - hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_OFF + for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_OFF - hass.states.async_set("media_player.player_1", STATE_UNAVAILABLE) - hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) + # Otherwise off + for state_1 in (STATE_OFF, STATE_UNKNOWN): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_OFF + + for state_1 in (STATE_OFF, STATE_UNAVAILABLE): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_OFF + + # All group members removed from the state machine -> unknown + hass.states.async_remove("media_player.player_1") + hass.states.async_remove("media_player.player_2") await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_UNAVAILABLE + assert hass.states.get("media_player.media_group").state == STATE_UNKNOWN async def test_supported_features(hass): diff --git a/tests/components/group/test_switch.py b/tests/components/group/test_switch.py index 5df2542d101..9a8da274a0a 100644 --- a/tests/components/group/test_switch.py +++ b/tests/components/group/test_switch.py @@ -56,7 +56,13 @@ async def test_default_state(hass): async def test_state_reporting(hass): - """Test the state reporting.""" + """Test the state reporting in 'any' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if all group members are unknown. + Otherwise, the group state is on if at least one group member is on. + Otherwise, the group state is off. + """ await async_setup_component( hass, SWITCH_DOMAIN, @@ -72,29 +78,79 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - hass.states.async_set("switch.test1", STATE_ON) - hass.states.async_set("switch.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("switch.switch_group").state == STATE_ON - - hass.states.async_set("switch.test1", STATE_ON) - hass.states.async_set("switch.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("switch.switch_group").state == STATE_ON - - hass.states.async_set("switch.test1", STATE_OFF) - hass.states.async_set("switch.test2", STATE_OFF) - await hass.async_block_till_done() - assert hass.states.get("switch.switch_group").state == STATE_OFF + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + # All group members unavailable -> unavailable hass.states.async_set("switch.test1", STATE_UNAVAILABLE) hass.states.async_set("switch.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + # All group members unknown -> unknown + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + # Group members unknown or unavailable -> unknown + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + # At least one member on -> group on + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_ON) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_ON + + # Otherwise -> off + hass.states.async_set("switch.test1", STATE_OFF) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_OFF + + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_OFF + + hass.states.async_set("switch.test1", STATE_UNAVAILABLE) + hass.states.async_set("switch.test2", STATE_OFF) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_OFF + + # All group members removed from the state machine -> unavailable + hass.states.async_remove("switch.test1") + hass.states.async_remove("switch.test2") + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + async def test_state_reporting_all(hass): - """Test the state reporting.""" + """Test the state reporting in 'all' mode. + + The group state is unavailable if all group members are unavailable. + Otherwise, the group state is unknown if at least one group member is unknown or unavailable. + Otherwise, the group state is off if at least one group member is off. + Otherwise, the group state is on. + """ await async_setup_component( hass, SWITCH_DOMAIN, @@ -110,11 +166,47 @@ async def test_state_reporting_all(hass): await hass.async_start() await hass.async_block_till_done() + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + + # All group members unavailable -> unavailable + hass.states.async_set("switch.test1", STATE_UNAVAILABLE) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE + + # At least one member unknown or unavailable -> group unknown hass.states.async_set("switch.test1", STATE_ON) hass.states.async_set("switch.test2", STATE_UNAVAILABLE) await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + hass.states.async_set("switch.test1", STATE_ON) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_OFF) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_OFF) + hass.states.async_set("switch.test2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + hass.states.async_set("switch.test1", STATE_UNKNOWN) + hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("switch.switch_group").state == STATE_UNKNOWN + + # At least one member off -> group off hass.states.async_set("switch.test1", STATE_ON) hass.states.async_set("switch.test2", STATE_OFF) await hass.async_block_till_done() @@ -125,13 +217,15 @@ async def test_state_reporting_all(hass): await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_OFF + # Otherwise -> on hass.states.async_set("switch.test1", STATE_ON) hass.states.async_set("switch.test2", STATE_ON) await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_ON - hass.states.async_set("switch.test1", STATE_UNAVAILABLE) - hass.states.async_set("switch.test2", STATE_UNAVAILABLE) + # All group members removed from the state machine -> unavailable + hass.states.async_remove("switch.test1") + hass.states.async_remove("switch.test2") await hass.async_block_till_done() assert hass.states.get("switch.switch_group").state == STATE_UNAVAILABLE From 3058a432a59579a5a9fbd71c8de127981d06d768 Mon Sep 17 00:00:00 2001 From: 0bmay <57501269+0bmay@users.noreply.github.com> Date: Thu, 23 Jun 2022 14:33:03 -0700 Subject: [PATCH 656/947] Bump py-canary to 0.5.3 (#73922) --- homeassistant/components/canary/alarm_control_panel.py | 8 ++------ homeassistant/components/canary/camera.py | 2 +- homeassistant/components/canary/coordinator.py | 3 ++- homeassistant/components/canary/manifest.json | 2 +- homeassistant/components/canary/model.py | 2 +- homeassistant/components/canary/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/canary/__init__.py | 2 +- tests/components/canary/test_alarm_control_panel.py | 2 +- 10 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index d33e98008d6..f668da25e2e 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -3,12 +3,8 @@ from __future__ import annotations from typing import Any -from canary.api import ( - LOCATION_MODE_AWAY, - LOCATION_MODE_HOME, - LOCATION_MODE_NIGHT, - Location, -) +from canary.const import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT +from canary.model import Location from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py index 5caae6f5cc5..44bac9e3bde 100644 --- a/homeassistant/components/canary/camera.py +++ b/homeassistant/components/canary/camera.py @@ -5,8 +5,8 @@ from datetime import timedelta from typing import Final from aiohttp.web import Request, StreamResponse -from canary.api import Device, Location from canary.live_stream_api import LiveStreamSession +from canary.model import Device, Location from haffmpeg.camera import CameraMjpeg import voluptuous as vol diff --git a/homeassistant/components/canary/coordinator.py b/homeassistant/components/canary/coordinator.py index 4c6c9ce5777..b2a8ef4daaa 100644 --- a/homeassistant/components/canary/coordinator.py +++ b/homeassistant/components/canary/coordinator.py @@ -6,7 +6,8 @@ from datetime import timedelta import logging from async_timeout import timeout -from canary.api import Api, Location +from canary.api import Api +from canary.model import Location from requests.exceptions import ConnectTimeout, HTTPError from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json index fdae5c83d7b..bf7ceaec273 100644 --- a/homeassistant/components/canary/manifest.json +++ b/homeassistant/components/canary/manifest.json @@ -2,7 +2,7 @@ "domain": "canary", "name": "Canary", "documentation": "https://www.home-assistant.io/integrations/canary", - "requirements": ["py-canary==0.5.2"], + "requirements": ["py-canary==0.5.3"], "dependencies": ["ffmpeg"], "codeowners": [], "config_flow": true, diff --git a/homeassistant/components/canary/model.py b/homeassistant/components/canary/model.py index 848278d9aec..12fb8209108 100644 --- a/homeassistant/components/canary/model.py +++ b/homeassistant/components/canary/model.py @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import ValuesView from typing import Optional, TypedDict -from canary.api import Location +from canary.model import Location class CanaryData(TypedDict): diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 3de088016a9..c80c178fbb5 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from typing import Final -from canary.api import Device, Location, SensorType +from canary.model import Device, Location, SensorType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry diff --git a/requirements_all.txt b/requirements_all.txt index ba18173bcac..fc14180138f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1296,7 +1296,7 @@ pushover_complete==1.1.1 pvo==0.2.2 # homeassistant.components.canary -py-canary==0.5.2 +py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8545067551e..0f3fc63c76f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -880,7 +880,7 @@ pushbullet.py==0.11.0 pvo==0.2.2 # homeassistant.components.canary -py-canary==0.5.2 +py-canary==0.5.3 # homeassistant.components.cpuspeed py-cpuinfo==8.0.0 diff --git a/tests/components/canary/__init__.py b/tests/components/canary/__init__.py index b327fb0ebcb..46737929dc5 100644 --- a/tests/components/canary/__init__.py +++ b/tests/components/canary/__init__.py @@ -1,7 +1,7 @@ """Tests for the Canary integration.""" from unittest.mock import MagicMock, PropertyMock, patch -from canary.api import SensorType +from canary.model import SensorType from homeassistant.components.canary.const import ( CONF_FFMPEG_ARGUMENTS, diff --git a/tests/components/canary/test_alarm_control_panel.py b/tests/components/canary/test_alarm_control_panel.py index 5034792d389..f6eed94e267 100644 --- a/tests/components/canary/test_alarm_control_panel.py +++ b/tests/components/canary/test_alarm_control_panel.py @@ -1,7 +1,7 @@ """The tests for the Canary alarm_control_panel platform.""" from unittest.mock import PropertyMock, patch -from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT +from canary.const import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, LOCATION_MODE_NIGHT from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN from homeassistant.components.canary import DOMAIN From 28dd92d92888e672917556ae77a7174992a055b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 16:35:10 -0500 Subject: [PATCH 657/947] Fix logbook state query with postgresql (#73924) --- homeassistant/components/logbook/processor.py | 4 ++-- homeassistant/components/logbook/queries/common.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/logbook/processor.py b/homeassistant/components/logbook/processor.py index 82225df8364..6d491ec2892 100644 --- a/homeassistant/components/logbook/processor.py +++ b/homeassistant/components/logbook/processor.py @@ -377,9 +377,9 @@ def _rows_match(row: Row | EventAsRow, other_row: Row | EventAsRow) -> bool: """Check of rows match by using the same method as Events __hash__.""" if ( row is other_row - or (state_id := row.state_id) is not None + or (state_id := row.state_id) and state_id == other_row.state_id - or (event_id := row.event_id) is not None + or (event_id := row.event_id) and event_id == other_row.event_id ): return True diff --git a/homeassistant/components/logbook/queries/common.py b/homeassistant/components/logbook/queries/common.py index 5b79f6e0d32..466df668da8 100644 --- a/homeassistant/components/logbook/queries/common.py +++ b/homeassistant/components/logbook/queries/common.py @@ -87,7 +87,7 @@ EVENT_COLUMNS_FOR_STATE_SELECT = [ ] EMPTY_STATE_COLUMNS = ( - literal(value=None, type_=sqlalchemy.String).label("state_id"), + literal(value=0, type_=sqlalchemy.Integer).label("state_id"), literal(value=None, type_=sqlalchemy.String).label("state"), literal(value=None, type_=sqlalchemy.String).label("entity_id"), literal(value=None, type_=sqlalchemy.String).label("icon"), From dc0ea6fd559d5ad12482fcc291cfac97550ce489 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 24 Jun 2022 01:57:12 +0200 Subject: [PATCH 658/947] Flush CI caches (#73926) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 27a761872d9..49f62f0943d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -20,8 +20,8 @@ on: type: boolean env: - CACHE_VERSION: 9 - PIP_CACHE_VERSION: 3 + CACHE_VERSION: 10 + PIP_CACHE_VERSION: 4 HA_SHORT_VERSION: 2022.7 DEFAULT_PYTHON: 3.9 PRE_COMMIT_CACHE: ~/.cache/pre-commit From 768e53ac2d0509ff2f2c7af17820b4ef4772f019 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 23 Jun 2022 20:13:37 -0400 Subject: [PATCH 659/947] Add zwave_js/get_any_firmware_update_progress WS cmd (#73905) --- homeassistant/components/zwave_js/api.py | 27 +++++++++ tests/components/zwave_js/test_api.py | 71 ++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index ca25088a642..8b98c61c4b2 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -421,6 +421,9 @@ def async_register_api(hass: HomeAssistant) -> None: websocket_api.async_register_command( hass, websocket_get_firmware_update_capabilities ) + websocket_api.async_register_command( + hass, websocket_get_any_firmware_update_progress + ) websocket_api.async_register_command(hass, websocket_check_for_config_updates) websocket_api.async_register_command(hass, websocket_install_config_update) websocket_api.async_register_command( @@ -1989,6 +1992,30 @@ async def websocket_get_firmware_update_capabilities( connection.send_result(msg[ID], capabilities.to_dict()) +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required(TYPE): "zwave_js/get_any_firmware_update_progress", + vol.Required(ENTRY_ID): str, + } +) +@websocket_api.async_response +@async_handle_failed_command +@async_get_entry +async def websocket_get_any_firmware_update_progress( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, + entry: ConfigEntry, + client: Client, + driver: Driver, +) -> None: + """Get whether any firmware updates are in progress.""" + connection.send_result( + msg[ID], await driver.controller.async_get_any_firmware_update_progress() + ) + + class FirmwareUploadView(HomeAssistantView): """View to upload firmware.""" diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index 1ed125cc43a..3a2bff54e88 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3723,6 +3723,77 @@ async def test_get_firmware_update_capabilities( assert msg["error"]["code"] == ERR_NOT_FOUND +async def test_get_any_firmware_update_progress( + hass, client, integration, hass_ws_client +): + """Test that the get_any_firmware_update_progress WS API call works.""" + entry = integration + ws_client = await hass_ws_client(hass) + + client.async_send_command.return_value = {"progress": True} + await ws_client.send_json( + { + ID: 1, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + assert msg["success"] + assert msg["result"] + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "controller.get_any_firmware_update_progress" + + # Test FailedZWaveCommand is caught + with patch( + "zwave_js_server.model.controller.Controller.async_get_any_firmware_update_progress", + side_effect=FailedZWaveCommand("failed_command", 1, "error message"), + ): + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == "zwave_error" + assert msg["error"]["message"] == "Z-Wave error 1: error message" + + # Test sending command with not loaded entry fails + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + await ws_client.send_json( + { + ID: 3, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: entry.entry_id, + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_LOADED + + # Test sending command with improper device ID fails + await ws_client.send_json( + { + ID: 4, + TYPE: "zwave_js/get_any_firmware_update_progress", + ENTRY_ID: "invalid_entry", + } + ) + msg = await ws_client.receive_json() + + assert not msg["success"] + assert msg["error"]["code"] == ERR_NOT_FOUND + + async def test_check_for_config_updates(hass, client, integration, hass_ws_client): """Test that the check_for_config_updates WS API call works.""" entry = integration From c607994fbeb1a746ebd2319754a4c3bb43c3cee7 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Fri, 24 Jun 2022 00:23:27 +0000 Subject: [PATCH 660/947] [ci skip] Translation update --- .../components/google/translations/it.json | 1 + .../components/google/translations/ja.json | 1 + .../components/google/translations/no.json | 1 + .../components/google/translations/pl.json | 1 + .../components/hue/translations/no.json | 2 +- .../components/nest/translations/it.json | 9 +++--- .../components/nest/translations/ja.json | 1 + .../components/nest/translations/no.json | 32 ++++++++++++++++++- .../components/nest/translations/pl.json | 29 ++++++++++++++++- .../overkiz/translations/sensor.it.json | 5 +++ .../overkiz/translations/sensor.ja.json | 5 +++ .../overkiz/translations/sensor.no.json | 5 +++ .../overkiz/translations/sensor.pl.json | 5 +++ .../components/scrape/translations/it.json | 4 +-- .../transmission/translations/it.json | 10 +++++- .../transmission/translations/ja.json | 10 +++++- .../transmission/translations/no.json | 10 +++++- .../transmission/translations/pl.json | 10 +++++- 18 files changed, 128 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/google/translations/it.json b/homeassistant/components/google/translations/it.json index a0f9d140329..ef5ec01202d 100644 --- a/homeassistant/components/google/translations/it.json +++ b/homeassistant/components/google/translations/it.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "L'account \u00e8 gi\u00e0 configurato", "already_in_progress": "Il flusso di configurazione \u00e8 gi\u00e0 in corso", + "cannot_connect": "Impossibile connettersi", "code_expired": "Il codice di autenticazione \u00e8 scaduto o la configurazione delle credenziali non \u00e8 valida, riprova.", "invalid_access_token": "Token di accesso non valido", "missing_configuration": "Il componente non \u00e8 configurato. Segui la documentazione.", diff --git a/homeassistant/components/google/translations/ja.json b/homeassistant/components/google/translations/ja.json index d5b52c197a8..4cb05958a76 100644 --- a/homeassistant/components/google/translations/ja.json +++ b/homeassistant/components/google/translations/ja.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", "already_in_progress": "\u69cb\u6210\u30d5\u30ed\u30fc\u306f\u3059\u3067\u306b\u9032\u884c\u4e2d\u3067\u3059", + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", "code_expired": "\u8a8d\u8a3c\u30b3\u30fc\u30c9\u306e\u6709\u52b9\u671f\u9650\u304c\u5207\u308c\u3066\u3044\u308b\u304b\u3001\u8cc7\u683c\u60c5\u5831\u306e\u8a2d\u5b9a\u304c\u7121\u52b9\u3067\u3059\u3002\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", diff --git a/homeassistant/components/google/translations/no.json b/homeassistant/components/google/translations/no.json index 4fcbeb1dae8..9842da0362c 100644 --- a/homeassistant/components/google/translations/no.json +++ b/homeassistant/components/google/translations/no.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "Kontoen er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", + "cannot_connect": "Tilkobling mislyktes", "code_expired": "Autentiseringskoden er utl\u00f8pt eller p\u00e5loggingsoppsettet er ugyldig. Pr\u00f8v p\u00e5 nytt.", "invalid_access_token": "Ugyldig tilgangstoken", "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", diff --git a/homeassistant/components/google/translations/pl.json b/homeassistant/components/google/translations/pl.json index 8013e775e62..3f78e3e9d6f 100644 --- a/homeassistant/components/google/translations/pl.json +++ b/homeassistant/components/google/translations/pl.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "Konto jest ju\u017c skonfigurowane", "already_in_progress": "Konfiguracja jest ju\u017c w toku", + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", "code_expired": "Kod uwierzytelniaj\u0105cy wygas\u0142 lub konfiguracja po\u015bwiadcze\u0144 jest nieprawid\u0142owa, spr\u00f3buj ponownie.", "invalid_access_token": "Niepoprawny token dost\u0119pu", "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", diff --git a/homeassistant/components/hue/translations/no.json b/homeassistant/components/hue/translations/no.json index 9c18003aca0..d34899db978 100644 --- a/homeassistant/components/hue/translations/no.json +++ b/homeassistant/components/hue/translations/no.json @@ -5,7 +5,7 @@ "already_configured": "Enheten er allerede konfigurert", "already_in_progress": "Konfigurasjonsflyten p\u00e5g\u00e5r allerede", "cannot_connect": "Tilkobling mislyktes", - "discover_timeout": "Kunne ikke oppdage Hue Bridger", + "discover_timeout": "Fant ingen Hue Bridger", "invalid_host": "Ugyldig vert", "no_bridges": "Ingen Philips Hue Bridger oppdaget", "not_hue_bridge": "Ikke en Hue bro", diff --git a/homeassistant/components/nest/translations/it.json b/homeassistant/components/nest/translations/it.json index 8fd83483290..8979631dec0 100644 --- a/homeassistant/components/nest/translations/it.json +++ b/homeassistant/components/nest/translations/it.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato", "authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.", "invalid_access_token": "Token di accesso non valido", "missing_configuration": "Il componente non \u00e8 configurato. Segui la documentazione.", @@ -33,7 +34,7 @@ "title": "Connetti l'account Google" }, "auth_upgrade": { - "description": "App Auth \u00e8 stato ritirato da Google per migliorare la sicurezza e devi agire creando nuove credenziali per l'applicazione. \n\nApri la [documentazione]({more_info_url}) per seguire i passaggi successivi che ti guideranno attraverso i passaggi necessari per ripristinare l'accesso ai tuoi dispositivi Nest.", + "description": "App Auth \u00e8 stato ritirato da Google per migliorare la sicurezza ed \u00e8 necessario intervenire creando nuove credenziali per l'applicazione. \n\nApri la [documentazione]({more_info_url}) per seguire i passaggi successivi che ti guideranno attraverso gli stadi necessari per ripristinare l'accesso ai tuoi dispositivi Nest.", "title": "Nest: ritiro dell'autenticazione dell'app" }, "cloud_project": { @@ -44,18 +45,18 @@ "title": "Nest: inserisci l'ID del progetto Cloud" }, "create_cloud_project": { - "description": "L'integrazione Nest ti consente di integrare i tuoi termostati, videocamere e campanelli Nest utilizzando l'API Smart Device Management. L'API SDM **richiede una tariffa di configurazione una tantum di US $ 5**. Consulta la documentazione per [maggiori informazioni]({more_info_url}). \n\n 1. Vai a [Google Cloud Console]( {cloud_console_url} ).\n 2. Se questo \u00e8 il tuo primo progetto, fai clic su **Crea progetto** e poi su **Nuovo progetto**.\n 3. Assegna un nome al tuo progetto cloud, quindi fai clic su **Crea**.\n 4. Salva l'ID del progetto cloud, ad es. *example-project-12345*, poich\u00e9 ti servir\u00e0 in seguito\n 5. Vai a Libreria API per [API Smart Device Management]({sdm_api_url}) e fai clic su **Abilita**.\n 6. Vai a Libreria API per [Cloud Pub/Sub API]({pubsub_api_url}) e fai clic su **Abilita**. \n\n Procedi quando il tuo progetto cloud \u00e8 impostato.", + "description": "L'integrazione Nest ti consente di integrare i tuoi termostati, videocamere e campanelli Nest utilizzando l'API Smart Device Management. L'API SDM **richiede una tariffa di configurazione una tantum di 5 $ USD**. Consulta la documentazione per [maggiori informazioni]({more_info_url}). \n\n 1. Vai su [Google Cloud Console]({cloud_console_url}).\n 2. Se questo \u00e8 il tuo primo progetto, fai clic su **Crea progetto** e poi su **Nuovo progetto**.\n 3. Assegna un nome al tuo progetto cloud, quindi fai clic su **Crea**.\n 4. Salva l'ID del progetto cloud, ad es. *example-project-12345*, poich\u00e9 ti servir\u00e0 in seguito.\n 5. Vai su Libreria API per [API Smart Device Management]({sdm_api_url}) e fai clic su **Abilita**.\n 6. Vai su Libreria API per [Cloud Pub/Sub API]({pubsub_api_url}) e fai clic su **Abilita**. \n\nProcedi quando il tuo progetto cloud \u00e8 impostato.", "title": "Nest: crea e configura un progetto cloud" }, "device_project": { "data": { "project_id": "ID progetto di accesso al dispositivo" }, - "description": "Crea un progetto di accesso al dispositivo Nest la cui configurazione **richiede una commissione di $ 5 USD**.\n 1. Vai alla [Console di accesso al dispositivo]({device_access_console_url}) e attraverso il flusso di pagamento.\n 2. Clicca su **Crea progetto**\n 3. Assegna un nome al progetto di accesso al dispositivo e fai clic su **Avanti**.\n 4. Inserisci il tuo ID cliente OAuth\n 5. Abilita gli eventi facendo clic su **Abilita** e **Crea progetto**. \n\n Inserisci il tuo ID progetto di accesso al dispositivo di seguito ([maggiori informazioni]({more_info_url})).\n", + "description": "Crea un progetto di accesso al dispositivo Nest la cui configurazione **richiede una commissione di 5 $ USD**.\n 1. Vai alla [Console di accesso al dispositivo]({device_access_console_url}) e attraverso il flusso di pagamento.\n 2. Clicca su **Crea progetto**\n 3. Assegna un nome al progetto di accesso al dispositivo e fai clic su **Avanti**.\n 4. Inserisci il tuo ID Client OAuth\n 5. Abilita gli eventi facendo clic su **Abilita** e **Crea progetto**. \n\nInserisci il tuo ID progetto di accesso al dispositivo di seguito ([maggiori informazioni]({more_info_url})).\n", "title": "Nest: crea un progetto di accesso al dispositivo" }, "device_project_upgrade": { - "description": "Aggiorna il progetto di accesso al dispositivo Nest con il nuovo ID client OAuth ([ulteriori informazioni]({more_info_url}))\n1. Vai a [Console di accesso al dispositivo]({device_access_console_url}).\n2. Fai clic sull'icona del cestino accanto a *OAuth Client ID*.\n3. Fai clic sul menu di overflow '...' e *Aggiungi ID client*.\n4. Immettere il nuovo ID client OAuth e fare clic su **Aggiungi**.\n\nIl tuo ID client OAuth \u00e8: '{client_id}'", + "description": "Aggiorna il progetto di accesso al dispositivo Nest con il nuovo ID client OAuth ([ulteriori informazioni]({more_info_url}))\n1. Vai a [Console di accesso al dispositivo]({device_access_console_url}).\n2. Fai clic sull'icona del cestino accanto a *ID Client OAuth*.\n3. Fai clic sul menu a comparsa '...' e *Aggiungi ID Client*.\n4. Immettere il nuovo ID Client OAuth e fare clic su **Aggiungi**.\n\nIl tuo ID Client OAuth \u00e8: `{client_id}`", "title": "Nest: aggiorna il progetto di accesso al dispositivo" }, "init": { diff --git a/homeassistant/components/nest/translations/ja.json b/homeassistant/components/nest/translations/ja.json index 37613407e4d..ee9fcbba98d 100644 --- a/homeassistant/components/nest/translations/ja.json +++ b/homeassistant/components/nest/translations/ja.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u30a2\u30ab\u30a6\u30f3\u30c8\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", "authorize_url_timeout": "\u8a8d\u8a3cURL\u306e\u751f\u6210\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f\u3002", "invalid_access_token": "\u7121\u52b9\u306a\u30a2\u30af\u30bb\u30b9\u30c8\u30fc\u30af\u30f3", "missing_configuration": "\u30b3\u30f3\u30dd\u30fc\u30cd\u30f3\u30c8\u304c\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u305b\u3093\u3002\u30c9\u30ad\u30e5\u30e1\u30f3\u30c8\u306b\u5f93\u3063\u3066\u304f\u3060\u3055\u3044\u3002", diff --git a/homeassistant/components/nest/translations/no.json b/homeassistant/components/nest/translations/no.json index fcc6aeadddf..ce6663ec22e 100644 --- a/homeassistant/components/nest/translations/no.json +++ b/homeassistant/components/nest/translations/no.json @@ -1,6 +1,10 @@ { + "application_credentials": { + "description": "F\u00f8lg [instruksjonene]( {more_info_url} ) for \u00e5 konfigurere Cloud Console: \n\n 1. G\u00e5 til [OAuth-samtykkeskjermen]( {oauth_consent_url} ) og konfigurer\n 1. G\u00e5 til [Credentials]( {oauth_creds_url} ) og klikk p\u00e5 **Create Credentials**.\n 1. Velg **OAuth-klient-ID** fra rullegardinlisten.\n 1. Velg **Nettapplikasjon** for applikasjonstype.\n 1. Legg til ` {redirect_url} ` under *Autorisert omdirigerings-URI*." + }, "config": { "abort": { + "already_configured": "Kontoen er allerede konfigurert", "authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse", "invalid_access_token": "Ugyldig tilgangstoken", "missing_configuration": "Komponenten er ikke konfigurert, vennligst f\u00f8lg dokumentasjonen", @@ -19,7 +23,7 @@ "subscriber_error": "Ukjent abonnentfeil, se logger", "timeout": "Tidsavbrudd ved validering av kode", "unknown": "Uventet feil", - "wrong_project_id": "Angi en gyldig Cloud Project ID (funnet Device Access Project ID)" + "wrong_project_id": "Angi en gyldig Cloud Project ID (var den samme som Device Access Project ID)" }, "step": { "auth": { @@ -29,6 +33,32 @@ "description": "For \u00e5 koble til Google-kontoen din, [autoriser kontoen din]( {url} ). \n\n Etter autorisasjon, kopier og lim inn den oppgitte Auth Token-koden nedenfor.", "title": "Koble til Google-kontoen" }, + "auth_upgrade": { + "description": "App Auth har blitt avviklet av Google for \u00e5 forbedre sikkerheten, og du m\u00e5 iverksette tiltak ved \u00e5 opprette ny applikasjonslegitimasjon. \n\n \u00c5pne [dokumentasjonen]( {more_info_url} ) for \u00e5 f\u00f8lge med, da de neste trinnene vil lede deg gjennom trinnene du m\u00e5 ta for \u00e5 gjenopprette tilgangen til Nest-enhetene dine.", + "title": "Nest: Appautentisering avvikelse" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Google Cloud Project ID" + }, + "description": "Skriv inn Cloud Project ID nedenfor, f.eks. *example-project-12345*. Se [Google Cloud Console]( {cloud_console_url} ) eller dokumentasjonen for [mer info]( {more_info_url} ).", + "title": "Nest: Angi Cloud Project ID" + }, + "create_cloud_project": { + "description": "Nest-integreringen lar deg integrere Nest-termostatene, kameraene og d\u00f8rklokkene dine ved hjelp av Smart Device Management API. SDM API **krever en engangsavgift p\u00e5 5 USD**. Se dokumentasjonen for [mer info]( {more_info_url} ). \n\n 1. G\u00e5 til [Google Cloud Console]( {cloud_console_url} ).\n 1. Hvis dette er ditt f\u00f8rste prosjekt, klikker du **Opprett prosjekt** og deretter **Nytt prosjekt**.\n 1. Gi skyprosjektet ditt et navn, og klikk deretter p\u00e5 **Opprett**.\n 1. Lagre Cloud Project ID, f.eks. *example-project-12345*, slik du trenger den senere\n 1. G\u00e5 til API Library for [Smart Device Management API]( {sdm_api_url} ) og klikk p\u00e5 **Aktiver**.\n 1. G\u00e5 til API-biblioteket for [Cloud Pub/Sub API]( {pubsub_api_url} ) og klikk p\u00e5 **Aktiver**. \n\n Fortsett n\u00e5r skyprosjektet ditt er satt opp.", + "title": "Nest: Opprett og konfigurer Cloud Project" + }, + "device_project": { + "data": { + "project_id": "Prosjekt-ID for enhetstilgang" + }, + "description": "Opprett et Nest Device Access-prosjekt som **krever en avgift p\u00e5 USD 5** for \u00e5 konfigurere.\n 1. G\u00e5 til [Device Access Console]( {device_access_console_url} ), og gjennom betalingsflyten.\n 1. Klikk p\u00e5 **Opprett prosjekt**\n 1. Gi Device Access-prosjektet ditt et navn og klikk p\u00e5 **Neste**.\n 1. Skriv inn din OAuth-klient-ID\n 1. Aktiver hendelser ved \u00e5 klikke **Aktiver** og **Opprett prosjekt**. \n\n Skriv inn Device Access Project ID nedenfor ([mer info]( {more_info_url} )).\n", + "title": "Nest: Opprett et Device Access Project" + }, + "device_project_upgrade": { + "description": "Oppdater Nest Device Access Project med din nye OAuth-klient-ID ([mer info]( {more_info_url} ))\n 1. G\u00e5 til [Device Access Console]( {device_access_console_url} ).\n 1. Klikk p\u00e5 s\u00f8ppelikonet ved siden av *OAuth Client ID*.\n 1. Klikk p\u00e5 \"...\" overl\u00f8psmenyen og *Legg til klient-ID*.\n 1. Skriv inn din nye OAuth-klient-ID og klikk p\u00e5 **Legg til**. \n\n Din OAuth-klient-ID er: ` {client_id} `", + "title": "Nest: Oppdater Device Access Project" + }, "init": { "data": { "flow_impl": "Tilbyder" diff --git a/homeassistant/components/nest/translations/pl.json b/homeassistant/components/nest/translations/pl.json index 4c53185e71f..c7a5c88b120 100644 --- a/homeassistant/components/nest/translations/pl.json +++ b/homeassistant/components/nest/translations/pl.json @@ -1,9 +1,10 @@ { "application_credentials": { - "description": "Post\u0119puj zgodnie z [instrukcj\u0105]({more_info_url}), aby skonfigurowa\u0107 Cloud Console: \n\n1. Przejd\u017a do [ekranu akceptacji OAuth]( {oauth_consent_url} ) i skonfiguruj\n2. Przejd\u017a do [Po\u015bwiadczenia]({oauth_creds_url}) i kliknij **Utw\u00f3rz po\u015bwiadczenia**.\n3. Z listy rozwijanej wybierz **ID klienta OAuth**.\n4. Wybierz **Aplikacja internetowa** jako Typ aplikacji.\n5. Dodaj `{redirect_url}` pod *Autoryzowany URI przekierowania*." + "description": "Post\u0119puj zgodnie z [instrukcj\u0105]({more_info_url}), aby skonfigurowa\u0107 Cloud Console: \n\n1. Przejd\u017a do [ekranu akceptacji OAuth]({oauth_consent_url}) i skonfiguruj.\n2. Przejd\u017a do [Po\u015bwiadczenia]({oauth_creds_url}) i kliknij **Utw\u00f3rz po\u015bwiadczenia**.\n3. Z listy rozwijanej wybierz **Identyfikator klienta OAuth**.\n4. Wybierz **Aplikacja internetowa** jako Typ aplikacji.\n5. Dodaj `{redirect_url}` pod *Autoryzowany URI przekierowania*." }, "config": { "abort": { + "already_configured": "Konto jest ju\u017c skonfigurowane", "authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji", "invalid_access_token": "Niepoprawny token dost\u0119pu", "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.", @@ -32,6 +33,32 @@ "description": "Aby po\u0142\u0105czy\u0107 swoje konto Google, [authorize your account]({url}). \n\nPo autoryzacji skopiuj i wklej podany poni\u017cej token uwierzytelniaj\u0105cy.", "title": "Po\u0142\u0105czenie z kontem Google" }, + "auth_upgrade": { + "description": "App Auth zosta\u0142o wycofane przez Google w celu poprawy bezpiecze\u0144stwa i musisz podj\u0105\u0107 dzia\u0142ania, tworz\u0105c nowe dane logowania do aplikacji. \n\nOtw\u00f3rz [dokumentacj\u0119]({more_info_url}), aby przej\u015b\u0107 dalej. Kolejne kroki poprowadz\u0105 Ci\u0119 przez instrukcje, kt\u00f3re musisz wykona\u0107, aby przywr\u00f3ci\u0107 dost\u0119p do urz\u0105dze\u0144 Nest.", + "title": "Nest: wycofanie App Auth" + }, + "cloud_project": { + "data": { + "cloud_project_id": "Identyfikator projektu chmury Google" + }, + "description": "Wpisz poni\u017cej identyfikator projektu chmury, np. *przykladowy-projekt-12345*. Zajrzyj do [Konsoli Google Cloud]({cloud_console_url}) lub dokumentacji po [wi\u0119cej informacji]({more_info_url}).", + "title": "Nest: Wprowad\u017a identyfikator projektu chmury" + }, + "create_cloud_project": { + "description": "Integracja Nest umo\u017cliwia integracj\u0119 termostat\u00f3w, kamer i dzwonk\u00f3w Nest za pomoc\u0105 Smart Device Management API. Interfejs API SDM **wymaga jednorazowej op\u0142aty instalacyjnej w wysoko\u015bci 5 dolar\u00f3w**. Zajrzyj do dokumentacji po [wi\u0119cej informacji]({more_info_url}). \n\n1. Przejd\u017a do [Konsoli Google Cloud]({cloud_console_url}).\n2. Je\u015bli to Tw\u00f3j pierwszy projekt, kliknij **Utw\u00f3rz projekt**, a nast\u0119pnie **Nowy projekt**.\n3. Nadaj nazw\u0119 swojemu projektowi w chmurze, a nast\u0119pnie kliknij **Utw\u00f3rz**.\n4. Zapisz identyfikator projektu chmury, np. *przykladowy-projekt-12345*, poniewa\u017c b\u0119dziesz go potrzebowa\u0107 p\u00f3\u017aniej.\n5. Przejd\u017a do biblioteki API dla [Smart Device Management API]({sdm_api_url}) i kliknij **W\u0142\u0105cz**.\n6. Przejd\u017a do biblioteki API dla [Cloud Pub/Sub API]({pubsub_api_url}) i kliknij **W\u0142\u0105cz**. \n\nKontynuuj po skonfigurowaniu projektu w chmurze.", + "title": "Nest: Utw\u00f3rz i skonfiguruj projekt w chmurze" + }, + "device_project": { + "data": { + "project_id": "Identyfikator projektu dost\u0119pu do urz\u0105dzenia" + }, + "description": "Utw\u00f3rz projekt dost\u0119pu do urz\u0105dzenia Nest, kt\u00f3rego konfiguracja **wymaga op\u0142aty w wysoko\u015bci 5 dolar\u00f3w**.\n1. Przejd\u017a do [Konsoli dost\u0119pu do urz\u0105dzenia]({device_access_console_url}) i przejd\u017a przez proces p\u0142atno\u015bci.\n2. Kliknij **Utw\u00f3rz projekt**.\n3. Nadaj projektowi dost\u0119pu do urz\u0105dzenia nazw\u0119 i kliknij **Dalej**.\n4. Wprowad\u017a sw\u00f3j identyfikator klienta OAuth\n5. W\u0142\u0105cz wydarzenia, klikaj\u0105c **W\u0142\u0105cz** i **Utw\u00f3rz projekt**. \n\nPod ([wi\u0119cej informacji]({more_info_url})) wpisz sw\u00f3j identyfikator projektu dost\u0119pu do urz\u0105dzenia.\n", + "title": "Nest: Utw\u00f3rz projekt dost\u0119pu do urz\u0105dzenia" + }, + "device_project_upgrade": { + "description": "Zaktualizuj projekt dost\u0119pu do urz\u0105dzenia Nest przy u\u017cyciu nowego identyfikatora klienta OAuth ([wi\u0119cej informacji]({more_info_url}))\n1. Przejd\u017a do [Konsoli dost\u0119pu do urz\u0105dzenia]({device_access_console_url}).\n2. Kliknij ikon\u0119 kosza obok *Identyfikator klienta OAuth*.\n3. Kliknij menu rozszerzone `...` i *Dodaj identyfikator klienta*.\n4. Wprowad\u017a nowy identyfikator klienta OAuth i kliknij **Dodaj**. \n\nTw\u00f3j identyfikator klienta OAuth to: `{client_id}`", + "title": "Nest: Zaktualizuj projekt dost\u0119pu do urz\u0105dzenia" + }, "init": { "data": { "flow_impl": "Dostawca" diff --git a/homeassistant/components/overkiz/translations/sensor.it.json b/homeassistant/components/overkiz/translations/sensor.it.json index cb63ec30408..0799ae8d7c8 100644 --- a/homeassistant/components/overkiz/translations/sensor.it.json +++ b/homeassistant/components/overkiz/translations/sensor.it.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Pulito", "dirty": "Sporco" + }, + "overkiz__three_way_handle_direction": { + "closed": "Chiuso", + "open": "Aperto", + "tilt": "Inclinazione" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.ja.json b/homeassistant/components/overkiz/translations/sensor.ja.json index ca1f1833a0a..e1c44a8e3ff 100644 --- a/homeassistant/components/overkiz/translations/sensor.ja.json +++ b/homeassistant/components/overkiz/translations/sensor.ja.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "\u30af\u30ea\u30fc\u30f3", "dirty": "\u30c0\u30fc\u30c6\u30a3" + }, + "overkiz__three_way_handle_direction": { + "closed": "\u30af\u30ed\u30fc\u30ba\u30c9", + "open": "\u30aa\u30fc\u30d7\u30f3", + "tilt": "\u50be\u659c(Tilt)" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.no.json b/homeassistant/components/overkiz/translations/sensor.no.json index 23a94df54c7..65f3cbeed9f 100644 --- a/homeassistant/components/overkiz/translations/sensor.no.json +++ b/homeassistant/components/overkiz/translations/sensor.no.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Ren", "dirty": "Skitten" + }, + "overkiz__three_way_handle_direction": { + "closed": "Lukket", + "open": "\u00c5pen", + "tilt": "Vippe" } } } \ No newline at end of file diff --git a/homeassistant/components/overkiz/translations/sensor.pl.json b/homeassistant/components/overkiz/translations/sensor.pl.json index 0633c0d8424..440cf4998f8 100644 --- a/homeassistant/components/overkiz/translations/sensor.pl.json +++ b/homeassistant/components/overkiz/translations/sensor.pl.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "czysto", "dirty": "brudno" + }, + "overkiz__three_way_handle_direction": { + "closed": "zamkni\u0119ta", + "open": "otwarta", + "tilt": "uchylona" } } } \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/it.json b/homeassistant/components/scrape/translations/it.json index 5fd8428335f..e64ee3022d8 100644 --- a/homeassistant/components/scrape/translations/it.json +++ b/homeassistant/components/scrape/translations/it.json @@ -23,7 +23,7 @@ }, "data_description": { "attribute": "Ottieni il valore di un attributo sull'etichetta selezionata", - "authentication": "Tipo di autenticazione HTTP. Base o digest", + "authentication": "Tipo di autenticazione HTTP. basic o digest", "device_class": "Il tipo/classe del sensore per impostare l'icona nel frontend", "headers": "Intestazioni da utilizzare per la richiesta web", "index": "Definisce quale degli elementi restituiti dal selettore CSS utilizzare", @@ -57,7 +57,7 @@ }, "data_description": { "attribute": "Ottieni il valore di un attributo sull'etichetta selezionata", - "authentication": "Tipo di autenticazione HTTP. Base o digest", + "authentication": "Tipo di autenticazione HTTP. basic o digest", "device_class": "Il tipo/classe del sensore per impostare l'icona nel frontend", "headers": "Intestazioni da utilizzare per la richiesta web", "index": "Definisce quale degli elementi restituiti dal selettore CSS utilizzare", diff --git a/homeassistant/components/transmission/translations/it.json b/homeassistant/components/transmission/translations/it.json index 18edfb17351..2cefcdfb290 100644 --- a/homeassistant/components/transmission/translations/it.json +++ b/homeassistant/components/transmission/translations/it.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", + "reauth_successful": "La nuova autenticazione \u00e8 stata eseguita correttamente" }, "error": { "cannot_connect": "Impossibile connettersi", @@ -9,6 +10,13 @@ "name_exists": "Nome gi\u00e0 esistente" }, "step": { + "reauth_confirm": { + "data": { + "password": "Password" + }, + "description": "La password per {username} non \u00e8 valida.", + "title": "Autentica nuovamente l'integrazione" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/transmission/translations/ja.json b/homeassistant/components/transmission/translations/ja.json index 5bd5b98a8b3..6f3b3191c83 100644 --- a/homeassistant/components/transmission/translations/ja.json +++ b/homeassistant/components/transmission/translations/ja.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "reauth_successful": "\u518d\u8a8d\u8a3c\u306b\u6210\u529f\u3057\u307e\u3057\u305f" }, "error": { "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f", @@ -9,6 +10,13 @@ "name_exists": "\u540d\u524d\u306f\u3059\u3067\u306b\u5b58\u5728\u3057\u307e\u3059" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u30d1\u30b9\u30ef\u30fc\u30c9" + }, + "description": "{username} \u306e\u30d1\u30b9\u30ef\u30fc\u30c9\u304c\u7121\u52b9\u3067\u3059\u3002", + "title": "\u30a4\u30f3\u30c6\u30b0\u30ec\u30fc\u30b7\u30e7\u30f3\u306e\u518d\u8a8d\u8a3c" + }, "user": { "data": { "host": "\u30db\u30b9\u30c8", diff --git a/homeassistant/components/transmission/translations/no.json b/homeassistant/components/transmission/translations/no.json index cab8fd22659..fe15e4adc43 100644 --- a/homeassistant/components/transmission/translations/no.json +++ b/homeassistant/components/transmission/translations/no.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Enheten er allerede konfigurert" + "already_configured": "Enheten er allerede konfigurert", + "reauth_successful": "Godkjenning p\u00e5 nytt var vellykket" }, "error": { "cannot_connect": "Tilkobling mislyktes", @@ -9,6 +10,13 @@ "name_exists": "Navn eksisterer allerede" }, "step": { + "reauth_confirm": { + "data": { + "password": "Passord" + }, + "description": "Passordet for {username} er ugyldig.", + "title": "Godkjenne integrering p\u00e5 nytt" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/transmission/translations/pl.json b/homeassistant/components/transmission/translations/pl.json index dd2b28ad65f..994744a3547 100644 --- a/homeassistant/components/transmission/translations/pl.json +++ b/homeassistant/components/transmission/translations/pl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane" + "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane", + "reauth_successful": "Ponowne uwierzytelnienie powiod\u0142o si\u0119" }, "error": { "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia", @@ -9,6 +10,13 @@ "name_exists": "Nazwa ju\u017c istnieje" }, "step": { + "reauth_confirm": { + "data": { + "password": "Has\u0142o" + }, + "description": "Has\u0142o dla u\u017cytkownika {username} jest nieprawid\u0142owe.", + "title": "Ponownie uwierzytelnij integracj\u0119" + }, "user": { "data": { "host": "Nazwa hosta lub adres IP", From e5c40d58ffc606c40b0400aa4a9ece472004393d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 23 Jun 2022 21:13:43 -0500 Subject: [PATCH 661/947] Add roku 3820X model to discovery (#73933) --- homeassistant/components/roku/manifest.json | 2 +- homeassistant/generated/zeroconf.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 3f63a7039c1..05fe0e1b260 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/roku", "requirements": ["rokuecp==0.16.0"], "homekit": { - "models": ["3810X", "4660X", "7820X", "C105X", "C135X"] + "models": ["3820X", "3810X", "4660X", "7820X", "C105X", "C135X"] }, "ssdp": [ { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 21631292f13..faca1c17854 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -406,6 +406,7 @@ ZEROCONF = { HOMEKIT = { "3810X": "roku", + "3820X": "roku", "4660X": "roku", "7820X": "roku", "819LMB": "myq", From e4a770984d82f3a3f9ff88a9a47b612caf2b90e1 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Thu, 23 Jun 2022 22:50:39 -0400 Subject: [PATCH 662/947] Bump version of pyunifiprotect to 4.0.8 (#73934) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index e28386d73a8..da82871d313 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==4.0.7", "unifi-discovery==1.1.4"], + "requirements": ["pyunifiprotect==4.0.8", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index fc14180138f..0e937316fc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.7 +pyunifiprotect==4.0.8 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f3fc63c76f..ea092b5744e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1325,7 +1325,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.7 +pyunifiprotect==4.0.8 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 307666da7f54cede3a73cb52ef13aab1e13d4954 Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Thu, 23 Jun 2022 22:51:31 -0500 Subject: [PATCH 663/947] Bump Frontend to 20220624.0 (#73938) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7d07bbd543c..b7c1c0ddeff 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220601.0"], + "requirements": ["home-assistant-frontend==20220624.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c9d011f3471..55cc067829b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220601.0 +home-assistant-frontend==20220624.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index 0e937316fc2..542fcdbb540 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -825,7 +825,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220601.0 +home-assistant-frontend==20220624.0 # homeassistant.components.home_connect homeconnect==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ea092b5744e..b41bf1b223d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -592,7 +592,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220601.0 +home-assistant-frontend==20220624.0 # homeassistant.components.home_connect homeconnect==0.7.0 From a92ab7a6693bb8a1ae42e5674f5abfa9ecc04d16 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jun 2022 06:40:26 +0200 Subject: [PATCH 664/947] Adjust CoverEntity function type hints in components (#73912) Adjust CoverEntity functions in components --- homeassistant/components/acmeda/cover.py | 16 +++--- homeassistant/components/ads/cover.py | 12 ++-- .../components/advantage_air/cover.py | 8 ++- homeassistant/components/blebox/cover.py | 10 ++-- homeassistant/components/bosch_shc/cover.py | 10 ++-- .../components/command_line/cover.py | 6 +- homeassistant/components/demo/cover.py | 14 +++-- homeassistant/components/dynalite/cover.py | 18 +++--- homeassistant/components/fibaro/cover.py | 16 +++--- homeassistant/components/freedompro/cover.py | 5 +- homeassistant/components/garadget/cover.py | 7 ++- homeassistant/components/gogogate2/cover.py | 6 +- homeassistant/components/homematic/cover.py | 18 +++--- .../components/homematicip_cloud/cover.py | 56 ++++++++++--------- homeassistant/components/insteon/cover.py | 7 ++- homeassistant/components/lutron/cover.py | 7 ++- .../components/lutron_caseta/cover.py | 10 ++-- .../components/motion_blinds/cover.py | 27 ++++----- homeassistant/components/mqtt/cover.py | 17 +++--- homeassistant/components/myq/cover.py | 6 +- homeassistant/components/opengarage/cover.py | 5 +- homeassistant/components/rflink/cover.py | 7 ++- homeassistant/components/scsgate/cover.py | 7 ++- homeassistant/components/slide/cover.py | 9 +-- homeassistant/components/smartthings/cover.py | 5 +- homeassistant/components/soma/cover.py | 18 +++--- .../components/somfy_mylink/cover.py | 9 +-- homeassistant/components/supla/cover.py | 17 +++--- homeassistant/components/tellduslive/cover.py | 8 ++- homeassistant/components/tellstick/cover.py | 8 ++- homeassistant/components/template/cover.py | 17 +++--- homeassistant/components/tuya/cover.py | 2 +- homeassistant/components/velux/cover.py | 18 +++--- homeassistant/components/vera/cover.py | 2 +- homeassistant/components/wilight/cover.py | 10 ++-- .../components/xiaomi_aqara/cover.py | 10 ++-- homeassistant/components/zha/cover.py | 24 ++++---- homeassistant/components/zwave_me/cover.py | 4 +- 38 files changed, 254 insertions(+), 202 deletions(-) diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index 2fbd2de6c42..d772d8ab01f 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -1,6 +1,8 @@ """Support for Acmeda Roller Blinds.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, CoverEntity, @@ -92,31 +94,31 @@ class AcmedaCover(AcmedaBase, CoverEntity): """Return if the cover is closed.""" return self.roller.closed_percent == 100 - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the roller.""" await self.roller.move_down() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the roller.""" await self.roller.move_up() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the roller.""" await self.roller.move_stop() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" await self.roller.move_to(100 - kwargs[ATTR_POSITION]) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the roller.""" await self.roller.move_down() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the roller.""" await self.roller.move_up() - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the roller.""" await self.roller.move_stop() diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index c1f057b588e..a976ce15877 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -1,6 +1,8 @@ """Support for ADS covers.""" from __future__ import annotations +from typing import Any + import pyads import voluptuous as vol @@ -122,7 +124,7 @@ class AdsCover(AdsEntity, CoverEntity): if ads_var_pos_set is not None: self._attr_supported_features |= CoverEntityFeature.SET_POSITION - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register device notification.""" if self._ads_var is not None: await self.async_initialize_device(self._ads_var, pyads.PLCTYPE_BOOL) @@ -146,12 +148,12 @@ class AdsCover(AdsEntity, CoverEntity): """Return current position of cover.""" return self._state_dict[STATE_KEY_POSITION] - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" if self._ads_var_stop: self._ads_hub.write_by_name(self._ads_var_stop, True, pyads.PLCTYPE_BOOL) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Set cover position.""" position = kwargs[ATTR_POSITION] if self._ads_var_pos_set is not None: @@ -159,14 +161,14 @@ class AdsCover(AdsEntity, CoverEntity): self._ads_var_pos_set, position, pyads.PLCTYPE_BYTE ) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._ads_var_open is not None: self._ads_hub.write_by_name(self._ads_var_open, True, pyads.PLCTYPE_BOOL) elif self._ads_var_pos_set is not None: self.set_cover_position(position=100) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" if self._ads_var_close is not None: self._ads_hub.write_by_name(self._ads_var_close, True, pyads.PLCTYPE_BOOL) diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index 847ca41c42c..bbe17835d71 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -1,4 +1,6 @@ """Cover platform for Advantage Air integration.""" +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, CoverDeviceClass, @@ -67,7 +69,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): return self._zone["value"] return 0 - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Fully open zone vent.""" await self.async_change( { @@ -79,7 +81,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): } ) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Fully close zone vent.""" await self.async_change( { @@ -89,7 +91,7 @@ class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): } ) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Change vent position.""" position = round(kwargs[ATTR_POSITION] / 5) * 5 if position == 0: diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index 09d28a4f87a..a7f531fe519 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -1,4 +1,6 @@ """BleBox cover entity.""" +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, CoverEntity, @@ -62,21 +64,21 @@ class BleBoxCoverEntity(BleBoxEntity, CoverEntity): """Return whether cover is closed.""" return self._is_state(STATE_CLOSED) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover position.""" await self._feature.async_open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover position.""" await self._feature.async_close() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover position.""" position = kwargs[ATTR_POSITION] await self._feature.async_set_position(100 - position) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._feature.async_stop() diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index fd191d59bc3..cdbe884dc45 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -1,4 +1,6 @@ """Platform for cover integration.""" +from typing import Any + from boschshcpy import SHCSession, SHCShutterControl from homeassistant.components.cover import ( @@ -54,7 +56,7 @@ class ShutterControlCover(SHCEntity, CoverEntity): """Return the current cover position.""" return round(self._device.level * 100.0) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._device.stop() @@ -79,15 +81,15 @@ class ShutterControlCover(SHCEntity, CoverEntity): == SHCShutterControl.ShutterControlService.State.CLOSING ) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._device.level = 1.0 - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close cover.""" self._device.level = 0.0 - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] self._device.level = position / 100.0 diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index 321b18437d9..609166f2d16 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -153,14 +153,14 @@ class CommandCover(CoverEntity): payload = self._value_template.render_with_possible_json_value(payload) self._state = int(payload) - def open_cover(self, **kwargs) -> None: + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._move_cover(self._command_open) - def close_cover(self, **kwargs) -> None: + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._move_cover(self._command_close) - def stop_cover(self, **kwargs) -> None: + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._move_cover(self._command_stop) diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 9a1ea6239ee..79a51406857 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -1,6 +1,8 @@ """Demo platform for the cover component.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -159,7 +161,7 @@ class DemoCover(CoverEntity): return self._supported_features return super().supported_features - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" if self._position == 0: return @@ -173,7 +175,7 @@ class DemoCover(CoverEntity): self._requested_closing = True self.async_write_ha_state() - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" if self._tilt_position in (0, None): return @@ -181,7 +183,7 @@ class DemoCover(CoverEntity): self._listen_cover_tilt() self._requested_closing_tilt = True - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" if self._position == 100: return @@ -195,7 +197,7 @@ class DemoCover(CoverEntity): self._requested_closing = False self.async_write_ha_state() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" if self._tilt_position in (100, None): return @@ -223,7 +225,7 @@ class DemoCover(CoverEntity): self._listen_cover_tilt() self._requested_closing_tilt = tilt_position < self._tilt_position - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._is_closing = False self._is_opening = False @@ -234,7 +236,7 @@ class DemoCover(CoverEntity): self._unsub_listener_cover = None self._set_position = None - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" if self._tilt_position is None: return diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 930ced4ff54..e5c38996a89 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -1,5 +1,7 @@ """Support for the Dynalite channels as covers.""" +from typing import Any + from homeassistant.components.cover import DEVICE_CLASSES, CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -60,19 +62,19 @@ class DynaliteCover(DynaliteBase, CoverEntity): """Return true if cover is closed.""" return self._device.is_closed - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.async_open_cover(**kwargs) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.async_close_cover(**kwargs) - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover position.""" await self._device.async_set_cover_position(**kwargs) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._device.async_stop_cover(**kwargs) @@ -85,18 +87,18 @@ class DynaliteCoverWithTilt(DynaliteCover): """Return the current tilt position.""" return self._device.current_cover_tilt_position - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" await self._device.async_open_cover_tilt(**kwargs) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" await self._device.async_close_cover_tilt(**kwargs) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Set the cover tilt position.""" await self._device.async_set_cover_tilt_position(**kwargs) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self._device.async_stop_cover_tilt(**kwargs) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index e898cd7ead9..1e5583f20d6 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -1,6 +1,8 @@ """Support for Fibaro cover - curtains, rollershutters etc.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -79,11 +81,11 @@ class FibaroCover(FibaroDevice, CoverEntity): """Return the current tilt position for venetian blinds.""" return self.bound(self.level2) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.set_level(kwargs.get(ATTR_POSITION)) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.set_level2(kwargs.get(ATTR_TILT_POSITION)) @@ -97,22 +99,22 @@ class FibaroCover(FibaroDevice, CoverEntity): return None return self.current_cover_position == 0 - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.action("open") - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self.action("close") - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" self.set_level2(100) - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover.""" self.set_level2(0) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self.action("stop") diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index ab3914519dd..e4483f0005b 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -1,5 +1,6 @@ """Support for Freedompro cover.""" import json +from typing import Any from pyfreedompro import put_state @@ -96,11 +97,11 @@ class Device(CoordinatorEntity, CoverEntity): await super().async_added_to_hass() self._handle_coordinator_update() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.async_set_cover_position(position=100) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.async_set_cover_position(position=0) diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 75198fa2f60..e0372db0c6c 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import requests import voluptuous as vol @@ -207,21 +208,21 @@ class GaradgetCover(CoverEntity): """Check the state of the service during an operation.""" self.schedule_update_ha_state(True) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" if self._state not in ["close", "closing"]: ret = self._put_command("setState", "close") self._start_watcher("close") return ret.get("return_value") == 1 - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" if self._state not in ["open", "opening"]: ret = self._put_command("setState", "open") self._start_watcher("open") return ret.get("return_value") == 1 - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the door where it is.""" if self._state not in ["stopped"]: ret = self._put_command("setState", "stop") diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index af3bd1c7530..b7434952fc1 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -1,6 +1,8 @@ """Support for Gogogate2 garage Doors.""" from __future__ import annotations +from typing import Any + from ismartgate.common import ( AbstractDoor, DoorStatus, @@ -84,12 +86,12 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): """Return if the cover is opening or not.""" return self.door_status == TransitionDoorStatus.OPENING - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the door.""" await self._api.async_open_door(self._door_id) await self.coordinator.async_refresh() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the door.""" await self._api.async_close_door(self._door_id) await self.coordinator.async_refresh() diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index 2aa47ad863a..d138e172784 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -1,6 +1,8 @@ """Support for HomeMatic covers.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -49,7 +51,7 @@ class HMCover(HMDevice, CoverEntity): """ return int(self._hm_get_state() * 100) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = float(kwargs[ATTR_POSITION]) @@ -64,15 +66,15 @@ class HMCover(HMDevice, CoverEntity): return self.current_cover_position == 0 return None - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._hmdevice.move_up(self._channel) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._hmdevice.move_down(self._channel) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" self._hmdevice.stop(self._channel) @@ -93,7 +95,7 @@ class HMCover(HMDevice, CoverEntity): return None return int(position * 100) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" if "LEVEL_2" in self._data and ATTR_TILT_POSITION in kwargs: position = float(kwargs[ATTR_TILT_POSITION]) @@ -101,17 +103,17 @@ class HMCover(HMDevice, CoverEntity): level = position / 100.0 self._hmdevice.set_cover_tilt_position(level, self._channel) - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" if "LEVEL_2" in self._data: self._hmdevice.open_slats() - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" if "LEVEL_2" in self._data: self._hmdevice.close_slats() - def stop_cover_tilt(self, **kwargs): + def stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" if "LEVEL_2" in self._data: self.stop_cover(**kwargs) diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index b5076fa74ec..31faea875a4 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -1,6 +1,8 @@ """Support for HomematicIP Cloud cover devices.""" from __future__ import annotations +from typing import Any + from homematicip.aio.device import ( AsyncBlindModule, AsyncDinRailBlind4, @@ -86,14 +88,14 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): return int((1 - self._device.secondaryShadingLevel) * 100) return None - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 await self._device.set_primary_shading_level(primaryShadingLevel=level) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 @@ -110,37 +112,37 @@ class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): return self._device.primaryShadingLevel == HMIP_COVER_CLOSED return None - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.set_primary_shading_level( primaryShadingLevel=HMIP_COVER_OPEN ) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.set_primary_shading_level( primaryShadingLevel=HMIP_COVER_CLOSED ) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.stop() - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" await self._device.set_secondary_shading_level( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_OPEN, ) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" await self._device.set_secondary_shading_level( primaryShadingLevel=self._device.primaryShadingLevel, secondaryShadingLevel=HMIP_SLATS_CLOSED, ) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.stop() @@ -174,7 +176,7 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): ) return None - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 @@ -191,15 +193,15 @@ class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): ) return None - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.set_shutter_level(HMIP_COVER_OPEN, self._channel) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.set_shutter_level(HMIP_COVER_CLOSED, self._channel) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.set_shutter_stop(self._channel) @@ -236,26 +238,26 @@ class HomematicipMultiCoverSlats(HomematicipMultiCoverShutter, CoverEntity): ) return None - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 await self._device.set_slats_level(slatsLevel=level, channelIndex=self._channel) - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" await self._device.set_slats_level( slatsLevel=HMIP_SLATS_OPEN, channelIndex=self._channel ) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" await self._device.set_slats_level( slatsLevel=HMIP_SLATS_CLOSED, channelIndex=self._channel ) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the device if in motion.""" await self._device.set_shutter_stop(self._channel) @@ -292,15 +294,15 @@ class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Return if the cover is closed.""" return self._device.doorState == DoorState.CLOSED - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.send_door_command(DoorCommand.OPEN) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.send_door_command(DoorCommand.CLOSE) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._device.send_door_command(DoorCommand.STOP) @@ -339,40 +341,40 @@ class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): return self._device.shutterLevel == HMIP_COVER_CLOSED return None - async def async_set_cover_position(self, **kwargs) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] # HmIP cover is closed:1 -> open:0 level = 1 - position / 100.0 await self._device.set_shutter_level(level) - async def async_set_cover_tilt_position(self, **kwargs) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover to a specific tilt position.""" position = kwargs[ATTR_TILT_POSITION] # HmIP slats is closed:1 -> open:0 level = 1 - position / 100.0 await self._device.set_slats_level(level) - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._device.set_shutter_level(HMIP_COVER_OPEN) - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._device.set_shutter_level(HMIP_COVER_CLOSED) - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the group if in motion.""" await self._device.set_shutter_stop() - async def async_open_cover_tilt(self, **kwargs) -> None: + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the slats.""" await self._device.set_slats_level(HMIP_SLATS_OPEN) - async def async_close_cover_tilt(self, **kwargs) -> None: + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the slats.""" await self._device.set_slats_level(HMIP_SLATS_CLOSED) - async def async_stop_cover_tilt(self, **kwargs) -> None: + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the group if in motion.""" await self._device.set_shutter_stop() diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index defa1acaa38..68f7f6156e3 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -1,5 +1,6 @@ """Support for Insteon covers via PowerLinc Modem.""" import math +from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, @@ -59,15 +60,15 @@ class InsteonCoverEntity(InsteonEntity, CoverEntity): """Return the boolean response if the node is on.""" return bool(self.current_cover_position) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open cover.""" await self._insteon_device.async_open() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._insteon_device.async_close() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set the cover position.""" position = int(kwargs[ATTR_POSITION] * 255 / 100) if position == 0: diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 45b7751aa7c..67a0e093337 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, @@ -51,15 +52,15 @@ class LutronCover(LutronDevice, CoverEntity): """Return the current position of cover.""" return self._lutron_device.last_level() - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._lutron_device.level = 0 - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._lutron_device.level = 100 - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 724dc4258da..68932a2a011 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -1,5 +1,7 @@ """Support for Lutron Caseta shades.""" +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, DOMAIN, @@ -57,23 +59,23 @@ class LutronCasetaCover(LutronCasetaDeviceUpdatableEntity, CoverEntity): """Return the current position of cover.""" return self._device["current_state"] - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Top the cover.""" await self._smartbridge.stop_cover(self.device_id) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._smartbridge.lower_cover(self.device_id) self.async_update() self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._smartbridge.raise_cover(self.device_id) self.async_update() self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 23e7e3d834a..301d2c6fbc7 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -1,5 +1,6 @@ """Support for Motion Blinds using their WLAN API.""" import logging +from typing import Any from motionblinds import DEVICE_TYPES_WIFI, BlindType import voluptuous as vol @@ -243,7 +244,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): return None return self._blind.position == 100 - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to multicast pushes and register signal handler.""" self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) await super().async_added_to_hass() @@ -288,19 +289,19 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): self.hass, UPDATE_INTERVAL_MOVING, self.async_scheduled_update_request ) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Open) await self.async_request_position_till_stop() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Close) await self.async_request_position_till_stop() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] async with self._api_lock: @@ -327,7 +328,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): ) await self.async_request_position_till_stop() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) @@ -349,23 +350,23 @@ class MotionTiltDevice(MotionPositionDevice): return None return self._blind.angle * 100 / 180 - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, 180) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, 0) - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" angle = kwargs[ATTR_TILT_POSITION] * 180 / 100 async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Set_angle, angle) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop) @@ -463,19 +464,19 @@ class MotionTDBUDevice(MotionPositionDevice): attributes[ATTR_WIDTH] = self._blind.width return attributes - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Open, self._motor_key) await self.async_request_position_till_stop() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Close, self._motor_key) await self.async_request_position_till_stop() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific scaled position.""" position = kwargs[ATTR_POSITION] async with self._api_lock: @@ -496,7 +497,7 @@ class MotionTDBUDevice(MotionPositionDevice): await self.async_request_position_till_stop() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" async with self._api_lock: await self.hass.async_add_executor_job(self._blind.Stop, self._motor_key) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 754fcb7ec44..8a32471ecec 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -3,6 +3,7 @@ from __future__ import annotations import functools import logging +from typing import Any import voluptuous as vol @@ -545,7 +546,7 @@ class MqttCover(MqttEntity, CoverEntity): return supported_features - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. This method is a coroutine. @@ -566,7 +567,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down. This method is a coroutine. @@ -587,7 +588,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the device. This method is a coroutine. @@ -600,7 +601,7 @@ class MqttCover(MqttEntity, CoverEntity): self._config[CONF_ENCODING], ) - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover open.""" tilt_open_position = self._config[CONF_TILT_OPEN_POSITION] variables = { @@ -625,7 +626,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover closed.""" tilt_closed_position = self._config[CONF_TILT_CLOSED_POSITION] variables = { @@ -652,7 +653,7 @@ class MqttCover(MqttEntity, CoverEntity): ) self.async_write_ha_state() - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" tilt = kwargs[ATTR_TILT_POSITION] percentage_tilt = tilt @@ -680,7 +681,7 @@ class MqttCover(MqttEntity, CoverEntity): self._tilt_value = percentage_tilt self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] percentage_position = position @@ -711,7 +712,7 @@ class MqttCover(MqttEntity, CoverEntity): self._position = percentage_position self.async_write_ha_state() - async def async_toggle_tilt(self, **kwargs): + async def async_toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" if self.is_tilt_closed(): await self.async_open_cover_tilt(**kwargs) diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index 861ae379007..fe8ef16bc89 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -1,4 +1,6 @@ """Support for MyQ-Enabled Garage Doors.""" +from typing import Any + from pymyq.const import DEVICE_TYPE_GATE as MYQ_DEVICE_TYPE_GATE from pymyq.errors import MyQError @@ -67,7 +69,7 @@ class MyQCover(MyQEntity, CoverEntity): """Return if the cover is opening or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" if self.is_closing or self.is_closed: return @@ -90,7 +92,7 @@ class MyQCover(MyQEntity, CoverEntity): if not result: raise HomeAssistantError(f"Closing of cover {self._device.name} failed") - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" if self.is_opening or self.is_open: return diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index 9d5bd6e5cb6..ac0af64737a 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -1,5 +1,6 @@ """Platform for the opengarage.io cover component.""" import logging +from typing import Any from homeassistant.components.cover import ( CoverDeviceClass, @@ -62,7 +63,7 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): return None return self._state == STATE_OPENING - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" if self._state in [STATE_CLOSED, STATE_CLOSING]: return @@ -70,7 +71,7 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): self._state = STATE_CLOSING await self._push_button() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" if self._state in [STATE_OPEN, STATE_OPENING]: return diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index cd109821582..91e68fa0fb8 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -155,15 +156,15 @@ class RflinkCover(RflinkCommand, CoverEntity, RestoreEntity): """Return True because covers can be stopped midway.""" return True - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Turn the device close.""" await self._async_handle_command("close_cover") - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Turn the device open.""" await self._async_handle_command("open_cover") - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Turn the device stop.""" await self._async_handle_command("stop_cover") diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index ddfb59c6fba..8d94f4214af 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from scsgate.tasks import ( HaltRollerShutterTask, @@ -85,15 +86,15 @@ class SCSGateCover(CoverEntity): """Return if the cover is closed.""" return None - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Move the cover.""" self._scsgate.append_task(RaiseRollerShutterTask(target=self._scs_id)) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" self._scsgate.append_task(LowerRollerShutterTask(target=self._scs_id)) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._scsgate.append_task(HaltRollerShutterTask(target=self._scs_id)) diff --git a/homeassistant/components/slide/cover.py b/homeassistant/components/slide/cover.py index 7841be50977..52fcc3c8da4 100644 --- a/homeassistant/components/slide/cover.py +++ b/homeassistant/components/slide/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.const import ATTR_ID, STATE_CLOSED, STATE_CLOSING, STATE_OPENING @@ -83,21 +84,21 @@ class SlideCover(CoverEntity): pos = int(pos * 100) return pos - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._slide["state"] = STATE_OPENING await self._api.slide_open(self._id) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._slide["state"] = STATE_CLOSING await self._api.slide_close(self._id) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._api.slide_stop(self._id) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs[ATTR_POSITION] / 100 if not self._invert: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 578b13879e8..7e2de17cad3 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import Any from pysmartthings import Attribute, Capability @@ -81,7 +82,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): if Capability.switch_level in device.capabilities: self._attr_supported_features |= CoverEntityFeature.SET_POSITION - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" # Same command for all 3 supported capabilities await self._device.close(set_status=True) @@ -89,7 +90,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): # the entity state ahead of receiving the confirming push updates self.async_schedule_update_ha_state(True) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" # Same for all capability types await self._device.open(set_status=True) diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index abc6d828acc..5777e904597 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -1,6 +1,8 @@ """Support for Soma Covers.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, @@ -59,7 +61,7 @@ class SomaTilt(SomaEntity, CoverEntity): """Return if the cover tilt is closed.""" return self.current_position == 0 - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): @@ -68,7 +70,7 @@ class SomaTilt(SomaEntity, CoverEntity): ) self.set_position(0) - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" response = self.api.set_shade_position(self.device["mac"], -100) if not is_api_response_success(response): @@ -77,7 +79,7 @@ class SomaTilt(SomaEntity, CoverEntity): ) self.set_position(100) - def stop_cover_tilt(self, **kwargs): + def stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): @@ -87,7 +89,7 @@ class SomaTilt(SomaEntity, CoverEntity): # Set cover position to some value where up/down are both enabled self.set_position(50) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" # 0 -> Closed down (api: 100) # 50 -> Fully open (api: 0) @@ -133,7 +135,7 @@ class SomaShade(SomaEntity, CoverEntity): """Return if the cover is closed.""" return self.current_position == 0 - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" response = self.api.set_shade_position(self.device["mac"], 100) if not is_api_response_success(response): @@ -141,7 +143,7 @@ class SomaShade(SomaEntity, CoverEntity): f'Error while closing the cover ({self.name}): {response["msg"]}' ) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" response = self.api.set_shade_position(self.device["mac"], 0) if not is_api_response_success(response): @@ -149,7 +151,7 @@ class SomaShade(SomaEntity, CoverEntity): f'Error while opening the cover ({self.name}): {response["msg"]}' ) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" response = self.api.stop_shade(self.device["mac"]) if not is_api_response_success(response): @@ -159,7 +161,7 @@ class SomaShade(SomaEntity, CoverEntity): # Set cover position to some value where up/down are both enabled self.set_position(50) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" self.current_position = kwargs[ATTR_POSITION] response = self.api.set_shade_position( diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py index d1b09175deb..43c9ca63bb5 100644 --- a/homeassistant/components/somfy_mylink/cover.py +++ b/homeassistant/components/somfy_mylink/cover.py @@ -1,5 +1,6 @@ """Cover Platform for the Somfy MyLink component.""" import logging +from typing import Any from homeassistant.components.cover import CoverDeviceClass, CoverEntity from homeassistant.config_entries import ConfigEntry @@ -87,7 +88,7 @@ class SomfyShade(RestoreEntity, CoverEntity): name=name, ) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._attr_is_closing = True self.async_write_ha_state() @@ -102,7 +103,7 @@ class SomfyShade(RestoreEntity, CoverEntity): self._attr_is_closing = None self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._attr_is_opening = True self.async_write_ha_state() @@ -117,11 +118,11 @@ class SomfyShade(RestoreEntity, CoverEntity): self._attr_is_opening = None self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.somfy_mylink.move_stop(self._target_id) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Complete the initialization.""" await super().async_added_to_hass() # Restore the last state diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 172f1cadf43..b1cc0951259 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging from pprint import pformat +from typing import Any from homeassistant.components.cover import ATTR_POSITION, CoverDeviceClass, CoverEntity from homeassistant.core import HomeAssistant @@ -65,7 +66,7 @@ class SuplaCover(SuplaChannel, CoverEntity): return 100 - state["shut"] return None - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self.async_action("REVEAL", percentage=kwargs.get(ATTR_POSITION)) @@ -76,15 +77,15 @@ class SuplaCover(SuplaChannel, CoverEntity): return None return self.current_cover_position == 0 - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.async_action("REVEAL") - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.async_action("SHUT") - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.async_action("STOP") @@ -100,21 +101,21 @@ class SuplaGateDoor(SuplaChannel, CoverEntity): return state.get("hi") return None - async def async_open_cover(self, **kwargs) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the gate.""" if self.is_closed: await self.async_action("OPEN_CLOSE") - async def async_close_cover(self, **kwargs) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the gate.""" if not self.is_closed: await self.async_action("OPEN_CLOSE") - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the gate.""" await self.async_action("OPEN_CLOSE") - async def async_toggle(self, **kwargs) -> None: + async def async_toggle(self, **kwargs: Any) -> None: """Toggle the gate.""" await self.async_action("OPEN_CLOSE") diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index b64c3f887a0..49c35ac3114 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -1,4 +1,6 @@ """Support for Tellstick covers using Tellstick Net.""" +from typing import Any + from homeassistant.components import cover, tellduslive from homeassistant.components.cover import CoverEntity from homeassistant.config_entries import ConfigEntry @@ -36,17 +38,17 @@ class TelldusLiveCover(TelldusLiveEntity, CoverEntity): """Return the current position of the cover.""" return self.device.is_down - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self.device.down() self._update_callback() - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self.device.up() self._update_callback() - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self.device.stop() self._update_callback() diff --git a/homeassistant/components/tellstick/cover.py b/homeassistant/components/tellstick/cover.py index 65f11bdbae6..7c38741960b 100644 --- a/homeassistant/components/tellstick/cover.py +++ b/homeassistant/components/tellstick/cover.py @@ -1,6 +1,8 @@ """Support for Tellstick covers.""" from __future__ import annotations +from typing import Any + from homeassistant.components.cover import CoverEntity from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -51,15 +53,15 @@ class TellstickCover(TellstickDevice, CoverEntity): """Return True if unable to access real state of the entity.""" return True - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._tellcore_device.down() - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._tellcore_device.up() - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._tellcore_device.stop() diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 53b829cac9e..0c86b1d5d5a 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import voluptuous as vol @@ -180,7 +181,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._is_closing = False self._tilt_value = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._template: self.add_template_attribute( @@ -322,7 +323,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): return supported_features - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._open_script: await self.async_run_script(self._open_script, context=self._context) @@ -336,7 +337,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._position = 100 self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" if self._close_script: await self.async_run_script(self._close_script, context=self._context) @@ -350,12 +351,12 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._position = 0 self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Fire the stop action.""" if self._stop_script: await self.async_run_script(self._stop_script, context=self._context) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Set cover position.""" self._position = kwargs[ATTR_POSITION] await self.async_run_script( @@ -366,7 +367,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): if self._optimistic: self.async_write_ha_state() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover open.""" self._tilt_value = 100 await self.async_run_script( @@ -377,7 +378,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): if self._tilt_optimistic: self.async_write_ha_state() - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover closed.""" self._tilt_value = 0 await self.async_run_script( @@ -388,7 +389,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): if self._tilt_optimistic: self.async_write_ha_state() - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] await self.async_run_script( diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index e75b07b988d..d8b0a97480e 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -360,7 +360,7 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity): ] ) - def set_cover_tilt_position(self, **kwargs): + def set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" if self._tilt is None: raise RuntimeError( diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 26cccfce6ce..1c8a4afcc6f 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -1,6 +1,8 @@ """Support for Velux covers.""" from __future__ import annotations +from typing import Any + from pyvlx import OpeningDevice, Position from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter, Window @@ -89,15 +91,15 @@ class VeluxCover(VeluxEntity, CoverEntity): """Return if the cover is closed.""" return self.node.position.closed - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self.node.close(wait_for_completion=False) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self.node.open(wait_for_completion=False) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position_percent = 100 - kwargs[ATTR_POSITION] @@ -105,23 +107,23 @@ class VeluxCover(VeluxEntity, CoverEntity): Position(position_percent=position_percent), wait_for_completion=False ) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self.node.stop(wait_for_completion=False) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Close cover tilt.""" await self.node.close_orientation(wait_for_completion=False) - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open cover tilt.""" await self.node.open_orientation(wait_for_completion=False) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop cover tilt.""" await self.node.stop_orientation(wait_for_completion=False) - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move cover tilt to a specific position.""" position_percent = 100 - kwargs[ATTR_TILT_POSITION] orientation = Position(position_percent=position_percent) diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py index 2f1a602ca19..5baf495b5fd 100644 --- a/homeassistant/components/vera/cover.py +++ b/homeassistant/components/vera/cover.py @@ -55,7 +55,7 @@ class VeraCover(VeraDevice[veraApi.VeraCurtain], CoverEntity): return 100 return position - def set_cover_position(self, **kwargs) -> None: + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" self.vera_device.set_level(kwargs.get(ATTR_POSITION)) self.schedule_update_ha_state() diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index ad94c224518..6ee4a857d36 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -1,4 +1,6 @@ """Support for WiLight Cover.""" +from typing import Any + from pywilight.const import ( COVER_V1, ITEM_COVER, @@ -86,19 +88,19 @@ class WiLightCover(WiLightDevice, CoverEntity): and wilight_to_hass_position(self._status["position_current"]) == 0 ) - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._client.cover_command(self._index, WL_OPEN) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" await self._client.cover_command(self._index, WL_CLOSE) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = hass_to_wilight_position(kwargs[ATTR_POSITION]) await self._client.set_cover_position(self._index, position) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._client.cover_command(self._index, WL_STOP) diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index 422d9b21e0d..e9946e37815 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -1,4 +1,6 @@ """Support for Xiaomi curtain.""" +from typing import Any + from homeassistant.components.cover import ATTR_POSITION, CoverEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -53,19 +55,19 @@ class XiaomiGenericCover(XiaomiDevice, CoverEntity): """Return if the cover is closed.""" return self.current_cover_position <= 0 - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close the cover.""" self._write_to_hub(self._sid, **{self._data_key: "close"}) - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" self._write_to_hub(self._sid, **{self._data_key: "open"}) - def stop_cover(self, **kwargs): + def stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" self._write_to_hub(self._sid, **{self._data_key: "stop"}) - def set_cover_position(self, **kwargs): + def set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" position = kwargs.get(ATTR_POSITION) if self._data_key == DATA_KEY_PROTO_V2: diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 413e7e9ae09..39f76b6b77f 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations import asyncio import functools import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from zigpy.zcl.foundation import Status @@ -77,7 +77,7 @@ class ZhaCover(ZhaEntity, CoverEntity): self._cover_channel = self.cluster_channels.get(CHANNEL_COVER) self._current_position = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -134,19 +134,19 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = state self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" res = await self._cover_channel.up_open() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_OPENING) - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._cover_channel.down_close() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: self.async_update_state(STATE_CLOSING) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] res = await self._cover_channel.go_to_lift_percentage(100 - new_pos) @@ -155,7 +155,7 @@ class ZhaCover(ZhaEntity, CoverEntity): STATE_CLOSING if new_pos < self._current_position else STATE_OPENING ) - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the window cover.""" res = await self._cover_channel.stop() if not isinstance(res, Exception) and res[1] is Status.SUCCESS: @@ -221,7 +221,7 @@ class Shade(ZhaEntity, CoverEntity): return None return not self._is_open - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -251,7 +251,7 @@ class Shade(ZhaEntity, CoverEntity): self._position = int(value * 100 / 255) self.async_write_ha_state() - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the window cover.""" res = await self._on_off_channel.on() if isinstance(res, Exception) or res[1] != Status.SUCCESS: @@ -261,7 +261,7 @@ class Shade(ZhaEntity, CoverEntity): self._is_open = True self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Close the window cover.""" res = await self._on_off_channel.off() if isinstance(res, Exception) or res[1] != Status.SUCCESS: @@ -271,7 +271,7 @@ class Shade(ZhaEntity, CoverEntity): self._is_open = False self.async_write_ha_state() - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] res = await self._level_channel.move_to_level_with_on_off( @@ -285,7 +285,7 @@ class Shade(ZhaEntity, CoverEntity): self._position = new_pos self.async_write_ha_state() - async def async_stop_cover(self, **kwargs) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" res = await self._level_channel.stop() if isinstance(res, Exception) or res[1] != Status.SUCCESS: @@ -301,7 +301,7 @@ class KeenVent(Shade): _attr_device_class = CoverDeviceClass.DAMPER - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" position = self._position or 100 tasks = [ diff --git a/homeassistant/components/zwave_me/cover.py b/homeassistant/components/zwave_me/cover.py index 7857306ef1f..5e2fdba8608 100644 --- a/homeassistant/components/zwave_me/cover.py +++ b/homeassistant/components/zwave_me/cover.py @@ -53,11 +53,11 @@ class ZWaveMeCover(ZWaveMeEntity, CoverEntity): | CoverEntityFeature.SET_POSITION ) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close cover.""" self.controller.zwave_api.send_command(self.device.id, "exact?level=0") - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open cover.""" self.controller.zwave_api.send_command(self.device.id, "exact?level=99") From d1708861db137a3da1b3d65020144a8bb953fbdf Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 24 Jun 2022 09:39:48 +0300 Subject: [PATCH 665/947] Add config flow for `simplepush` (#73471) * Add config flow for `simplepush` * fix warning message * fix typos * Add importing yaml config * patch integration setup * Add check for errrors raised by the library * fix coverage * Adjust comment and logging message Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + CODEOWNERS | 2 + .../components/simplepush/__init__.py | 38 +++++ .../components/simplepush/config_flow.py | 88 +++++++++++ homeassistant/components/simplepush/const.py | 13 ++ .../components/simplepush/manifest.json | 3 +- homeassistant/components/simplepush/notify.py | 83 +++++++--- .../components/simplepush/strings.json | 21 +++ .../simplepush/translations/en.json | 21 +++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/simplepush/__init__.py | 1 + .../components/simplepush/test_config_flow.py | 144 ++++++++++++++++++ 13 files changed, 394 insertions(+), 25 deletions(-) create mode 100644 homeassistant/components/simplepush/config_flow.py create mode 100644 homeassistant/components/simplepush/const.py create mode 100644 homeassistant/components/simplepush/strings.json create mode 100644 homeassistant/components/simplepush/translations/en.json create mode 100644 tests/components/simplepush/__init__.py create mode 100644 tests/components/simplepush/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 0e7a7324064..928f4d7789e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1065,6 +1065,7 @@ omit = homeassistant/components/shelly/sensor.py homeassistant/components/shelly/utils.py homeassistant/components/sigfox/sensor.py + homeassistant/components/simplepush/__init__.py homeassistant/components/simplepush/notify.py homeassistant/components/simplisafe/__init__.py homeassistant/components/simplisafe/alarm_control_panel.py diff --git a/CODEOWNERS b/CODEOWNERS index 9de118552aa..e2d0cbdaa3f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -929,6 +929,8 @@ build.json @home-assistant/supervisor /tests/components/sighthound/ @robmarkcole /homeassistant/components/signal_messenger/ @bbernhard /tests/components/signal_messenger/ @bbernhard +/homeassistant/components/simplepush/ @engrbm87 +/tests/components/simplepush/ @engrbm87 /homeassistant/components/simplisafe/ @bachya /tests/components/simplisafe/ @bachya /homeassistant/components/sinch/ @bendikrb diff --git a/homeassistant/components/simplepush/__init__.py b/homeassistant/components/simplepush/__init__.py index 8253cfad8b4..c5782258cb7 100644 --- a/homeassistant/components/simplepush/__init__.py +++ b/homeassistant/components/simplepush/__init__.py @@ -1 +1,39 @@ """The simplepush component.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +from homeassistant.helpers.typing import ConfigType + +from .const import DATA_HASS_CONFIG, DOMAIN + +PLATFORMS = [Platform.NOTIFY] + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the simplepush component.""" + + hass.data[DATA_HASS_CONFIG] = config + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up simplepush from a config entry.""" + + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + dict(entry.data), + hass.data[DATA_HASS_CONFIG], + ) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py new file mode 100644 index 00000000000..cf08a341114 --- /dev/null +++ b/homeassistant/components/simplepush/config_flow.py @@ -0,0 +1,88 @@ +"""Config flow for simplepush integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from simplepush import UnknownError, send, send_encrypted +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult + +from .const import ATTR_ENCRYPTED, CONF_DEVICE_KEY, CONF_SALT, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def validate_input(entry: dict[str, str]) -> dict[str, str] | None: + """Validate user input.""" + try: + if CONF_PASSWORD in entry: + send_encrypted( + entry[CONF_DEVICE_KEY], + entry[CONF_PASSWORD], + entry[CONF_PASSWORD], + "HA test", + "Message delivered successfully", + ) + else: + send(entry[CONF_DEVICE_KEY], "HA test", "Message delivered successfully") + except UnknownError: + return {"base": "cannot_connect"} + + return None + + +class SimplePushFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for simplepush.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors: dict[str, str] | None = None + if user_input is not None: + + await self.async_set_unique_id(user_input[CONF_DEVICE_KEY]) + self._abort_if_unique_id_configured() + + self._async_abort_entries_match( + { + CONF_NAME: user_input[CONF_NAME], + } + ) + + if not ( + errors := await self.hass.async_add_executor_job( + validate_input, user_input + ) + ): + return self.async_create_entry( + title=user_input[CONF_NAME], + data=user_input, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_DEVICE_KEY): str, + vol.Required(CONF_NAME, default=DEFAULT_NAME): str, + vol.Inclusive(CONF_PASSWORD, ATTR_ENCRYPTED): str, + vol.Inclusive(CONF_SALT, ATTR_ENCRYPTED): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config: dict[str, str]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + _LOGGER.warning( + "Configuration of the simplepush integration in YAML is deprecated and " + "will be removed in a future release; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + return await self.async_step_user(import_config) diff --git a/homeassistant/components/simplepush/const.py b/homeassistant/components/simplepush/const.py new file mode 100644 index 00000000000..6195a5fd1d9 --- /dev/null +++ b/homeassistant/components/simplepush/const.py @@ -0,0 +1,13 @@ +"""Constants for the simplepush integration.""" + +from typing import Final + +DOMAIN: Final = "simplepush" +DEFAULT_NAME: Final = "simplepush" +DATA_HASS_CONFIG: Final = "simplepush_hass_config" + +ATTR_ENCRYPTED: Final = "encrypted" +ATTR_EVENT: Final = "event" + +CONF_DEVICE_KEY: Final = "device_key" +CONF_SALT: Final = "salt" diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 26321d17aef..7c37546485a 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -3,7 +3,8 @@ "name": "Simplepush", "documentation": "https://www.home-assistant.io/integrations/simplepush", "requirements": ["simplepush==1.1.4"], - "codeowners": [], + "codeowners": ["@engrbm87"], + "config_flow": true, "iot_class": "cloud_polling", "loggers": ["simplepush"] } diff --git a/homeassistant/components/simplepush/notify.py b/homeassistant/components/simplepush/notify.py index 5a83dec69f0..e9cd9813175 100644 --- a/homeassistant/components/simplepush/notify.py +++ b/homeassistant/components/simplepush/notify.py @@ -1,5 +1,10 @@ """Simplepush notification service.""" -from simplepush import send, send_encrypted +from __future__ import annotations + +import logging +from typing import Any + +from simplepush import BadRequest, UnknownError, send, send_encrypted import voluptuous as vol from homeassistant.components.notify import ( @@ -8,14 +13,16 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) +from homeassistant.components.notify.const import ATTR_DATA +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_EVENT, CONF_PASSWORD +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -ATTR_ENCRYPTED = "encrypted" - -CONF_DEVICE_KEY = "device_key" -CONF_SALT = "salt" +from .const import ATTR_ENCRYPTED, ATTR_EVENT, CONF_DEVICE_KEY, CONF_SALT, DOMAIN +# Configuring simplepush under the notify platform will be removed in 2022.9.0 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_DEVICE_KEY): cv.string, @@ -25,34 +32,62 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) +_LOGGER = logging.getLogger(__name__) -def get_service(hass, config, discovery_info=None): + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> SimplePushNotificationService | None: """Get the Simplepush notification service.""" - return SimplePushNotificationService(config) + if discovery_info is None: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + return None + + return SimplePushNotificationService(discovery_info) class SimplePushNotificationService(BaseNotificationService): """Implementation of the notification service for Simplepush.""" - def __init__(self, config): + def __init__(self, config: dict[str, Any]) -> None: """Initialize the Simplepush notification service.""" - self._device_key = config.get(CONF_DEVICE_KEY) - self._event = config.get(CONF_EVENT) - self._password = config.get(CONF_PASSWORD) - self._salt = config.get(CONF_SALT) + self._device_key: str = config[CONF_DEVICE_KEY] + self._event: str | None = config.get(CONF_EVENT) + self._password: str | None = config.get(CONF_PASSWORD) + self._salt: str | None = config.get(CONF_SALT) - def send_message(self, message="", **kwargs): + def send_message(self, message: str, **kwargs: Any) -> None: """Send a message to a Simplepush user.""" title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) - if self._password: - send_encrypted( - self._device_key, - self._password, - self._salt, - title, - message, - event=self._event, - ) - else: - send(self._device_key, title, message, event=self._event) + # event can now be passed in the service data + event = None + if data := kwargs.get(ATTR_DATA): + event = data.get(ATTR_EVENT) + + # use event from config until YAML config is removed + event = event or self._event + + try: + if self._password: + send_encrypted( + self._device_key, + self._password, + self._salt, + title, + message, + event=event, + ) + else: + send(self._device_key, title, message, event=event) + + except BadRequest: + _LOGGER.error("Bad request. Title or message are too long") + except UnknownError: + _LOGGER.error("Failed to send the notification") diff --git a/homeassistant/components/simplepush/strings.json b/homeassistant/components/simplepush/strings.json new file mode 100644 index 00000000000..0031dc32340 --- /dev/null +++ b/homeassistant/components/simplepush/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "[%key:common::config_flow::data::name%]", + "device_key": "The device key of your device", + "event": "The event for the events.", + "password": "The password of the encryption used by your device", + "salt": "The salt used by your device." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/simplepush/translations/en.json b/homeassistant/components/simplepush/translations/en.json new file mode 100644 index 00000000000..a36a3b2b273 --- /dev/null +++ b/homeassistant/components/simplepush/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "device_key": "The device key of your device", + "event": "The event for the events.", + "name": "Name", + "password": "The password of the encryption used by your device", + "salt": "The salt used by your device." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6b26b2a99b7..3c6ad94a21f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -311,6 +311,7 @@ FLOWS = { "shelly", "shopping_list", "sia", + "simplepush", "simplisafe", "skybell", "slack", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b41bf1b223d..a8fd3b39dbf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1430,6 +1430,9 @@ sharkiq==0.0.1 # homeassistant.components.sighthound simplehound==0.3 +# homeassistant.components.simplepush +simplepush==1.1.4 + # homeassistant.components.simplisafe simplisafe-python==2022.06.0 diff --git a/tests/components/simplepush/__init__.py b/tests/components/simplepush/__init__.py new file mode 100644 index 00000000000..fd40577f8fa --- /dev/null +++ b/tests/components/simplepush/__init__.py @@ -0,0 +1 @@ +"""Tests for the simeplush integration.""" diff --git a/tests/components/simplepush/test_config_flow.py b/tests/components/simplepush/test_config_flow.py new file mode 100644 index 00000000000..4636df6b28f --- /dev/null +++ b/tests/components/simplepush/test_config_flow.py @@ -0,0 +1,144 @@ +"""Test Simplepush config flow.""" +from unittest.mock import patch + +import pytest +from simplepush import UnknownError + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.simplepush.const import CONF_DEVICE_KEY, CONF_SALT, DOMAIN +from homeassistant.const import CONF_NAME, CONF_PASSWORD +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +MOCK_CONFIG = { + CONF_DEVICE_KEY: "abc", + CONF_NAME: "simplepush", +} + + +@pytest.fixture(autouse=True) +def simplepush_setup_fixture(): + """Patch simplepush setup entry.""" + with patch( + "homeassistant.components.simplepush.async_setup_entry", return_value=True + ): + yield + + +@pytest.fixture(autouse=True) +def mock_api_request(): + """Patch simplepush api request.""" + with patch("homeassistant.components.simplepush.config_flow.send"), patch( + "homeassistant.components.simplepush.config_flow.send_encrypted" + ): + yield + + +async def test_flow_successful(hass: HomeAssistant) -> None: + """Test user initialized flow with minimum config.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == MOCK_CONFIG + + +async def test_flow_with_password(hass: HomeAssistant) -> None: + """Test user initialized flow with password and salt.""" + mock_config_pass = {**MOCK_CONFIG, CONF_PASSWORD: "password", CONF_SALT: "salt"} + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=mock_config_pass, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == mock_config_pass + + +async def test_flow_user_device_key_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate device key.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="abc", + ) + + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_user_name_already_configured(hass: HomeAssistant) -> None: + """Test user initialized flow with duplicate name.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_CONFIG, + unique_id="abc", + ) + + entry.add_to_hass(hass) + + new_entry = MOCK_CONFIG.copy() + new_entry[CONF_DEVICE_KEY] = "abc1" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_error_on_connection_failure(hass: HomeAssistant) -> None: + """Test when connection to api fails.""" + with patch( + "homeassistant.components.simplepush.config_flow.send", + side_effect=UnknownError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_flow_import(hass: HomeAssistant) -> None: + """Test an import flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=MOCK_CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "simplepush" + assert result["data"] == MOCK_CONFIG From 2f78faa7181f8832a68fb102e95a92feb9c56e59 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jun 2022 01:44:35 -0500 Subject: [PATCH 666/947] Make aiohttp mockers aware of the json loads kwarg (#73939) --- homeassistant/util/aiohttp.py | 8 +++++--- tests/test_util/aiohttp.py | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py index aa1aea1abc3..05ade335a53 100644 --- a/homeassistant/util/aiohttp.py +++ b/homeassistant/util/aiohttp.py @@ -3,13 +3,15 @@ from __future__ import annotations from http import HTTPStatus import io -import json from typing import Any from urllib.parse import parse_qsl from aiohttp import payload, web +from aiohttp.typedefs import JSONDecoder from multidict import CIMultiDict, MultiDict +from homeassistant.helpers.json import json_loads + class MockStreamReader: """Small mock to imitate stream reader.""" @@ -64,9 +66,9 @@ class MockRequest: """Return the body as text.""" return MockStreamReader(self._content) - async def json(self) -> Any: + async def json(self, loads: JSONDecoder = json_loads) -> Any: """Return the body as JSON.""" - return json.loads(self._text) + return loads(self._text) async def post(self) -> MultiDict[str]: """Return POST parameters.""" diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index d69c6d7c290..9ed47109210 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -2,7 +2,6 @@ import asyncio from contextlib import contextmanager from http import HTTPStatus -import json as _json import re from unittest import mock from urllib.parse import parse_qs @@ -14,6 +13,7 @@ from multidict import CIMultiDict from yarl import URL from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE +from homeassistant.helpers.json import json_dumps, json_loads RETYPE = type(re.compile("")) @@ -169,7 +169,7 @@ class AiohttpClientMockResponse: ): """Initialize a fake response.""" if json is not None: - text = _json.dumps(json) + text = json_dumps(json) if text is not None: response = text.encode("utf-8") if response is None: @@ -252,9 +252,9 @@ class AiohttpClientMockResponse: """Return mock response as a string.""" return self.response.decode(encoding, errors=errors) - async def json(self, encoding="utf-8", content_type=None): + async def json(self, encoding="utf-8", content_type=None, loads=json_loads): """Return mock response as a json.""" - return _json.loads(self.response.decode(encoding)) + return loads(self.response.decode(encoding)) def release(self): """Mock release.""" From 6cafcb016fc01713a6717245677c35d8cc7b929e Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jun 2022 10:22:40 +0200 Subject: [PATCH 667/947] Adjust rfxtrx cover type hints (#73947) --- homeassistant/components/rfxtrx/cover.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py index ceb34520b07..6bf49beb89a 100644 --- a/homeassistant/components/rfxtrx/cover.py +++ b/homeassistant/components/rfxtrx/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +from typing import Any import RFXtrx as rfxtrxmod @@ -65,13 +66,13 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): device: rfxtrxmod.RFXtrxDevice, device_id: DeviceTuple, event: rfxtrxmod.RFXtrxEvent = None, - venetian_blind_mode: bool | None = None, + venetian_blind_mode: str | None = None, ) -> None: """Initialize the RFXtrx cover device.""" super().__init__(device, device_id, event) self._venetian_blind_mode = venetian_blind_mode - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore device state.""" await super().async_added_to_hass() @@ -81,7 +82,7 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): self._state = old_state.state == STATE_OPEN @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP @@ -100,11 +101,11 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): return supported_features @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return not self._state - async def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_up05sec) @@ -115,7 +116,7 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): self._state = True self.async_write_ha_state() - async def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs: Any) -> None: """Move the cover down.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_down05sec) @@ -126,27 +127,27 @@ class RfxtrxCover(RfxtrxCommandEntity, CoverEntity): self._state = False self.async_write_ha_state() - async def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" await self._async_send(self._device.send_stop) self._state = True self.async_write_ha_state() - async def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover up.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_up2sec) elif self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_EU: await self._async_send(self._device.send_up05sec) - async def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs: Any) -> None: """Tilt the cover down.""" if self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_US: await self._async_send(self._device.send_down2sec) elif self._venetian_blind_mode == CONST_VENETIAN_BLIND_MODE_EU: await self._async_send(self._device.send_down05sec) - async def async_stop_cover_tilt(self, **kwargs): + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: """Stop the cover tilt.""" await self._async_send(self._device.send_stop) self._state = True From f29cc33fa0652b3d1c1c71081344d7b5b80e174a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jun 2022 08:43:35 -0500 Subject: [PATCH 668/947] Fix selecting entity_ids and device_ids in events with MySQL and PostgreSQL with logbook (#73918) * Fix selecting entity_ids and device_ids in events with MySQL and PostgreSQL Fixes #73818 * add cover --- .../components/logbook/queries/__init__.py | 17 ++--- .../components/logbook/queries/devices.py | 5 +- .../components/logbook/queries/entities.py | 29 +++++--- .../logbook/queries/entities_and_devices.py | 30 ++++---- .../components/logbook/test_websocket_api.py | 72 +++++++++++++++---- 5 files changed, 105 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/logbook/queries/__init__.py b/homeassistant/components/logbook/queries/__init__.py index 3c027823612..0c3a63f990e 100644 --- a/homeassistant/components/logbook/queries/__init__.py +++ b/homeassistant/components/logbook/queries/__init__.py @@ -6,6 +6,7 @@ from datetime import datetime as dt from sqlalchemy.sql.lambdas import StatementLambdaElement from homeassistant.components.recorder.filters import Filters +from homeassistant.helpers.json import json_dumps from .all import all_stmt from .devices import devices_stmt @@ -45,34 +46,34 @@ def statement_for_request( # entities and devices: logbook sends everything for the timeframe for the entities and devices if entity_ids and device_ids: - json_quotable_entity_ids = list(entity_ids) - json_quotable_device_ids = list(device_ids) + json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] + json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return entities_devices_stmt( start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, - json_quotable_device_ids, + json_quoted_entity_ids, + json_quoted_device_ids, ) # entities: logbook sends everything for the timeframe for the entities if entity_ids: - json_quotable_entity_ids = list(entity_ids) + json_quoted_entity_ids = [json_dumps(entity_id) for entity_id in entity_ids] return entities_stmt( start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, + json_quoted_entity_ids, ) # devices: logbook sends everything for the timeframe for the devices assert device_ids is not None - json_quotable_device_ids = list(device_ids) + json_quoted_device_ids = [json_dumps(device_id) for device_id in device_ids] return devices_stmt( start_day, end_day, event_types, - json_quotable_device_ids, + json_quoted_device_ids, ) diff --git a/homeassistant/components/logbook/queries/devices.py b/homeassistant/components/logbook/queries/devices.py index f750c552bc4..e268c2d3ac3 100644 --- a/homeassistant/components/logbook/queries/devices.py +++ b/homeassistant/components/logbook/queries/devices.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Iterable from datetime import datetime as dt +import sqlalchemy from sqlalchemy import lambda_stmt, select from sqlalchemy.orm import Query from sqlalchemy.sql.elements import ClauseList @@ -93,4 +94,6 @@ def apply_event_device_id_matchers( json_quotable_device_ids: Iterable[str], ) -> ClauseList: """Create matchers for the device_ids in the event_data.""" - return DEVICE_ID_IN_EVENT.in_(json_quotable_device_ids) + return DEVICE_ID_IN_EVENT.is_not(None) & sqlalchemy.cast( + DEVICE_ID_IN_EVENT, sqlalchemy.Text() + ).in_(json_quotable_device_ids) diff --git a/homeassistant/components/logbook/queries/entities.py b/homeassistant/components/logbook/queries/entities.py index 4ef96c100d7..3803da6f4e8 100644 --- a/homeassistant/components/logbook/queries/entities.py +++ b/homeassistant/components/logbook/queries/entities.py @@ -36,12 +36,12 @@ def _select_entities_context_ids_sub_query( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], + json_quoted_entity_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities.""" union = union_all( select_events_context_id_subquery(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quotable_entity_ids) + apply_event_entity_id_matchers(json_quoted_entity_ids) ), apply_entities_hints(select(States.context_id)) .filter((States.last_updated > start_day) & (States.last_updated < end_day)) @@ -56,7 +56,7 @@ def _apply_entities_context_union( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], + json_quoted_entity_ids: list[str], ) -> CompoundSelect: """Generate a CTE to find the entity and device context ids and a query to find linked row.""" entities_cte: CTE = _select_entities_context_ids_sub_query( @@ -64,7 +64,7 @@ def _apply_entities_context_union( end_day, event_types, entity_ids, - json_quotable_entity_ids, + json_quoted_entity_ids, ).cte() # We used to optimize this to exclude rows we already in the union with # a States.entity_id.not_in(entity_ids) but that made the @@ -91,19 +91,19 @@ def entities_stmt( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], + json_quoted_entity_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" return lambda_stmt( lambda: _apply_entities_context_union( select_events_without_states(start_day, end_day, event_types).where( - apply_event_entity_id_matchers(json_quotable_entity_ids) + apply_event_entity_id_matchers(json_quoted_entity_ids) ), start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, + json_quoted_entity_ids, ).order_by(Events.time_fired) ) @@ -118,12 +118,19 @@ def states_query_for_entity_ids( def apply_event_entity_id_matchers( - json_quotable_entity_ids: Iterable[str], + json_quoted_entity_ids: Iterable[str], ) -> sqlalchemy.or_: """Create matchers for the entity_id in the event_data.""" - return ENTITY_ID_IN_EVENT.in_( - json_quotable_entity_ids - ) | OLD_ENTITY_ID_IN_EVENT.in_(json_quotable_entity_ids) + return sqlalchemy.or_( + ENTITY_ID_IN_EVENT.is_not(None) + & sqlalchemy.cast(ENTITY_ID_IN_EVENT, sqlalchemy.Text()).in_( + json_quoted_entity_ids + ), + OLD_ENTITY_ID_IN_EVENT.is_not(None) + & sqlalchemy.cast(OLD_ENTITY_ID_IN_EVENT, sqlalchemy.Text()).in_( + json_quoted_entity_ids + ), + ) def apply_entities_hints(query: Query) -> Query: diff --git a/homeassistant/components/logbook/queries/entities_and_devices.py b/homeassistant/components/logbook/queries/entities_and_devices.py index 591918dd653..f22a8392e19 100644 --- a/homeassistant/components/logbook/queries/entities_and_devices.py +++ b/homeassistant/components/logbook/queries/entities_and_devices.py @@ -33,14 +33,14 @@ def _select_entities_device_id_context_ids_sub_query( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], - json_quotable_device_ids: list[str], + json_quoted_entity_ids: list[str], + json_quoted_device_ids: list[str], ) -> CompoundSelect: """Generate a subquery to find context ids for multiple entities and multiple devices.""" union = union_all( select_events_context_id_subquery(start_day, end_day, event_types).where( _apply_event_entity_id_device_id_matchers( - json_quotable_entity_ids, json_quotable_device_ids + json_quoted_entity_ids, json_quoted_device_ids ) ), apply_entities_hints(select(States.context_id)) @@ -56,16 +56,16 @@ def _apply_entities_devices_context_union( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], - json_quotable_device_ids: list[str], + json_quoted_entity_ids: list[str], + json_quoted_device_ids: list[str], ) -> CompoundSelect: devices_entities_cte: CTE = _select_entities_device_id_context_ids_sub_query( start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, - json_quotable_device_ids, + json_quoted_entity_ids, + json_quoted_device_ids, ).cte() # We used to optimize this to exclude rows we already in the union with # a States.entity_id.not_in(entity_ids) but that made the @@ -92,32 +92,32 @@ def entities_devices_stmt( end_day: dt, event_types: tuple[str, ...], entity_ids: list[str], - json_quotable_entity_ids: list[str], - json_quotable_device_ids: list[str], + json_quoted_entity_ids: list[str], + json_quoted_device_ids: list[str], ) -> StatementLambdaElement: """Generate a logbook query for multiple entities.""" stmt = lambda_stmt( lambda: _apply_entities_devices_context_union( select_events_without_states(start_day, end_day, event_types).where( _apply_event_entity_id_device_id_matchers( - json_quotable_entity_ids, json_quotable_device_ids + json_quoted_entity_ids, json_quoted_device_ids ) ), start_day, end_day, event_types, entity_ids, - json_quotable_entity_ids, - json_quotable_device_ids, + json_quoted_entity_ids, + json_quoted_device_ids, ).order_by(Events.time_fired) ) return stmt def _apply_event_entity_id_device_id_matchers( - json_quotable_entity_ids: Iterable[str], json_quotable_device_ids: Iterable[str] + json_quoted_entity_ids: Iterable[str], json_quoted_device_ids: Iterable[str] ) -> sqlalchemy.or_: """Create matchers for the device_id and entity_id in the event_data.""" return apply_event_entity_id_matchers( - json_quotable_entity_ids - ) | apply_event_device_id_matchers(json_quotable_device_ids) + json_quoted_entity_ids + ) | apply_event_device_id_matchers(json_quoted_device_ids) diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index ac6a31202e7..66fbc9b0bca 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -94,7 +94,7 @@ async def _async_mock_entity_with_logbook_platform(hass): return entry -async def _async_mock_device_with_logbook_platform(hass): +async def _async_mock_devices_with_logbook_platform(hass): """Mock an integration that provides a device that are described by the logbook.""" entry = MockConfigEntry(domain="test", data={"first": True}, options=None) entry.add_to_hass(hass) @@ -109,8 +109,18 @@ async def _async_mock_device_with_logbook_platform(hass): model="model", suggested_area="Game Room", ) + device2 = dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:CC")}, + identifiers={("bridgeid", "4567")}, + sw_version="sw-version", + name="device name", + manufacturer="manufacturer", + model="model", + suggested_area="Living Room", + ) await _async_mock_logbook_platform(hass) - return device + return [device, device2] async def test_get_events(hass, hass_ws_client, recorder_mock): @@ -392,10 +402,13 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): ] ) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] + device2 = devices[1] hass.bus.async_fire(EVENT_HOMEASSISTANT_START) hass.bus.async_fire("mock_event", {"device_id": device.id}) + hass.bus.async_fire("mock_event", {"device_id": device2.id}) hass.states.async_set("light.kitchen", STATE_OFF) await hass.async_block_till_done() @@ -423,7 +436,7 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): "id": 1, "type": "logbook/get_events", "start_time": now.isoformat(), - "device_ids": [device.id], + "device_ids": [device.id, device2.id], } ) response = await client.receive_json() @@ -431,10 +444,13 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): assert response["id"] == 1 results = response["result"] - assert len(results) == 1 + assert len(results) == 2 assert results[0]["name"] == "device name" assert results[0]["message"] == "is on fire" assert isinstance(results[0]["when"], float) + assert results[1]["name"] == "device name" + assert results[1]["message"] == "is on fire" + assert isinstance(results[1]["when"], float) await client.send_json( { @@ -470,17 +486,20 @@ async def test_get_events_with_device_ids(hass, hass_ws_client, recorder_mock): assert response["id"] == 3 results = response["result"] - assert len(results) == 4 + assert len(results) == 5 assert results[0]["message"] == "started" assert results[1]["name"] == "device name" assert results[1]["message"] == "is on fire" assert isinstance(results[1]["when"], float) - assert results[2]["entity_id"] == "light.kitchen" - assert results[2]["state"] == "on" + assert results[2]["name"] == "device name" + assert results[2]["message"] == "is on fire" assert isinstance(results[2]["when"], float) assert results[3]["entity_id"] == "light.kitchen" - assert results[3]["state"] == "off" + assert results[3]["state"] == "on" assert isinstance(results[3]["when"], float) + assert results[4]["entity_id"] == "light.kitchen" + assert results[4]["state"] == "off" + assert isinstance(results[4]["when"], float) @patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) @@ -1731,7 +1750,9 @@ async def test_subscribe_unsubscribe_logbook_stream_device( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] + device2 = devices[1] await hass.async_block_till_done() init_count = sum(hass.bus.async_listeners().values()) @@ -1743,7 +1764,7 @@ async def test_subscribe_unsubscribe_logbook_stream_device( "id": 7, "type": "logbook/event_stream", "start_time": now.isoformat(), - "device_ids": [device.id], + "device_ids": [device.id, device2.id], } ) @@ -1775,6 +1796,29 @@ async def test_subscribe_unsubscribe_logbook_stream_device( {"domain": "test", "message": "is on fire", "name": "device name", "when": ANY} ] + for _ in range(3): + hass.bus.async_fire("mock_event", {"device_id": device.id}) + hass.bus.async_fire("mock_event", {"device_id": device2.id}) + await hass.async_block_till_done() + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + ] + await websocket_client.send_json( {"id": 8, "type": "unsubscribe_events", "subscription": 7} ) @@ -1950,7 +1994,8 @@ async def test_live_stream_with_one_second_commit_interval( for comp in ("homeassistant", "logbook", "automation", "script") ] ) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] await hass.async_block_till_done() init_count = sum(hass.bus.async_listeners().values()) @@ -2143,7 +2188,8 @@ async def test_recorder_is_far_behind(hass, recorder_mock, hass_ws_client, caplo ] ) await async_wait_recording_done(hass) - device = await _async_mock_device_with_logbook_platform(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] await async_wait_recording_done(hass) # Block the recorder queue From b880a05e4533a63222e19b7bc9520e9b1620f4b2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Fri, 24 Jun 2022 16:35:38 +0200 Subject: [PATCH 669/947] Fix type hints in zha remaining channels (#73778) * Fix hvac channel type hints * Fix security channel type hints * Fix homeautomation channel type hints * Fix type hints in zha base channel * Adjust select entity * Remove unused arg --- .../components/zha/core/channels/__init__.py | 2 +- .../components/zha/core/channels/base.py | 9 +++++---- .../zha/core/channels/homeautomation.py | 6 +++++- .../components/zha/core/channels/hvac.py | 1 + .../components/zha/core/channels/security.py | 17 ++++------------- homeassistant/components/zha/select.py | 1 + mypy.ini | 12 ------------ script/hassfest/mypy_config.py | 4 ---- 8 files changed, 17 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 33143821f9c..2da7462f3eb 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -370,7 +370,7 @@ class ChannelPool: return [self.all_channels[chan_id] for chan_id in (available - claimed)] @callback - def zha_send_event(self, event_data: dict[str, str | int]) -> None: + def zha_send_event(self, event_data: dict[str, Any]) -> None: """Relay events to hass.""" self._channels.zha_send_event( { diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index de943ebac16..ae5980cd630 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -137,7 +137,7 @@ class ZigbeeChannel(LogMixin): self.value_attribute = attr self._status = ChannelStatus.CREATED self._cluster.add_listener(self) - self.data_cache = {} + self.data_cache: dict[str, Enum] = {} @property def id(self) -> str: @@ -278,7 +278,7 @@ class ZigbeeChannel(LogMixin): ) def _configure_reporting_status( - self, attrs: dict[int | str, tuple], res: list | tuple + self, attrs: dict[int | str, tuple[int, int, float | int]], res: list | tuple ) -> None: """Parse configure reporting result.""" if isinstance(res, (Exception, ConfigureReportingResponseRecord)): @@ -304,10 +304,10 @@ class ZigbeeChannel(LogMixin): for r in res if r.status != Status.SUCCESS ] - attrs = {self.cluster.attributes.get(r, [r])[0] for r in attrs} + attributes = {self.cluster.attributes.get(r, [r])[0] for r in attrs} self.debug( "Successfully configured reporting for '%s' on '%s' cluster", - attrs - set(failed), + attributes - set(failed), self.name, ) self.debug( @@ -393,6 +393,7 @@ class ZigbeeChannel(LogMixin): def zha_send_event(self, command: str, arg: list | dict | CommandSchema) -> None: """Relay events to hass.""" + args: list | dict if isinstance(arg, CommandSchema): args = [a for a in arg if a is not None] params = arg.as_dict() diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index 52036706f19..69295ef6f81 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -158,7 +158,11 @@ class ElectricalMeasurementChannel(ZigbeeChannel): return None meas_type = self.MeasurementType(meas_type) - return ", ".join(m.name for m in self.MeasurementType if m in meas_type) + return ", ".join( + m.name + for m in self.MeasurementType + if m in meas_type and m.name is not None + ) @registries.ZIGBEE_CHANNEL_REGISTRY.register( diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 53f18a0fd0f..4b4909299b1 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -293,6 +293,7 @@ class ThermostatChannel(ZigbeeChannel): return bool(self.occupancy) except ZigbeeException as ex: self.debug("Couldn't read 'occupancy' attribute: %s", ex) + return None async def write_attributes(self, data, **kwargs): """Write attributes helper.""" diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 789e792e149..41e65019415 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -199,26 +199,17 @@ class IasAce(ZigbeeChannel): def _emergency(self) -> None: """Handle the IAS ACE emergency command.""" - self._set_alarm( - AceCluster.AlarmStatus.Emergency, - IAS_ACE_EMERGENCY, - ) + self._set_alarm(AceCluster.AlarmStatus.Emergency) def _fire(self) -> None: """Handle the IAS ACE fire command.""" - self._set_alarm( - AceCluster.AlarmStatus.Fire, - IAS_ACE_FIRE, - ) + self._set_alarm(AceCluster.AlarmStatus.Fire) def _panic(self) -> None: """Handle the IAS ACE panic command.""" - self._set_alarm( - AceCluster.AlarmStatus.Emergency_Panic, - IAS_ACE_PANIC, - ) + self._set_alarm(AceCluster.AlarmStatus.Emergency_Panic) - def _set_alarm(self, status: AceCluster.PanelStatus, event: str) -> None: + def _set_alarm(self, status: AceCluster.AlarmStatus) -> None: """Set the specified alarm status.""" self.alarm_status = status self.armed_state = AceCluster.PanelStatus.In_Alarm diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 83bbcdca580..6d202ccceb2 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -64,6 +64,7 @@ class ZHAEnumSelectEntity(ZhaEntity, SelectEntity): """Representation of a ZHA select entity.""" _attr_entity_category = EntityCategory.CONFIG + _attr_name: str _enum: type[Enum] def __init__( diff --git a/mypy.ini b/mypy.ini index 8dca7403eff..e2e91ef921c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2996,18 +2996,6 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.switch] ignore_errors = true -[mypy-homeassistant.components.zha.core.channels.base] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.homeautomation] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.hvac] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.channels.security] -ignore_errors = true - [mypy-homeassistant.components.zha.core.device] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index f319ccb5235..49cf3b7b65a 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -144,10 +144,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.light", "homeassistant.components.xiaomi_miio.sensor", "homeassistant.components.xiaomi_miio.switch", - "homeassistant.components.zha.core.channels.base", - "homeassistant.components.zha.core.channels.homeautomation", - "homeassistant.components.zha.core.channels.hvac", - "homeassistant.components.zha.core.channels.security", "homeassistant.components.zha.core.device", "homeassistant.components.zha.core.discovery", "homeassistant.components.zha.core.gateway", From 1866a1e92561dba2c74fd1b8db236ace83c5cbb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jun 2022 09:59:01 -0500 Subject: [PATCH 670/947] Handle non-str keys when storing json data (#73958) --- homeassistant/util/json.py | 11 +++++++++-- tests/util/test_json.py | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 8a9663bb95d..d69a4106728 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -45,6 +45,13 @@ def load_json(filename: str, default: list | dict | None = None) -> list | dict: return {} if default is None else default +def _orjson_encoder(data: Any) -> str: + """JSON encoder that uses orjson.""" + return orjson.dumps( + data, option=orjson.OPT_INDENT_2 | orjson.OPT_NON_STR_KEYS + ).decode("utf-8") + + def save_json( filename: str, data: list | dict, @@ -62,8 +69,8 @@ def save_json( if encoder: json_data = json.dumps(data, indent=2, cls=encoder) else: - dump = orjson.dumps - json_data = orjson.dumps(data, option=orjson.OPT_INDENT_2).decode("utf-8") + dump = _orjson_encoder + json_data = _orjson_encoder(data) except TypeError as error: msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data, dump=dump))}" _LOGGER.error(msg) diff --git a/tests/util/test_json.py b/tests/util/test_json.py index abf47b0bc53..9974cbb9628 100644 --- a/tests/util/test_json.py +++ b/tests/util/test_json.py @@ -52,6 +52,14 @@ def test_save_and_load(): assert data == TEST_JSON_A +def test_save_and_load_int_keys(): + """Test saving and loading back stringifies the keys.""" + fname = _path_for("test1") + save_json(fname, {1: "a", 2: "b"}) + data = load_json(fname) + assert data == {"1": "a", "2": "b"} + + def test_save_and_load_private(): """Test we can load private files and that they are protected.""" fname = _path_for("test2") From 44da543ca06eaf26c4fe73cf68f34990c60432ab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jun 2022 09:59:41 -0500 Subject: [PATCH 671/947] Bump nexia to 2.0.0 (#73935) --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 4bae2d9a15d..1cb410ad1a8 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia/American Standard/Trane", - "requirements": ["nexia==1.0.2"], + "requirements": ["nexia==2.0.0"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 542fcdbb540..339c0a8da88 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1083,7 +1083,7 @@ nettigo-air-monitor==1.3.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==1.0.2 +nexia==2.0.0 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a8fd3b39dbf..33763e0b892 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -748,7 +748,7 @@ netmap==0.7.0.2 nettigo-air-monitor==1.3.0 # homeassistant.components.nexia -nexia==1.0.2 +nexia==2.0.0 # homeassistant.components.discord nextcord==2.0.0a8 From 57efa9569c2bb744f167c4e07b0f7600702fc091 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 24 Jun 2022 17:05:36 -0400 Subject: [PATCH 672/947] Cache is_supported for Google entities (#73936) --- .../components/google_assistant/helpers.py | 13 ++++++- .../google_assistant/test_helpers.py | 35 ++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 2ed91b42ec6..932611390eb 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -90,6 +90,7 @@ class AbstractConfig(ABC): self._local_sdk_active = False self._local_last_active: datetime | None = None self._local_sdk_version_warn = False + self.is_supported_cache: dict[str, tuple[int | None, bool]] = {} async def async_initialize(self): """Perform async initialization of config.""" @@ -541,7 +542,17 @@ class GoogleEntity: @callback def is_supported(self) -> bool: """Return if the entity is supported by Google.""" - return bool(self.traits()) + features: int | None = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) + + result = self.config.is_supported_cache.get(self.entity_id) + + if result is None or result[0] != features: + result = self.config.is_supported_cache[self.entity_id] = ( + features, + bool(self.traits()), + ) + + return result[1] @callback def might_2fa(self) -> bool: diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 1ab573baf2a..8898fc7ef76 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -327,7 +327,9 @@ async def test_sync_entities_all(agents, result): def test_supported_features_string(caplog): """Test bad supported features.""" entity = helpers.GoogleEntity( - None, None, State("test.entity_id", "on", {"supported_features": "invalid"}) + None, + MockConfig(), + State("test.entity_id", "on", {"supported_features": "invalid"}), ) assert entity.is_supported() is False assert "Entity test.entity_id contains invalid supported_features value invalid" @@ -427,3 +429,34 @@ async def test_config_local_sdk_warn_version(hass, hass_client, caplog, version) f"Local SDK version is too old ({version}), check documentation on how " "to update to the latest version" ) in caplog.text + + +def test_is_supported_cached(): + """Test is_supported is cached.""" + config = MockConfig() + + def entity(features: int): + return helpers.GoogleEntity( + None, + config, + State("test.entity_id", "on", {"supported_features": features}), + ) + + with patch( + "homeassistant.components.google_assistant.helpers.GoogleEntity.traits", + return_value=[1], + ) as mock_traits: + assert entity(1).is_supported() is True + assert len(mock_traits.mock_calls) == 1 + + # Supported feature changes, so we calculate again + assert entity(2).is_supported() is True + assert len(mock_traits.mock_calls) == 2 + + mock_traits.reset_mock() + + # Supported feature is same, so we do not calculate again + mock_traits.side_effect = ValueError + + assert entity(2).is_supported() is True + assert len(mock_traits.mock_calls) == 0 From 32e0d9f47c47c1caea81cfe56150531beeafb3f7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 24 Jun 2022 16:28:26 -0500 Subject: [PATCH 673/947] Speed up generation of template states (#73728) * Speed up generation of template states * tweak * cache * cache hash * weaken * Revert "weaken" This reverts commit 4856f500807c21aa1c9333d44fd53555bae7bb82. * lower cache size as it tends to be the same ones over and over * lower cache size as it tends to be the same ones over and over * lower cache size as it tends to be the same ones over and over * cover * Update homeassistant/helpers/template.py Co-authored-by: Paulus Schoutsen * id reuse is possible * account for iterting all sensors Co-authored-by: Paulus Schoutsen --- homeassistant/core.py | 7 +++++ homeassistant/helpers/template.py | 52 +++++++++++++++++++++++++++---- tests/helpers/test_template.py | 10 ++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index b8f509abef3..b568ee72689 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1110,6 +1110,13 @@ class State: self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None + def __hash__(self) -> int: + """Make the state hashable. + + State objects are effectively immutable. + """ + return hash((id(self), self.last_updated)) + @property def name(self) -> str: """Name of this state.""" diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index ac5b4c8119f..eca76a8c7bc 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -9,7 +9,7 @@ from collections.abc import Callable, Generator, Iterable from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta -from functools import partial, wraps +from functools import cache, lru_cache, partial, wraps import json import logging import math @@ -98,6 +98,9 @@ template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) +CACHED_TEMPLATE_STATES = 512 +EVAL_CACHE_SIZE = 512 + @bind_hass def attach(hass: HomeAssistant, obj: Any) -> None: @@ -222,6 +225,9 @@ def _false(arg: str) -> bool: return False +_cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval) + + class RenderInfo: """Holds information about a template render.""" @@ -318,6 +324,7 @@ class Template: "_exc_info", "_limited", "_strict", + "_hash_cache", ) def __init__(self, template, hass=None): @@ -333,6 +340,7 @@ class Template: self._exc_info = None self._limited = None self._strict = None + self._hash_cache: int = hash(self.template) @property def _env(self) -> TemplateEnvironment: @@ -421,7 +429,7 @@ class Template: def _parse_result(self, render_result: str) -> Any: """Parse the result.""" try: - result = literal_eval(render_result) + result = _cached_literal_eval(render_result) if type(result) in RESULT_WRAPPERS: result = RESULT_WRAPPERS[type(result)]( @@ -618,16 +626,30 @@ class Template: def __hash__(self) -> int: """Hash code for template.""" - return hash(self.template) + return self._hash_cache def __repr__(self) -> str: """Representation of Template.""" return 'Template("' + self.template + '")' +@cache +def _domain_states(hass: HomeAssistant, name: str) -> DomainStates: + return DomainStates(hass, name) + + +def _readonly(*args: Any, **kwargs: Any) -> Any: + """Raise an exception when a states object is modified.""" + raise RuntimeError(f"Cannot modify template States object: {args} {kwargs}") + + class AllStates: """Class to expose all HA states as attributes.""" + __setitem__ = _readonly + __delitem__ = _readonly + __slots__ = ("_hass",) + def __init__(self, hass: HomeAssistant) -> None: """Initialize all states.""" self._hass = hass @@ -643,7 +665,7 @@ class AllStates: if not valid_entity_id(f"{name}.entity"): raise TemplateError(f"Invalid domain name '{name}'") - return DomainStates(self._hass, name) + return _domain_states(self._hass, name) # Jinja will try __getitem__ first and it avoids the need # to call is_safe_attribute @@ -682,6 +704,11 @@ class AllStates: class DomainStates: """Class to expose a specific HA domain as attributes.""" + __slots__ = ("_hass", "_domain") + + __setitem__ = _readonly + __delitem__ = _readonly + def __init__(self, hass: HomeAssistant, domain: str) -> None: """Initialize the domain states.""" self._hass = hass @@ -727,6 +754,9 @@ class TemplateStateBase(State): _state: State + __setitem__ = _readonly + __delitem__ = _readonly + # Inheritance is done so functions that check against State keep working # pylint: disable=super-init-not-called def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: @@ -865,10 +895,15 @@ def _collect_state(hass: HomeAssistant, entity_id: str) -> None: entity_collect.entities.add(entity_id) +@lru_cache(maxsize=CACHED_TEMPLATE_STATES) +def _template_state_no_collect(hass: HomeAssistant, state: State) -> TemplateState: + return TemplateState(hass, state, collect=False) + + def _state_generator(hass: HomeAssistant, domain: str | None) -> Generator: """State generator for a domain or all states.""" for state in sorted(hass.states.async_all(domain), key=attrgetter("entity_id")): - yield TemplateState(hass, state, collect=False) + yield _template_state_no_collect(hass, state) def _get_state_if_valid(hass: HomeAssistant, entity_id: str) -> TemplateState | None: @@ -882,6 +917,11 @@ def _get_state(hass: HomeAssistant, entity_id: str) -> TemplateState | None: return _get_template_state_from_state(hass, entity_id, hass.states.get(entity_id)) +@lru_cache(maxsize=CACHED_TEMPLATE_STATES) +def _template_state(hass: HomeAssistant, state: State) -> TemplateState: + return TemplateState(hass, state) + + def _get_template_state_from_state( hass: HomeAssistant, entity_id: str, state: State | None ) -> TemplateState | None: @@ -890,7 +930,7 @@ def _get_template_state_from_state( # access to the state properties in the state wrapper. _collect_state(hass, entity_id) return None - return TemplateState(hass, state) + return _template_state(hass, state) def _resolve_state( diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index a1fd3e73f59..69a3af22759 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -18,6 +18,7 @@ from homeassistant.const import ( MASS_GRAMS, PRESSURE_PA, SPEED_KILOMETERS_PER_HOUR, + STATE_ON, TEMP_CELSIUS, VOLUME_LITERS, ) @@ -3831,3 +3832,12 @@ async def test_undefined_variable(hass, caplog): "Template variable warning: 'no_such_variable' is undefined when rendering '{{ no_such_variable }}'" in caplog.text ) + + +async def test_template_states_blocks_setitem(hass): + """Test we cannot setitem on TemplateStates.""" + hass.states.async_set("light.new", STATE_ON) + state = hass.states.get("light.new") + template_state = template.TemplateState(hass, state, True) + with pytest.raises(RuntimeError): + template_state["any"] = "any" From 0461eda83b39d07308a240b416066edd8704ac9f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jun 2022 00:34:49 +0200 Subject: [PATCH 674/947] Adjust demo cover position methods (#73944) --- homeassistant/components/demo/cover.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 79a51406857..5c908dfa33a 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -205,9 +205,9 @@ class DemoCover(CoverEntity): self._listen_cover_tilt() self._requested_closing_tilt = False - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" - position = kwargs.get(ATTR_POSITION) + position: int = kwargs[ATTR_POSITION] self._set_position = round(position, -1) if self._position == position: return @@ -215,9 +215,9 @@ class DemoCover(CoverEntity): self._listen_cover() self._requested_closing = position < self._position - async def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover til to a specific position.""" - tilt_position = kwargs.get(ATTR_TILT_POSITION) + tilt_position: int = kwargs[ATTR_TILT_POSITION] self._set_tilt_position = round(tilt_position, -1) if self._tilt_position == tilt_position: return From 15b756417145bbafc0a462096acef992e580e6ae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jun 2022 00:48:27 +0200 Subject: [PATCH 675/947] Fix coverage issue in CI (#73959) * Fix coverage issue in CI * Adjust to latest findings Co-authored-by: Franck Nijhof --- .github/workflows/ci.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 49f62f0943d..fcd879d7512 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -796,7 +796,7 @@ jobs: --dist=loadfile \ --test-group-count ${{ needs.changes.outputs.test_group_count }} \ --test-group=${{ matrix.group }} \ - --cov homeassistant \ + --cov="homeassistant" \ --cov-report=xml \ -o console_output_style=count \ -p no:sugar \ @@ -818,8 +818,8 @@ jobs: -qq \ --timeout=9 \ --durations=10 \ - -n auto \ - --cov homeassistant.components.${{ matrix.group }} \ + -n 0 \ + --cov="homeassistant.components.${{ matrix.group }}" \ --cov-report=xml \ --cov-report=term-missing \ -o console_output_style=count \ From 9b88b77b6619a89c8aa641bf59331666072bf900 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jun 2022 00:55:01 +0200 Subject: [PATCH 676/947] Use attributes in wilight (#73898) Co-authored-by: Franck Nijhof --- homeassistant/components/wilight/__init__.py | 52 ++++++---------- homeassistant/components/wilight/cover.py | 21 ++++--- homeassistant/components/wilight/fan.py | 22 +++---- homeassistant/components/wilight/light.py | 60 ++++++++++--------- .../components/wilight/parent_device.py | 32 +++++----- 5 files changed, 88 insertions(+), 99 deletions(-) diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py index 932ce1538bf..2cdcf20c1ea 100644 --- a/homeassistant/components/wilight/__init__.py +++ b/homeassistant/components/wilight/__init__.py @@ -1,5 +1,9 @@ """The WiLight integration.""" +from typing import Any + +from pywilight.wilight_device import Device as PyWiLightDevice + from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback @@ -51,61 +55,43 @@ class WiLightDevice(Entity): Contains the common logic for WiLight entities. """ - def __init__(self, api_device, index, item_name): + _attr_should_poll = False + + def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: """Initialize the device.""" # WiLight specific attributes for every component type self._device_id = api_device.device_id - self._sw_version = api_device.swversion self._client = api_device.client - self._model = api_device.model - self._name = item_name self._index = index - self._unique_id = f"{self._device_id}_{self._index}" - self._status = {} + self._status: dict[str, Any] = {} - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return a name for this WiLight item.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID for this WiLight item.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - name=self._name, - identifiers={(DOMAIN, self._unique_id)}, - model=self._model, + self._attr_name = item_name + self._attr_unique_id = f"{self._device_id}_{index}" + self._attr_device_info = DeviceInfo( + name=item_name, + identifiers={(DOMAIN, self._attr_unique_id)}, + model=api_device.model, manufacturer="WiLight", - sw_version=self._sw_version, + sw_version=api_device.swversion, via_device=(DOMAIN, self._device_id), ) @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return bool(self._client.is_connected) @callback - def handle_event_callback(self, states): + def handle_event_callback(self, states: dict[str, Any]) -> None: """Propagate changes through ha.""" self._status = states self.async_write_ha_state() - async def async_update(self): + async def async_update(self) -> None: """Synchronize state with api_device.""" await self._client.status(self._index) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register update callback.""" self._client.register_status_callback(self.handle_event_callback, self._index) await self._client.status(self._index) diff --git a/homeassistant/components/wilight/cover.py b/homeassistant/components/wilight/cover.py index 6ee4a857d36..cd0a3cc21ac 100644 --- a/homeassistant/components/wilight/cover.py +++ b/homeassistant/components/wilight/cover.py @@ -1,4 +1,6 @@ """Support for WiLight Cover.""" +from __future__ import annotations + from typing import Any from pywilight.const import ( @@ -18,16 +20,18 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, WiLightDevice +from .parent_device import WiLightParent async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WiLight covers from a config entry.""" - parent = hass.data[DOMAIN][entry.entry_id] + parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] # Handle a discovered WiLight device. entities = [] + assert parent.api for item in parent.api.items: if item["type"] != ITEM_COVER: continue @@ -35,18 +39,17 @@ async def async_setup_entry( item_name = item["name"] if item["sub_type"] != COVER_V1: continue - entity = WiLightCover(parent.api, index, item_name) - entities.append(entity) + entities.append(WiLightCover(parent.api, index, item_name)) async_add_entities(entities) -def wilight_to_hass_position(value): +def wilight_to_hass_position(value: int) -> int: """Convert wilight position 1..255 to hass format 0..100.""" return min(100, round((value * 100) / 255)) -def hass_to_wilight_position(value): +def hass_to_wilight_position(value: int) -> int: """Convert hass position 0..100 to wilight 1..255 scale.""" return min(255, round((value * 255) / 100)) @@ -55,7 +58,7 @@ class WiLightCover(WiLightDevice, CoverEntity): """Representation of a WiLights cover.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -65,21 +68,21 @@ class WiLightCover(WiLightDevice, CoverEntity): return None @property - def is_opening(self): + def is_opening(self) -> bool | None: """Return if the cover is opening or not.""" if "motor_state" not in self._status: return None return self._status["motor_state"] == WL_OPENING @property - def is_closing(self): + def is_closing(self) -> bool | None: """Return if the cover is closing or not.""" if "motor_state" not in self._status: return None return self._status["motor_state"] == WL_CLOSING @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if "motor_state" not in self._status or "position_current" not in self._status: return None diff --git a/homeassistant/components/wilight/fan.py b/homeassistant/components/wilight/fan.py index a93e0eb9447..c598e6db397 100644 --- a/homeassistant/components/wilight/fan.py +++ b/homeassistant/components/wilight/fan.py @@ -13,6 +13,7 @@ from pywilight.const import ( WL_SPEED_LOW, WL_SPEED_MEDIUM, ) +from pywilight.wilight_device import Device as PyWiLightDevice from homeassistant.components.fan import DIRECTION_FORWARD, FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry @@ -24,6 +25,7 @@ from homeassistant.util.percentage import ( ) from . import DOMAIN, WiLightDevice +from .parent_device import WiLightParent ORDERED_NAMED_FAN_SPEEDS = [WL_SPEED_LOW, WL_SPEED_MEDIUM, WL_SPEED_HIGH] @@ -32,10 +34,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WiLight lights from a config entry.""" - parent = hass.data[DOMAIN][entry.entry_id] + parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] # Handle a discovered WiLight device. entities = [] + assert parent.api for item in parent.api.items: if item["type"] != ITEM_FAN: continue @@ -43,8 +46,7 @@ async def async_setup_entry( item_name = item["name"] if item["sub_type"] != FAN_V1: continue - entity = WiLightFan(parent.api, index, item_name) - entities.append(entity) + entities.append(WiLightFan(parent.api, index, item_name)) async_add_entities(entities) @@ -52,19 +54,16 @@ async def async_setup_entry( class WiLightFan(WiLightDevice, FanEntity): """Representation of a WiLights fan.""" + _attr_icon = "mdi:fan" + _attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS) _attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.DIRECTION - def __init__(self, api_device, index, item_name): + def __init__(self, api_device: PyWiLightDevice, index: str, item_name: str) -> None: """Initialize the device.""" super().__init__(api_device, index, item_name) # Initialize the WiLights fan. self._direction = WL_DIRECTION_FORWARD - @property - def icon(self): - """Return the icon of device based on its type.""" - return "mdi:fan" - @property def is_on(self) -> bool: """Return true if device is on.""" @@ -83,11 +82,6 @@ class WiLightFan(WiLightDevice, FanEntity): return None return ordered_list_item_to_percentage(ORDERED_NAMED_FAN_SPEEDS, wl_speed) - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return len(ORDERED_NAMED_FAN_SPEEDS) - @property def current_direction(self) -> str: """Return the current direction of the fan.""" diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py index 10ff79fe60d..ea9e19dcb30 100644 --- a/homeassistant/components/wilight/light.py +++ b/homeassistant/components/wilight/light.py @@ -1,5 +1,10 @@ """Support for WiLight lights.""" +from __future__ import annotations + +from typing import Any + from pywilight.const import ITEM_LIGHT, LIGHT_COLOR, LIGHT_DIMMER, LIGHT_ON_OFF +from pywilight.wilight_device import Device as PyWiLightDevice from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -12,25 +17,23 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN, WiLightDevice +from .parent_device import WiLightParent -def entities_from_discovered_wilight(hass, api_device): +def entities_from_discovered_wilight(api_device: PyWiLightDevice) -> list[LightEntity]: """Parse configuration and add WiLight light entities.""" - entities = [] + entities: list[LightEntity] = [] for item in api_device.items: if item["type"] != ITEM_LIGHT: continue index = item["index"] item_name = item["name"] if item["sub_type"] == LIGHT_ON_OFF: - entity = WiLightLightOnOff(api_device, index, item_name) + entities.append(WiLightLightOnOff(api_device, index, item_name)) elif item["sub_type"] == LIGHT_DIMMER: - entity = WiLightLightDimmer(api_device, index, item_name) + entities.append(WiLightLightDimmer(api_device, index, item_name)) elif item["sub_type"] == LIGHT_COLOR: - entity = WiLightLightColor(api_device, index, item_name) - else: - continue - entities.append(entity) + entities.append(WiLightLightColor(api_device, index, item_name)) return entities @@ -39,10 +42,11 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up WiLight lights from a config entry.""" - parent = hass.data[DOMAIN][entry.entry_id] + parent: WiLightParent = hass.data[DOMAIN][entry.entry_id] # Handle a discovered WiLight device. - entities = entities_from_discovered_wilight(hass, parent.api) + assert parent.api + entities = entities_from_discovered_wilight(parent.api) async_add_entities(entities) @@ -53,15 +57,15 @@ class WiLightLightOnOff(WiLightDevice, LightEntity): _attr_supported_color_modes = {ColorMode.ONOFF} @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._status.get("on") - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._client.turn_off(self._index) @@ -73,16 +77,16 @@ class WiLightLightDimmer(WiLightDevice, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(self._status.get("brightness", 0)) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._status.get("on") - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on,set brightness if needed.""" # Dimmer switches use a range of [0, 255] to control # brightness. Level 255 might mean to set it to previous value @@ -92,27 +96,27 @@ class WiLightLightDimmer(WiLightDevice, LightEntity): else: await self._client.turn_on(self._index) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._client.turn_off(self._index) -def wilight_to_hass_hue(value): +def wilight_to_hass_hue(value: int) -> float: """Convert wilight hue 1..255 to hass 0..360 scale.""" return min(360, round((value * 360) / 255, 3)) -def hass_to_wilight_hue(value): +def hass_to_wilight_hue(value: float) -> int: """Convert hass hue 0..360 to wilight 1..255 scale.""" return min(255, round((value * 255) / 360)) -def wilight_to_hass_saturation(value): +def wilight_to_hass_saturation(value: int) -> float: """Convert wilight saturation 1..255 to hass 0..100 scale.""" return min(100, round((value * 100) / 255, 3)) -def hass_to_wilight_saturation(value): +def hass_to_wilight_saturation(value: float) -> int: """Convert hass saturation 0..100 to wilight 1..255 scale.""" return min(255, round((value * 255) / 100)) @@ -124,24 +128,24 @@ class WiLightLightColor(WiLightDevice, LightEntity): _attr_supported_color_modes = {ColorMode.HS} @property - def brightness(self): + def brightness(self) -> int: """Return the brightness of this light between 0..255.""" return int(self._status.get("brightness", 0)) @property - def hs_color(self): + def hs_color(self) -> tuple[float, float]: """Return the hue and saturation color value [float, float].""" - return [ + return ( wilight_to_hass_hue(int(self._status.get("hue", 0))), wilight_to_hass_saturation(int(self._status.get("saturation", 0))), - ] + ) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if device is on.""" return self._status.get("on") - async def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on,set brightness if needed.""" # Brightness use a range of [0, 255] to control # Hue use a range of [0, 360] to control @@ -161,6 +165,6 @@ class WiLightLightColor(WiLightDevice, LightEntity): else: await self._client.turn_on(self._index) - async def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" await self._client.turn_off(self._index) diff --git a/homeassistant/components/wilight/parent_device.py b/homeassistant/components/wilight/parent_device.py index faf71b74f72..17a33fef633 100644 --- a/homeassistant/components/wilight/parent_device.py +++ b/homeassistant/components/wilight/parent_device.py @@ -1,12 +1,16 @@ """The WiLight Device integration.""" +from __future__ import annotations + import asyncio import logging import pywilight +from pywilight.wilight_device import Device as PyWiLightDevice import requests +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send _LOGGER = logging.getLogger(__name__) @@ -15,23 +19,23 @@ _LOGGER = logging.getLogger(__name__) class WiLightParent: """Manages a single WiLight Parent Device.""" - def __init__(self, hass, config_entry): + def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Initialize the system.""" - self._host = config_entry.data[CONF_HOST] + self._host: str = config_entry.data[CONF_HOST] self._hass = hass - self._api = None + self._api: PyWiLightDevice | None = None @property - def host(self): + def host(self) -> str: """Return the host of this parent.""" return self._host @property - def api(self): + def api(self) -> PyWiLightDevice | None: """Return the api of this parent.""" return self._api - async def async_setup(self): + async def async_setup(self) -> bool: """Set up a WiLight Parent Device based on host parameter.""" host = self._host hass = self._hass @@ -42,7 +46,7 @@ class WiLightParent: return False @callback - def disconnected(): + def disconnected() -> None: # Schedule reconnect after connection has been lost. _LOGGER.warning("WiLight %s disconnected", api_device.device_id) async_dispatcher_send( @@ -50,14 +54,14 @@ class WiLightParent: ) @callback - def reconnected(): + def reconnected() -> None: # Schedule reconnect after connection has been lost. _LOGGER.warning("WiLight %s reconnect", api_device.device_id) async_dispatcher_send( hass, f"wilight_device_available_{api_device.device_id}", True ) - async def connect(api_device): + async def connect(api_device: PyWiLightDevice) -> None: # Set up connection and hook it into HA for reconnect/shutdown. _LOGGER.debug("Initiating connection to %s", api_device.device_id) @@ -81,7 +85,7 @@ class WiLightParent: return True - async def async_reset(self): + async def async_reset(self) -> None: """Reset api.""" # If the initialization was not wrong. @@ -89,15 +93,13 @@ class WiLightParent: self._api.client.stop() -def create_api_device(host): +def create_api_device(host: str) -> PyWiLightDevice: """Create an API Device.""" try: - device = pywilight.device_from_host(host) + return pywilight.device_from_host(host) except ( requests.exceptions.ConnectionError, requests.exceptions.Timeout, ) as err: _LOGGER.error("Unable to access WiLight at %s (%s)", host, err) return None - - return device From a267045a31a2bfe0ceff83cd5c46ee87ec9b361c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 25 Jun 2022 01:05:31 +0200 Subject: [PATCH 677/947] Migrate open_meteo to native_* (#73910) --- .../components/open_meteo/weather.py | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/open_meteo/weather.py b/homeassistant/components/open_meteo/weather.py index 40b52248a52..d1f0adf2e87 100644 --- a/homeassistant/components/open_meteo/weather.py +++ b/homeassistant/components/open_meteo/weather.py @@ -5,7 +5,11 @@ from open_meteo import Forecast as OpenMeteoForecast from homeassistant.components.weather import Forecast, WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_MILLIMETERS, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -33,7 +37,9 @@ class OpenMeteoWeatherEntity( ): """Defines an Open-Meteo weather entity.""" - _attr_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR def __init__( self, @@ -63,14 +69,14 @@ class OpenMeteoWeatherEntity( ) @property - def temperature(self) -> float | None: + def native_temperature(self) -> float | None: """Return the platform temperature.""" if not self.coordinator.data.current_weather: return None return self.coordinator.data.current_weather.temperature @property - def wind_speed(self) -> float | None: + def native_wind_speed(self) -> float | None: """Return the wind speed.""" if not self.coordinator.data.current_weather: return None @@ -103,19 +109,19 @@ class OpenMeteoWeatherEntity( ) if daily.precipitation_sum is not None: - forecast["precipitation"] = daily.precipitation_sum[index] + forecast["native_precipitation"] = daily.precipitation_sum[index] if daily.temperature_2m_max is not None: - forecast["temperature"] = daily.temperature_2m_max[index] + forecast["native_temperature"] = daily.temperature_2m_max[index] if daily.temperature_2m_min is not None: - forecast["templow"] = daily.temperature_2m_min[index] + forecast["native_templow"] = daily.temperature_2m_min[index] if daily.wind_direction_10m_dominant is not None: forecast["wind_bearing"] = daily.wind_direction_10m_dominant[index] if daily.wind_speed_10m_max is not None: - forecast["wind_speed"] = daily.wind_speed_10m_max[index] + forecast["native_wind_speed"] = daily.wind_speed_10m_max[index] forecasts.append(forecast) From ad3bd6773c5da0ab9827ee0f73d6c1d141fc4c78 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Sat, 25 Jun 2022 00:23:26 +0100 Subject: [PATCH 678/947] Add device_info to Glances entities (#73047) --- homeassistant/components/glances/sensor.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index a907dd1695a..0d60747ecaa 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -4,6 +4,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES, GlancesSensorEntityDescription @@ -30,6 +31,7 @@ async def async_setup_entry( name, disk["mnt_point"], description, + config_entry.entry_id, ) ) elif description.type == "sensors": @@ -42,11 +44,16 @@ async def async_setup_entry( name, sensor["label"], description, + config_entry.entry_id, ) ) elif description.type == "raid": for raid_device in client.api.data[description.type]: - dev.append(GlancesSensor(client, name, raid_device, description)) + dev.append( + GlancesSensor( + client, name, raid_device, description, config_entry.entry_id + ) + ) elif client.api.data[description.type]: dev.append( GlancesSensor( @@ -54,6 +61,7 @@ async def async_setup_entry( name, "", description, + config_entry.entry_id, ) ) @@ -71,6 +79,7 @@ class GlancesSensor(SensorEntity): name, sensor_name_prefix, description: GlancesSensorEntityDescription, + config_entry_id: str, ): """Initialize the sensor.""" self.glances_data = glances_data @@ -80,6 +89,11 @@ class GlancesSensor(SensorEntity): self.entity_description = description self._attr_name = f"{name} {sensor_name_prefix} {description.name_suffix}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry_id)}, + manufacturer="Glances", + name=name, + ) @property def unique_id(self): From 0166816200c364d3a906d2071f8c9f72c7704764 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sat, 25 Jun 2022 00:24:25 +0000 Subject: [PATCH 679/947] [ci skip] Translation update --- .../eight_sleep/translations/uk.json | 19 ++++++++++++++++ .../components/google/translations/uk.json | 7 ++++++ .../components/nest/translations/uk.json | 20 +++++++++++++++++ .../overkiz/translations/sensor.uk.json | 9 ++++++++ .../radiotherm/translations/it.json | 2 +- .../radiotherm/translations/uk.json | 22 +++++++++++++++++++ .../sensibo/translations/sensor.uk.json | 8 +++++++ .../simplepush/translations/et.json | 21 ++++++++++++++++++ .../simplepush/translations/fr.json | 21 ++++++++++++++++++ .../simplepush/translations/no.json | 21 ++++++++++++++++++ .../simplepush/translations/pt-BR.json | 21 ++++++++++++++++++ .../simplepush/translations/zh-Hant.json | 21 ++++++++++++++++++ .../components/skybell/translations/uk.json | 12 ++++++++++ .../transmission/translations/uk.json | 10 ++++++++- .../components/vacuum/translations/nn.json | 8 +++++++ 15 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/eight_sleep/translations/uk.json create mode 100644 homeassistant/components/google/translations/uk.json create mode 100644 homeassistant/components/overkiz/translations/sensor.uk.json create mode 100644 homeassistant/components/radiotherm/translations/uk.json create mode 100644 homeassistant/components/sensibo/translations/sensor.uk.json create mode 100644 homeassistant/components/simplepush/translations/et.json create mode 100644 homeassistant/components/simplepush/translations/fr.json create mode 100644 homeassistant/components/simplepush/translations/no.json create mode 100644 homeassistant/components/simplepush/translations/pt-BR.json create mode 100644 homeassistant/components/simplepush/translations/zh-Hant.json create mode 100644 homeassistant/components/skybell/translations/uk.json diff --git a/homeassistant/components/eight_sleep/translations/uk.json b/homeassistant/components/eight_sleep/translations/uk.json new file mode 100644 index 00000000000..4dea8ca0857 --- /dev/null +++ b/homeassistant/components/eight_sleep/translations/uk.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e", + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u0454\u0442\u044c\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e \u0445\u043c\u0430\u0440\u0438 Eight Sleep: {error}" + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u0454\u0442\u044c\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e \u0445\u043c\u0430\u0440\u0438 Eight Sleep: {error}" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/google/translations/uk.json b/homeassistant/components/google/translations/uk.json new file mode 100644 index 00000000000..d0beb9cab9f --- /dev/null +++ b/homeassistant/components/google/translations/uk.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/translations/uk.json b/homeassistant/components/nest/translations/uk.json index 9ab8349670e..cfdb2c91ee2 100644 --- a/homeassistant/components/nest/translations/uk.json +++ b/homeassistant/components/nest/translations/uk.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u041e\u0431\u043b\u0456\u043a\u043e\u0432\u0438\u0439 \u0437\u0430\u043f\u0438\u0441 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e", "authorize_url_timeout": "\u041c\u0438\u043d\u0443\u0432 \u0447\u0430\u0441 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0456\u0457 \u043f\u043e\u0441\u0438\u043b\u0430\u043d\u043d\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457.", "missing_configuration": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u0438 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f. \u0411\u0443\u0434\u044c \u043b\u0430\u0441\u043a\u0430, \u043e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 \u0456\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0456\u044f\u043c\u0438.", "no_url_available": "URL-\u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430. \u041e\u0437\u043d\u0430\u0439\u043e\u043c\u0442\u0435\u0441\u044f \u0437 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0456\u0454\u044e] ({docs_url}) \u0434\u043b\u044f \u043e\u0442\u0440\u0438\u043c\u0430\u043d\u043d\u044f \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u0457 \u043f\u0440\u043e \u0446\u044e \u043f\u043e\u043c\u0438\u043b\u043a\u0443.", @@ -18,6 +19,25 @@ "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" }, "step": { + "auth_upgrade": { + "title": "Nest: \u0437\u0430\u0431\u043e\u0440\u043e\u043d\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0456\u0457 \u0434\u043e\u0434\u0430\u0442\u043a\u0456\u0432" + }, + "cloud_project": { + "data": { + "cloud_project_id": "ID \u043f\u0440\u043e\u0435\u043a\u0442\u0443 Google Cloud" + } + }, + "create_cloud_project": { + "title": "Nest: \u0441\u0442\u0432\u043e\u0440\u0456\u0442\u044c \u0456 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0439\u0442\u0435 Cloud Project" + }, + "device_project": { + "data": { + "project_id": "ID \u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0443 \u0434\u043e \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e" + } + }, + "device_project_upgrade": { + "title": "Nest: \u043e\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044f \u043f\u0440\u043e\u0435\u043a\u0442\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0443 \u0434\u043e \u043f\u0440\u0438\u0441\u0442\u0440\u043e\u044e" + }, "init": { "data": { "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440" diff --git a/homeassistant/components/overkiz/translations/sensor.uk.json b/homeassistant/components/overkiz/translations/sensor.uk.json new file mode 100644 index 00000000000..cf84368e911 --- /dev/null +++ b/homeassistant/components/overkiz/translations/sensor.uk.json @@ -0,0 +1,9 @@ +{ + "state": { + "overkiz__three_way_handle_direction": { + "closed": "\u0417\u0430\u043a\u0440\u0438\u0442\u043e", + "open": "\u0412\u0456\u0434\u043a\u0440\u0438\u0442\u043e", + "tilt": "\u041d\u0430\u0445\u0438\u043b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/it.json b/homeassistant/components/radiotherm/translations/it.json index 1fd9c7152ff..653dd56321b 100644 --- a/homeassistant/components/radiotherm/translations/it.json +++ b/homeassistant/components/radiotherm/translations/it.json @@ -23,7 +23,7 @@ "step": { "init": { "data": { - "hold_temp": "Impostare una sospensione permanente durante la regolazione della temperatura." + "hold_temp": "Imposta un blocco permanente quando si regola la temperatura." } } } diff --git a/homeassistant/components/radiotherm/translations/uk.json b/homeassistant/components/radiotherm/translations/uk.json new file mode 100644 index 00000000000..51459878ddb --- /dev/null +++ b/homeassistant/components/radiotherm/translations/uk.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u041f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e" + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f", + "unknown": "\u041d\u0435\u043e\u0447\u0456\u043a\u0443\u0432\u0430\u043d\u0430 \u043f\u043e\u043c\u0438\u043b\u043a\u0430" + }, + "flow_title": "{name} {model} ({host})", + "step": { + "confirm": { + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0457\u0442\u0438 {name} {model} ({host})?" + }, + "user": { + "data": { + "host": "\u0425\u043e\u0441\u0442" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sensibo/translations/sensor.uk.json b/homeassistant/components/sensibo/translations/sensor.uk.json new file mode 100644 index 00000000000..d93a147307e --- /dev/null +++ b/homeassistant/components/sensibo/translations/sensor.uk.json @@ -0,0 +1,8 @@ +{ + "state": { + "sensibo__sensitivity": { + "n": "\u041d\u043e\u0440\u043c\u0430\u043b\u044c\u043d\u0438\u0439", + "s": "\u0427\u0443\u0442\u043b\u0438\u0432\u0438\u0439" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/et.json b/homeassistant/components/simplepush/translations/et.json new file mode 100644 index 00000000000..2501d992c83 --- /dev/null +++ b/homeassistant/components/simplepush/translations/et.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Seade on juba h\u00e4\u00e4lestatud" + }, + "error": { + "cannot_connect": "\u00dchendamine nurjus" + }, + "step": { + "user": { + "data": { + "device_key": "Seadme seadmev\u00f5ti", + "event": "S\u00fcndmuste jaoks m\u00f5eldud s\u00fcndmus.", + "name": "Nimi", + "password": "Seadmes kasutatava kr\u00fcptimise parool", + "salt": "Sadmes kasutatav sool." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/fr.json b/homeassistant/components/simplepush/translations/fr.json new file mode 100644 index 00000000000..546d03bb131 --- /dev/null +++ b/homeassistant/components/simplepush/translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "cannot_connect": "\u00c9chec de connexion" + }, + "step": { + "user": { + "data": { + "device_key": "La cl\u00e9 d'appareil de votre appareil", + "event": "L'\u00e9v\u00e9nement pour les \u00e9v\u00e9nements.", + "name": "Nom", + "password": "Le mot de passe du chiffrement utilis\u00e9 par votre appareil", + "salt": "Le salage utilis\u00e9 par votre appareil." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/no.json b/homeassistant/components/simplepush/translations/no.json new file mode 100644 index 00000000000..78cf864a33d --- /dev/null +++ b/homeassistant/components/simplepush/translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Tilkobling mislyktes" + }, + "step": { + "user": { + "data": { + "device_key": "Enhetsn\u00f8kkelen til enheten din", + "event": "Arrangementet for arrangementene.", + "name": "Navn", + "password": "Passordet til krypteringen som brukes av enheten din", + "salt": "Saltet som brukes av enheten." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/pt-BR.json b/homeassistant/components/simplepush/translations/pt-BR.json new file mode 100644 index 00000000000..bf933fe94da --- /dev/null +++ b/homeassistant/components/simplepush/translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Dispositivo j\u00e1 configurado" + }, + "error": { + "cannot_connect": "Falha ao conectar" + }, + "step": { + "user": { + "data": { + "device_key": "A chave do dispositivo do seu dispositivo", + "event": "O evento para os eventos.", + "name": "Nome", + "password": "A senha da criptografia usada pelo seu dispositivo", + "salt": "O salto utilizado pelo seu dispositivo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/zh-Hant.json b/homeassistant/components/simplepush/translations/zh-Hant.json new file mode 100644 index 00000000000..891f2242467 --- /dev/null +++ b/homeassistant/components/simplepush/translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557" + }, + "step": { + "user": { + "data": { + "device_key": "\u88dd\u7f6e\u4e4b\u88dd\u7f6e\u5bc6\u9470", + "event": "\u4e8b\u4ef6\u7684\u4e8b\u4ef6\u3002", + "name": "\u540d\u7a31", + "password": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b\u52a0\u5bc6\u5bc6\u78bc", + "salt": "\u88dd\u7f6e\u6240\u4f7f\u7528\u4e4b Salt" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/uk.json b/homeassistant/components/skybell/translations/uk.json new file mode 100644 index 00000000000..19744315085 --- /dev/null +++ b/homeassistant/components/skybell/translations/uk.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "email": "Email", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/translations/uk.json b/homeassistant/components/transmission/translations/uk.json index 5bc74f7da2a..9fbe0848657 100644 --- a/homeassistant/components/transmission/translations/uk.json +++ b/homeassistant/components/transmission/translations/uk.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u0426\u0435\u0439 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant." + "already_configured": "\u0426\u0435\u0439 \u043f\u0440\u0438\u0441\u0442\u0440\u0456\u0439 \u0432\u0436\u0435 \u0434\u043e\u0434\u0430\u043d\u043e \u0432 Home Assistant.", + "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f \u043f\u0440\u043e\u0439\u0448\u043b\u0430 \u0443\u0441\u043f\u0456\u0448\u043d\u043e" }, "error": { "cannot_connect": "\u041d\u0435 \u0432\u0434\u0430\u043b\u043e\u0441\u044f \u043f\u0456\u0434'\u0454\u0434\u043d\u0430\u0442\u0438\u0441\u044f", @@ -9,6 +10,13 @@ "name_exists": "\u0426\u044f \u043d\u0430\u0437\u0432\u0430 \u0432\u0436\u0435 \u0432\u0438\u043a\u043e\u0440\u0438\u0441\u0442\u043e\u0432\u0443\u0454\u0442\u044c\u0441\u044f." }, "step": { + "reauth_confirm": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "description": "\u041f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f {username} \u043d\u0435\u0434\u0456\u0439\u0441\u043d\u0438\u0439.", + "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430 \u0430\u0432\u0442\u0435\u043d\u0442\u0438\u0444\u0456\u043a\u0430\u0446\u0456\u044f \u0456\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0456\u0457" + }, "user": { "data": { "host": "\u0425\u043e\u0441\u0442", diff --git a/homeassistant/components/vacuum/translations/nn.json b/homeassistant/components/vacuum/translations/nn.json index e06ae761458..12d981555ad 100644 --- a/homeassistant/components/vacuum/translations/nn.json +++ b/homeassistant/components/vacuum/translations/nn.json @@ -1,4 +1,12 @@ { + "device_automation": { + "condition_type": { + "is_cleaning": "k\u00f8yrer" + }, + "trigger_type": { + "cleaning": "starta reingjering" + } + }, "state": { "_": { "cleaning": "Reingjer", From 55b5ade5861582cf0750b4dba9385486bbf71c35 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jun 2022 03:31:44 -0500 Subject: [PATCH 680/947] Prime platform.uname cache at startup to fix blocking subprocess in the event loop (#73975) Prime platform.uname cache at startup to fix blocking subprocess - Multiple modules check platform.uname()[0] at startup which does a blocking subprocess call. We can avoid this happening in the eventloop and distrupting startup stability by priming the cache ahead of time in the executor --- homeassistant/bootstrap.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index eabbbb49362..81d0fa72134 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta import logging import logging.handlers import os +import platform import sys import threading from time import monotonic @@ -540,11 +541,22 @@ async def _async_set_up_integrations( stage_2_domains = domains_to_setup - logging_domains - debuggers - stage_1_domains + def _cache_uname_processor() -> None: + """Cache the result of platform.uname().processor in the executor. + + Multiple modules call this function at startup which + executes a blocking subprocess call. This is a problem for the + asyncio event loop. By primeing the cache of uname we can + avoid the blocking call in the event loop. + """ + platform.uname().processor # pylint: disable=expression-not-assigned + # Load the registries await asyncio.gather( device_registry.async_load(hass), entity_registry.async_load(hass), area_registry.async_load(hass), + hass.async_add_executor_job(_cache_uname_processor), ) # Start setup From 10dc38e0ec27f7bef990ee431459342f9c3c52b4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jun 2022 11:59:56 +0200 Subject: [PATCH 681/947] Adjust CoverEntity property type hints in components (#73943) * Adjust CoverEntity property type hints in components * Revert changes to rflink * Revert changes to wilight --- homeassistant/components/acmeda/cover.py | 8 +++---- homeassistant/components/ads/cover.py | 4 ++-- .../components/advantage_air/cover.py | 4 ++-- homeassistant/components/blebox/cover.py | 12 ++++++---- homeassistant/components/bosch_shc/cover.py | 8 +++---- homeassistant/components/brunt/cover.py | 3 +-- homeassistant/components/demo/cover.py | 18 +++++++------- homeassistant/components/esphome/cover.py | 2 +- homeassistant/components/fibaro/cover.py | 6 ++--- homeassistant/components/garadget/cover.py | 18 ++++++++------ homeassistant/components/gogogate2/cover.py | 8 +++---- homeassistant/components/homematic/cover.py | 10 ++++---- .../hunterdouglas_powerview/cover.py | 4 ++-- homeassistant/components/insteon/cover.py | 4 ++-- homeassistant/components/lutron/cover.py | 6 ++--- .../components/lutron_caseta/cover.py | 4 ++-- .../components/motion_blinds/cover.py | 24 ++++++++++--------- homeassistant/components/mqtt/cover.py | 19 ++++++++------- homeassistant/components/myq/cover.py | 8 +++---- homeassistant/components/opengarage/cover.py | 8 ++++--- homeassistant/components/scsgate/cover.py | 6 ++--- homeassistant/components/slide/cover.py | 10 ++++---- homeassistant/components/smartthings/cover.py | 10 ++++---- homeassistant/components/soma/cover.py | 8 +++---- homeassistant/components/supla/cover.py | 8 +++---- homeassistant/components/tellduslive/cover.py | 5 ++-- homeassistant/components/tellstick/cover.py | 4 ++-- homeassistant/components/template/cover.py | 17 ++++++------- homeassistant/components/velux/cover.py | 10 ++++---- .../components/xiaomi_aqara/cover.py | 4 ++-- homeassistant/components/zha/cover.py | 10 ++++---- 31 files changed, 141 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/acmeda/cover.py b/homeassistant/components/acmeda/cover.py index d772d8ab01f..887e26cd7fc 100644 --- a/homeassistant/components/acmeda/cover.py +++ b/homeassistant/components/acmeda/cover.py @@ -47,7 +47,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): """Representation of a Acmeda cover device.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of the roller blind. None is unknown, 0 is closed, 100 is fully open. @@ -58,7 +58,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): return position @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt of the roller blind. None is unknown, 0 is closed, 100 is fully open. @@ -69,7 +69,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): return position @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = 0 if self.current_cover_position is not None: @@ -90,7 +90,7 @@ class AcmedaCover(AcmedaBase, CoverEntity): return supported_features @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.roller.closed_percent == 100 diff --git a/homeassistant/components/ads/cover.py b/homeassistant/components/ads/cover.py index a976ce15877..a2fb1888cd3 100644 --- a/homeassistant/components/ads/cover.py +++ b/homeassistant/components/ads/cover.py @@ -135,7 +135,7 @@ class AdsCover(AdsEntity, CoverEntity): ) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._ads_var is not None: return self._state_dict[STATE_KEY_STATE] @@ -144,7 +144,7 @@ class AdsCover(AdsEntity, CoverEntity): return None @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return current position of cover.""" return self._state_dict[STATE_KEY_POSITION] diff --git a/homeassistant/components/advantage_air/cover.py b/homeassistant/components/advantage_air/cover.py index bbe17835d71..36ae2c7fff0 100644 --- a/homeassistant/components/advantage_air/cover.py +++ b/homeassistant/components/advantage_air/cover.py @@ -58,12 +58,12 @@ class AdvantageAirZoneVent(AdvantageAirEntity, CoverEntity): ) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if vent is fully closed.""" return self._zone["state"] == ADVANTAGE_AIR_STATE_CLOSE @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return vents current position as a percentage.""" if self._zone["state"] == ADVANTAGE_AIR_STATE_OPEN: return self._zone["value"] diff --git a/homeassistant/components/blebox/cover.py b/homeassistant/components/blebox/cover.py index a7f531fe519..368443988a4 100644 --- a/homeassistant/components/blebox/cover.py +++ b/homeassistant/components/blebox/cover.py @@ -1,4 +1,6 @@ """BleBox cover entity.""" +from __future__ import annotations + from typing import Any from homeassistant.components.cover import ( @@ -41,7 +43,7 @@ class BleBoxCoverEntity(BleBoxEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current cover position.""" position = self._feature.current if position == -1: # possible for shutterBox @@ -50,17 +52,17 @@ class BleBoxCoverEntity(BleBoxEntity, CoverEntity): return None if position is None else 100 - position @property - def is_opening(self): + def is_opening(self) -> bool | None: """Return whether cover is opening.""" return self._is_state(STATE_OPENING) @property - def is_closing(self): + def is_closing(self) -> bool | None: """Return whether cover is closing.""" return self._is_state(STATE_CLOSING) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return whether cover is closed.""" return self._is_state(STATE_CLOSED) @@ -82,6 +84,6 @@ class BleBoxCoverEntity(BleBoxEntity, CoverEntity): """Stop the cover.""" await self._feature.async_stop() - def _is_state(self, state_name): + def _is_state(self, state_name) -> bool | None: value = BLEBOX_TO_HASS_COVER_STATES[self._feature.state] return None if value is None else value == state_name diff --git a/homeassistant/components/bosch_shc/cover.py b/homeassistant/components/bosch_shc/cover.py index cdbe884dc45..91dc361a23d 100644 --- a/homeassistant/components/bosch_shc/cover.py +++ b/homeassistant/components/bosch_shc/cover.py @@ -52,7 +52,7 @@ class ShutterControlCover(SHCEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current cover position.""" return round(self._device.level * 100.0) @@ -61,12 +61,12 @@ class ShutterControlCover(SHCEntity, CoverEntity): self._device.stop() @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed or not.""" return self.current_cover_position == 0 @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return ( self._device.operation_state @@ -74,7 +74,7 @@ class ShutterControlCover(SHCEntity, CoverEntity): ) @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return ( self._device.operation_state diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index c38dfa0eb78..489229622b2 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -1,7 +1,6 @@ """Support for Brunt Blind Engine covers.""" from __future__ import annotations -from collections.abc import MutableMapping from typing import Any from aiohttp.client_exceptions import ClientResponseError @@ -140,7 +139,7 @@ class BruntDevice(CoordinatorEntity, CoverEntity): return self.move_state == 2 @property - def extra_state_attributes(self) -> MutableMapping[str, Any]: + def extra_state_attributes(self) -> dict[str, Any]: """Return the detailed device state attributes.""" return { ATTR_REQUEST_POSITION: self.request_cover_position, diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py index 5c908dfa33a..f867ed3faa4 100644 --- a/homeassistant/components/demo/cover.py +++ b/homeassistant/components/demo/cover.py @@ -110,42 +110,42 @@ class DemoCover(CoverEntity): ) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for cover.""" return self._unique_id @property - def name(self): + def name(self) -> str: """Return the name of the cover.""" return self._name @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed for a demo cover.""" return False @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of the cover.""" return self._position @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt position of the cover.""" return self._tilt_position @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._closed @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing.""" return self._is_closing @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening.""" return self._is_opening @@ -155,7 +155,7 @@ class DemoCover(CoverEntity): return self._device_class @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" if self._supported_features is not None: return self._supported_features diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index 4296c899253..ab8b7af2185 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -111,7 +111,7 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """Stop the cover.""" await self._client.cover_command(key=self._static_info.key, stop=True) - async def async_set_cover_position(self, **kwargs: int) -> None: + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" await self._client.cover_command( key=self._static_info.key, position=kwargs[ATTR_POSITION] / 100 diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index 1e5583f20d6..c0749f9c100 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -72,12 +72,12 @@ class FibaroCover(FibaroDevice, CoverEntity): return False @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. 0 is closed, 100 is open.""" return self.bound(self.level) @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current tilt position for venetian blinds.""" return self.bound(self.level2) @@ -90,7 +90,7 @@ class FibaroCover(FibaroDevice, CoverEntity): self.set_level2(kwargs.get(ATTR_TILT_POSITION)) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._is_open_close_only(): return self.fibaro_device.properties.state.lower() == "closed" diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index e0372db0c6c..96ebe698605 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -7,7 +7,11 @@ from typing import Any import requests import voluptuous as vol -from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity +from homeassistant.components.cover import ( + PLATFORM_SCHEMA, + CoverDeviceClass, + CoverEntity, +) from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_COVERS, @@ -135,17 +139,17 @@ class GaradgetCover(CoverEntity): self.remove_token() @property - def name(self): + def name(self) -> str: """Return the name of the cover.""" return self._name @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" return self._available @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the device state attributes.""" data = {} @@ -164,16 +168,16 @@ class GaradgetCover(CoverEntity): return data @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._state is None: return None return self._state == STATE_CLOSED @property - def device_class(self): + def device_class(self) -> CoverDeviceClass: """Return the class of this device, from component DEVICE_CLASSES.""" - return "garage" + return CoverDeviceClass.GARAGE def get_token(self): """Get new token for usage during this session.""" diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index b7434952fc1..f0039d85295 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -62,12 +62,12 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): ) @property - def name(self): + def name(self) -> str | None: """Return the name of the door.""" return self.door.name @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return true if cover is closed, else False.""" door_status = self.door_status if door_status == DoorStatus.OPENED: @@ -77,12 +77,12 @@ class DeviceCover(GoGoGate2Entity, CoverEntity): return None @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self.door_status == TransitionDoorStatus.CLOSING @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self.door_status == TransitionDoorStatus.OPENING diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py index d138e172784..d5f1802e774 100644 --- a/homeassistant/components/homematic/cover.py +++ b/homeassistant/components/homematic/cover.py @@ -43,7 +43,7 @@ class HMCover(HMDevice, CoverEntity): """Representation a HomeMatic Cover.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """ Return current position of cover. @@ -60,7 +60,7 @@ class HMCover(HMDevice, CoverEntity): self._hmdevice.set_level(level, self._channel) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return whether the cover is closed.""" if self.current_cover_position is not None: return self.current_cover_position == 0 @@ -86,7 +86,7 @@ class HMCover(HMDevice, CoverEntity): self._data.update({"LEVEL_2": None}) @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. None is unknown, 0 is closed, 100 is fully open. @@ -125,7 +125,7 @@ class HMGarage(HMCover): _attr_device_class = CoverDeviceClass.GARAGE @property - def current_cover_position(self): + def current_cover_position(self) -> None: """ Return current position of cover. @@ -135,7 +135,7 @@ class HMGarage(HMCover): return None @property - def is_closed(self): + def is_closed(self) -> bool: """Return whether the cover is closed.""" return self._hmdevice.is_closed(self._hm_get_state()) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index fe26a100569..3f7d04f5b87 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -192,7 +192,7 @@ class PowerViewShadeBase(ShadeEntity, CoverEntity): return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.positions.primary <= CLOSED_POSITION @@ -474,7 +474,7 @@ class PowerViewShadeTDBUTop(PowerViewShadeTDBU): return False @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" # top shade needs to check other motor return self.positions.secondary <= CLOSED_POSITION diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 68f7f6156e3..645450166b9 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -47,7 +47,7 @@ class InsteonCoverEntity(InsteonEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current cover position.""" if self._insteon_device_group.value is not None: pos = self._insteon_device_group.value @@ -56,7 +56,7 @@ class InsteonCoverEntity(InsteonEntity, CoverEntity): return int(math.ceil(pos * 100 / 255)) @property - def is_closed(self): + def is_closed(self) -> bool: """Return the boolean response if the node is on.""" return bool(self.current_cover_position) diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index 67a0e093337..fa62ef3745a 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -43,12 +43,12 @@ class LutronCover(LutronDevice, CoverEntity): ) @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._lutron_device.last_level() < 1 @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of cover.""" return self._lutron_device.last_level() @@ -73,6 +73,6 @@ class LutronCover(LutronDevice, CoverEntity): _LOGGER.debug("Lutron ID: %d updated to %f", self._lutron_device.id, level) @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" return {"lutron_integration_id": self._lutron_device.id} diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index 68932a2a011..b74642a8589 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -50,12 +50,12 @@ class LutronCasetaCover(LutronCasetaDeviceUpdatableEntity, CoverEntity): _attr_device_class = CoverDeviceClass.SHADE @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._device["current_state"] < 1 @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of cover.""" return self._device["current_state"] diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 301d2c6fbc7..a73166912f4 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -1,4 +1,6 @@ """Support for Motion Blinds using their WLAN API.""" +from __future__ import annotations + import logging from typing import Any @@ -216,7 +218,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): ) @property - def available(self): + def available(self) -> bool: """Return True if entity is available.""" if self.coordinator.data is None: return False @@ -227,7 +229,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """ Return current position of cover. @@ -238,7 +240,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): return 100 - self._blind.position @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._blind.position is None: return None @@ -249,7 +251,7 @@ class MotionPositionDevice(CoordinatorEntity, CoverEntity): self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) await super().async_added_to_hass() - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe when removed.""" self._blind.Remove_callback(self.unique_id) await super().async_will_remove_from_hass() @@ -340,7 +342,7 @@ class MotionTiltDevice(MotionPositionDevice): _restore_tilt = True @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """ Return current angle of cover. @@ -378,7 +380,7 @@ class MotionTiltOnlyDevice(MotionTiltDevice): _restore_tilt = False @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = ( CoverEntityFeature.OPEN_TILT @@ -392,12 +394,12 @@ class MotionTiltOnlyDevice(MotionTiltDevice): return supported_features @property - def current_cover_position(self): + def current_cover_position(self) -> None: """Return current position of cover.""" return None @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._blind.angle is None: return None @@ -430,7 +432,7 @@ class MotionTDBUDevice(MotionPositionDevice): _LOGGER.error("Unknown motor '%s'", self._motor) @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """ Return current position of cover. @@ -442,7 +444,7 @@ class MotionTDBUDevice(MotionPositionDevice): return 100 - self._blind.scaled_position[self._motor_key] @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._blind.position is None: return None @@ -453,7 +455,7 @@ class MotionTDBUDevice(MotionPositionDevice): return self._blind.position[self._motor_key] == 100 @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return device specific state attributes.""" attributes = {} if self._blind.position is not None: diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 8a32471ecec..54ed4f2b0a0 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -12,6 +12,7 @@ from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, + CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -486,12 +487,12 @@ class MqttCover(MqttEntity, CoverEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return true if we do optimistic updates.""" - return self._optimistic + return bool(self._optimistic) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return true if the cover is closed or None if the status is unknown.""" if self._state is None: return None @@ -499,17 +500,17 @@ class MqttCover(MqttEntity, CoverEntity): return self._state == STATE_CLOSED @property - def is_opening(self): + def is_opening(self) -> bool: """Return true if the cover is actively opening.""" return self._state == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return true if the cover is actively closing.""" return self._state == STATE_CLOSING @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -517,17 +518,17 @@ class MqttCover(MqttEntity, CoverEntity): return self._position @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt.""" return self._tilt_value @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Return the class of this sensor.""" return self._config.get(CONF_DEVICE_CLASS) @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = 0 if self._config.get(CONF_COMMAND_TOPIC) is not None: diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py index fe8ef16bc89..51d0b3290a6 100644 --- a/homeassistant/components/myq/cover.py +++ b/homeassistant/components/myq/cover.py @@ -50,22 +50,22 @@ class MyQCover(MyQEntity, CoverEntity): self._attr_unique_id = device.device_id @property - def is_closed(self): + def is_closed(self) -> bool: """Return true if cover is closed, else False.""" return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING @property - def is_open(self): + def is_open(self) -> bool: """Return if the cover is opening or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_OPEN @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py index ac0af64737a..aff913cf205 100644 --- a/homeassistant/components/opengarage/cover.py +++ b/homeassistant/components/opengarage/cover.py @@ -1,4 +1,6 @@ """Platform for the opengarage.io cover component.""" +from __future__ import annotations + import logging from typing import Any @@ -43,21 +45,21 @@ class OpenGarageCover(OpenGarageEntity, CoverEntity): super().__init__(open_garage_data_coordinator, device_id) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._state is None: return None return self._state == STATE_CLOSED @property - def is_closing(self): + def is_closing(self) -> bool | None: """Return if the cover is closing.""" if self._state is None: return None return self._state == STATE_CLOSING @property - def is_opening(self): + def is_opening(self) -> bool | None: """Return if the cover is opening.""" if self._state is None: return None diff --git a/homeassistant/components/scsgate/cover.py b/homeassistant/components/scsgate/cover.py index 8d94f4214af..4aa08cae3bd 100644 --- a/homeassistant/components/scsgate/cover.py +++ b/homeassistant/components/scsgate/cover.py @@ -72,17 +72,17 @@ class SCSGateCover(CoverEntity): return self._scs_id @property - def should_poll(self): + def should_poll(self) -> bool: """No polling needed.""" return False @property - def name(self): + def name(self) -> str: """Return the name of the cover.""" return self._name @property - def is_closed(self): + def is_closed(self) -> None: """Return if the cover is closed.""" return None diff --git a/homeassistant/components/slide/cover.py b/homeassistant/components/slide/cover.py index 52fcc3c8da4..866d3d40307 100644 --- a/homeassistant/components/slide/cover.py +++ b/homeassistant/components/slide/cover.py @@ -52,29 +52,29 @@ class SlideCover(CoverEntity): self._invert = slide["invert"] @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._slide["state"] == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._slide["state"] == STATE_CLOSING @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return None if status is unknown, True if closed, else False.""" if self._slide["state"] is None: return None return self._slide["state"] == STATE_CLOSED @property - def available(self): + def available(self) -> bool: """Return False if state is not available.""" return self._slide["online"] @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of cover shutter.""" if (pos := self._slide["pos"]) is not None: if (1 - pos) <= DEFAULT_OFFSET or pos <= DEFAULT_OFFSET: diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 7e2de17cad3..80a30131301 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -126,17 +126,17 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): self._state_attrs[ATTR_BATTERY_LEVEL] = battery @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._state == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._state == STATE_CLOSING @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed or not.""" if self._state == STATE_CLOSED: return True @@ -150,11 +150,11 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): return self._device.status.level @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Define this cover as a garage door.""" return self._device_class @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Get additional state attributes.""" return self._state_attrs diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 5777e904597..0130b0ca7b1 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -52,12 +52,12 @@ class SomaTilt(SomaEntity, CoverEntity): ) @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int: """Return the current cover tilt position.""" return self.current_position @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover tilt is closed.""" return self.current_position == 0 @@ -126,12 +126,12 @@ class SomaShade(SomaEntity, CoverEntity): ) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current cover position.""" return self.current_position @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.current_position == 0 diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index b1cc0951259..c6c1d9c07db 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -60,7 +60,7 @@ class SuplaCover(SuplaChannel, CoverEntity): """Representation of a Supla Cover.""" @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. 0 is closed, 100 is open.""" if state := self.channel_data.get("state"): return 100 - state["shut"] @@ -71,7 +71,7 @@ class SuplaCover(SuplaChannel, CoverEntity): await self.async_action("REVEAL", percentage=kwargs.get(ATTR_POSITION)) @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self.current_cover_position is None: return None @@ -94,7 +94,7 @@ class SuplaGateDoor(SuplaChannel, CoverEntity): """Representation of a Supla gate door.""" @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the gate is closed or not.""" state = self.channel_data.get("state") if state and "hi" in state: @@ -120,6 +120,6 @@ class SuplaGateDoor(SuplaChannel, CoverEntity): await self.async_action("OPEN_CLOSE") @property - def device_class(self): + def device_class(self) -> CoverDeviceClass: """Return the class of this device, from component DEVICE_CLASSES.""" return CoverDeviceClass.GARAGE diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index 49c35ac3114..829478fc990 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -8,6 +8,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import TelldusLiveClient from .entry import TelldusLiveEntity @@ -20,7 +21,7 @@ async def async_setup_entry( async def async_discover_cover(device_id): """Discover and add a discovered sensor.""" - client = hass.data[tellduslive.DOMAIN] + client: TelldusLiveClient = hass.data[tellduslive.DOMAIN] async_add_entities([TelldusLiveCover(client, device_id)]) async_dispatcher_connect( @@ -34,7 +35,7 @@ class TelldusLiveCover(TelldusLiveEntity, CoverEntity): """Representation of a cover.""" @property - def is_closed(self): + def is_closed(self) -> bool: """Return the current position of the cover.""" return self.device.is_down diff --git a/homeassistant/components/tellstick/cover.py b/homeassistant/components/tellstick/cover.py index 7c38741960b..17da5684670 100644 --- a/homeassistant/components/tellstick/cover.py +++ b/homeassistant/components/tellstick/cover.py @@ -44,12 +44,12 @@ class TellstickCover(TellstickDevice, CoverEntity): """Representation of a Tellstick cover.""" @property - def is_closed(self): + def is_closed(self) -> None: """Return the current position of the cover is not possible.""" return None @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True if unable to access real state of the entity.""" return True diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 0c86b1d5d5a..aad0270e434 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -12,6 +12,7 @@ from homeassistant.components.cover import ( DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -154,7 +155,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._template = config.get(CONF_VALUE_TEMPLATE) self._position_template = config.get(CONF_POSITION_TEMPLATE) self._tilt_template = config.get(CONF_TILT_TEMPLATE) - self._device_class = config.get(CONF_DEVICE_CLASS) + self._device_class: CoverDeviceClass | None = config.get(CONF_DEVICE_CLASS) self._open_script = None if (open_action := config.get(OPEN_ACTION)) is not None: self._open_script = Script(hass, open_action, friendly_name, DOMAIN) @@ -270,22 +271,22 @@ class CoverTemplate(TemplateEntity, CoverEntity): self._tilt_value = state @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self._position == 0 @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is currently opening.""" return self._is_opening @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is currently closing.""" return self._is_closing @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. @@ -295,7 +296,7 @@ class CoverTemplate(TemplateEntity, CoverEntity): return None @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt. None is unknown, 0 is closed, 100 is fully open. @@ -303,12 +304,12 @@ class CoverTemplate(TemplateEntity, CoverEntity): return self._tilt_value @property - def device_class(self): + def device_class(self) -> CoverDeviceClass | None: """Return the device class of the cover.""" return self._device_class @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 1c8a4afcc6f..f721a628ef8 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -40,7 +40,7 @@ class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" @property - def supported_features(self): + def supported_features(self) -> int: """Flag supported features.""" supported_features = ( CoverEntityFeature.OPEN @@ -58,19 +58,19 @@ class VeluxCover(VeluxEntity, CoverEntity): return supported_features @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of the cover.""" return 100 - self.node.position.position_percent @property - def current_cover_tilt_position(self): + def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" if isinstance(self.node, Blind): return 100 - self.node.orientation.position_percent return None @property - def device_class(self): + def device_class(self) -> CoverDeviceClass: """Define this cover as either awning, blind, garage, gate, shutter or window.""" if isinstance(self.node, Awning): return CoverDeviceClass.AWNING @@ -87,7 +87,7 @@ class VeluxCover(VeluxEntity, CoverEntity): return CoverDeviceClass.WINDOW @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.node.position.closed diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py index e9946e37815..b6de7189d83 100644 --- a/homeassistant/components/xiaomi_aqara/cover.py +++ b/homeassistant/components/xiaomi_aqara/cover.py @@ -46,12 +46,12 @@ class XiaomiGenericCover(XiaomiDevice, CoverEntity): super().__init__(device, name, xiaomi_hub, config_entry) @property - def current_cover_position(self): + def current_cover_position(self) -> int: """Return the current position of the cover.""" return self._pos @property - def is_closed(self): + def is_closed(self) -> bool: """Return if the cover is closed.""" return self.current_cover_position <= 0 diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 39f76b6b77f..6ade62343b1 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -92,24 +92,24 @@ class ZhaCover(ZhaEntity, CoverEntity): self._current_position = last_state.attributes["current_position"] @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self.current_cover_position is None: return None return self.current_cover_position == 0 @property - def is_opening(self): + def is_opening(self) -> bool: """Return if the cover is opening or not.""" return self._state == STATE_OPENING @property - def is_closing(self): + def is_closing(self) -> bool: """Return if the cover is closing or not.""" return self._state == STATE_CLOSING @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return the current position of ZHA cover. None is unknown, 0 is closed, 100 is fully open. @@ -207,7 +207,7 @@ class Shade(ZhaEntity, CoverEntity): self._is_open: bool | None = None @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover. None is unknown, 0 is closed, 100 is fully open. From 3743d42ade80528325d36357ca6f9629d4970eaa Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jun 2022 12:31:53 +0200 Subject: [PATCH 682/947] Adjust smartthings cover type hints (#73948) --- homeassistant/components/smartthings/cover.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 80a30131301..59f6e09df19 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -70,6 +70,8 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: class SmartThingsCover(SmartThingsEntity, CoverEntity): """Define a SmartThings cover.""" + _attr_supported_features: int + def __init__(self, device): """Initialize the cover class.""" super().__init__(device) @@ -98,7 +100,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): # the entity state ahead of receiving the confirming push updates self.async_schedule_update_ha_state(True) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover to a specific position.""" if not self._attr_supported_features & CoverEntityFeature.SET_POSITION: return @@ -143,7 +145,7 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): return None if self._state is None else False @property - def current_cover_position(self): + def current_cover_position(self) -> int | None: """Return current position of cover.""" if not self._attr_supported_features & CoverEntityFeature.SET_POSITION: return None From 9eed8b2ef4dc3ffd271368af545b28ceb6e4e93c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 25 Jun 2022 12:32:55 +0200 Subject: [PATCH 683/947] Adjust freedompro cover position method (#73945) --- homeassistant/components/freedompro/cover.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/freedompro/cover.py b/homeassistant/components/freedompro/cover.py index e4483f0005b..ebb8a98b4b1 100644 --- a/homeassistant/components/freedompro/cover.py +++ b/homeassistant/components/freedompro/cover.py @@ -105,15 +105,13 @@ class Device(CoordinatorEntity, CoverEntity): """Close the cover.""" await self.async_set_cover_position(position=0) - async def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs: Any) -> None: """Async function to set position to cover.""" - payload = {} - payload["position"] = kwargs[ATTR_POSITION] - payload = json.dumps(payload) + payload = {"position": kwargs[ATTR_POSITION]} await put_state( self._session, self._api_key, self.unique_id, - payload, + json.dumps(payload), ) await self.coordinator.async_request_refresh() From eb6afd27b312ed2910a1ad32aa20a3f4ebd54cf8 Mon Sep 17 00:00:00 2001 From: rappenze Date: Sat, 25 Jun 2022 12:34:30 +0200 Subject: [PATCH 684/947] Fix fibaro cover state (#73921) --- homeassistant/components/fibaro/cover.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index c0749f9c100..364cbcf39cb 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -93,6 +93,11 @@ class FibaroCover(FibaroDevice, CoverEntity): def is_closed(self) -> bool | None: """Return if the cover is closed.""" if self._is_open_close_only(): + if ( + "state" not in self.fibaro_device.properties + or self.fibaro_device.properties.state.lower() == "unknown" + ): + return None return self.fibaro_device.properties.state.lower() == "closed" if self.current_cover_position is None: From 85fdc562400211bbaab07233bd5e9a7c496ddb44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jun 2022 09:41:47 -0500 Subject: [PATCH 685/947] Bump aiosteamist to 0.3.2 (#73976) Changelog: https://github.com/bdraco/aiosteamist/compare/0.3.1...0.3.2 --- homeassistant/components/steamist/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/steamist/manifest.json b/homeassistant/components/steamist/manifest.json index 057645a1d4c..4ea50e5c7de 100644 --- a/homeassistant/components/steamist/manifest.json +++ b/homeassistant/components/steamist/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "dependencies": ["network"], "documentation": "https://www.home-assistant.io/integrations/steamist", - "requirements": ["aiosteamist==0.3.1", "discovery30303==0.2.1"], + "requirements": ["aiosteamist==0.3.2", "discovery30303==0.2.1"], "codeowners": ["@bdraco"], "iot_class": "local_polling", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 339c0a8da88..c76bc5c26f3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -247,7 +247,7 @@ aioskybell==22.6.1 aioslimproto==2.0.1 # homeassistant.components.steamist -aiosteamist==0.3.1 +aiosteamist==0.3.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33763e0b892..7c50b7ee62b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -216,7 +216,7 @@ aioskybell==22.6.1 aioslimproto==2.0.1 # homeassistant.components.steamist -aiosteamist==0.3.1 +aiosteamist==0.3.2 # homeassistant.components.switcher_kis aioswitcher==2.0.6 From e67f8720e81e8618370aaf3e6a36b8b1553b9e9a Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Sat, 25 Jun 2022 11:15:38 -0400 Subject: [PATCH 686/947] Refactor UniFi Protect tests (#73971) Co-authored-by: J. Nick Koston --- tests/components/unifiprotect/conftest.py | 327 +++++++------ .../unifiprotect/fixtures/sample_camera.json | 54 +-- .../unifiprotect/fixtures/sample_chime.json | 2 +- .../fixtures/sample_doorlock.json | 6 +- .../unifiprotect/fixtures/sample_light.json | 14 +- .../unifiprotect/fixtures/sample_sensor.json | 24 +- .../unifiprotect/test_binary_sensor.py | 320 +++---------- tests/components/unifiprotect/test_button.py | 45 +- tests/components/unifiprotect/test_camera.py | 408 ++++++----------- .../unifiprotect/test_config_flow.py | 54 ++- .../unifiprotect/test_diagnostics.py | 47 +- tests/components/unifiprotect/test_init.py | 165 +++---- tests/components/unifiprotect/test_light.py | 98 ++-- tests/components/unifiprotect/test_lock.py | 174 ++++--- .../unifiprotect/test_media_player.py | 197 ++++---- tests/components/unifiprotect/test_migrate.py | 222 ++++----- tests/components/unifiprotect/test_number.py | 192 ++------ tests/components/unifiprotect/test_select.py | 429 +++++++----------- tests/components/unifiprotect/test_sensor.py | 371 ++++++--------- .../components/unifiprotect/test_services.py | 96 +--- tests/components/unifiprotect/test_switch.py | 416 ++++++----------- tests/components/unifiprotect/utils.py | 168 +++++++ 22 files changed, 1535 insertions(+), 2294 deletions(-) create mode 100644 tests/components/unifiprotect/utils.py diff --git a/tests/components/unifiprotect/conftest.py b/tests/components/unifiprotect/conftest.py index 68945ac0988..51cef190e2f 100644 --- a/tests/components/unifiprotect/conftest.py +++ b/tests/components/unifiprotect/conftest.py @@ -3,14 +3,14 @@ from __future__ import annotations from collections.abc import Callable -from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta from ipaddress import IPv4Address import json from typing import Any from unittest.mock import AsyncMock, Mock, patch import pytest +from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( NVR, Bootstrap, @@ -19,37 +19,27 @@ from pyunifiprotect.data import ( Doorlock, Light, Liveview, - ProtectAdoptableDeviceModel, Sensor, + SmartDetectObjectType, + VideoMode, Viewer, WSSubscriptionMessage, ) -from pyunifiprotect.test_util.anonymize import random_hex from homeassistant.components.unifiprotect.const import DOMAIN -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity import EntityDescription +from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util from . import _patch_discovery +from .utils import MockUFPFixture -from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture +from tests.common import MockConfigEntry, load_fixture MAC_ADDR = "aa:bb:cc:dd:ee:ff" -@dataclass -class MockEntityFixture: - """Mock for NVR.""" - - entry: MockConfigEntry - api: Mock - - -@pytest.fixture(name="mock_nvr") -def mock_nvr_fixture(): +@pytest.fixture(name="nvr") +def mock_nvr(): """Mock UniFi Protect Camera device.""" data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) @@ -63,7 +53,7 @@ def mock_nvr_fixture(): NVR.__config__.validate_assignment = True -@pytest.fixture(name="mock_ufp_config_entry") +@pytest.fixture(name="ufp_config_entry") def mock_ufp_config_entry(): """Mock the unifiprotect config entry.""" @@ -81,8 +71,8 @@ def mock_ufp_config_entry(): ) -@pytest.fixture(name="mock_old_nvr") -def mock_old_nvr_fixture(): +@pytest.fixture(name="old_nvr") +def old_nvr(): """Mock UniFi Protect Camera device.""" data = json.loads(load_fixture("sample_nvr.json", integration=DOMAIN)) @@ -90,11 +80,11 @@ def mock_old_nvr_fixture(): return NVR.from_unifi_dict(**data) -@pytest.fixture(name="mock_bootstrap") -def mock_bootstrap_fixture(mock_nvr: NVR): +@pytest.fixture(name="bootstrap") +def bootstrap_fixture(nvr: NVR): """Mock Bootstrap fixture.""" data = json.loads(load_fixture("sample_bootstrap.json", integration=DOMAIN)) - data["nvr"] = mock_nvr + data["nvr"] = nvr data["cameras"] = [] data["lights"] = [] data["sensors"] = [] @@ -107,24 +97,11 @@ def mock_bootstrap_fixture(mock_nvr: NVR): return Bootstrap.from_unifi_dict(**data) -def reset_objects(bootstrap: Bootstrap): - """Reset bootstrap objects.""" - - bootstrap.cameras = {} - bootstrap.lights = {} - bootstrap.sensors = {} - bootstrap.viewers = {} - bootstrap.liveviews = {} - bootstrap.events = {} - bootstrap.doorlocks = {} - bootstrap.chimes = {} - - -@pytest.fixture -def mock_client(mock_bootstrap: Bootstrap): +@pytest.fixture(name="ufp_client") +def mock_ufp_client(bootstrap: Bootstrap): """Mock ProtectApiClient for testing.""" client = Mock() - client.bootstrap = mock_bootstrap + client.bootstrap = bootstrap nvr = client.bootstrap.nvr nvr._api = client @@ -133,161 +110,227 @@ def mock_client(mock_bootstrap: Bootstrap): client.base_url = "https://127.0.0.1" client.connection_host = IPv4Address("127.0.0.1") client.get_nvr = AsyncMock(return_value=nvr) - client.update = AsyncMock(return_value=mock_bootstrap) + client.update = AsyncMock(return_value=bootstrap) client.async_disconnect_ws = AsyncMock() - - def subscribe(ws_callback: Callable[[WSSubscriptionMessage], None]) -> Any: - client.ws_subscription = ws_callback - - return Mock() - - client.subscribe_websocket = subscribe return client -@pytest.fixture +@pytest.fixture(name="ufp") def mock_entry( - hass: HomeAssistant, - mock_ufp_config_entry: MockConfigEntry, - mock_client, # pylint: disable=redefined-outer-name + hass: HomeAssistant, ufp_config_entry: MockConfigEntry, ufp_client: ProtectApiClient ): """Mock ProtectApiClient for testing.""" with _patch_discovery(no_device=True), patch( "homeassistant.components.unifiprotect.ProtectApiClient" ) as mock_api: - mock_ufp_config_entry.add_to_hass(hass) + ufp_config_entry.add_to_hass(hass) - mock_api.return_value = mock_client + mock_api.return_value = ufp_client - yield MockEntityFixture(mock_ufp_config_entry, mock_client) + ufp = MockUFPFixture(ufp_config_entry, ufp_client) + + def subscribe(ws_callback: Callable[[WSSubscriptionMessage], None]) -> Any: + ufp.ws_subscription = ws_callback + return Mock() + + ufp_client.subscribe_websocket = subscribe + yield ufp @pytest.fixture -def mock_liveview(): +def liveview(): """Mock UniFi Protect Liveview.""" data = json.loads(load_fixture("sample_liveview.json", integration=DOMAIN)) return Liveview.from_unifi_dict(**data) -@pytest.fixture -def mock_camera(): +@pytest.fixture(name="camera") +def camera_fixture(fixed_now: datetime): """Mock UniFi Protect Camera device.""" + # disable pydantic validation so mocking can happen + Camera.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_camera.json", integration=DOMAIN)) - return Camera.from_unifi_dict(**data) + camera = Camera.from_unifi_dict(**data) + camera.last_motion = fixed_now - timedelta(hours=1) + + yield camera + + Camera.__config__.validate_assignment = True + + +@pytest.fixture(name="camera_all") +def camera_all_fixture(camera: Camera): + """Mock UniFi Protect Camera device.""" + + all_camera = camera.copy() + all_camera.channels = [all_camera.channels[0].copy()] + + medium_channel = all_camera.channels[0].copy() + medium_channel.name = "Medium" + medium_channel.id = 1 + medium_channel.rtsp_alias = "test_medium_alias" + all_camera.channels.append(medium_channel) + + low_channel = all_camera.channels[0].copy() + low_channel.name = "Low" + low_channel.id = 2 + low_channel.rtsp_alias = "test_medium_alias" + all_camera.channels.append(low_channel) + + return all_camera + + +@pytest.fixture(name="doorbell") +def doorbell_fixture(camera: Camera, fixed_now: datetime): + """Mock UniFi Protect Camera device (with chime).""" + + doorbell = camera.copy() + doorbell.channels = [c.copy() for c in doorbell.channels] + + package_channel = doorbell.channels[0].copy() + package_channel.name = "Package Camera" + package_channel.id = 3 + package_channel.fps = 2 + package_channel.rtsp_alias = "test_package_alias" + + doorbell.channels.append(package_channel) + doorbell.feature_flags.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS] + doorbell.feature_flags.smart_detect_types = [ + SmartDetectObjectType.PERSON, + SmartDetectObjectType.VEHICLE, + ] + doorbell.feature_flags.has_hdr = True + doorbell.feature_flags.has_lcd_screen = True + doorbell.feature_flags.has_speaker = True + doorbell.feature_flags.has_privacy_mask = True + doorbell.feature_flags.has_chime = True + doorbell.feature_flags.has_smart_detect = True + doorbell.feature_flags.has_package_camera = True + doorbell.feature_flags.has_led_status = True + doorbell.last_ring = fixed_now - timedelta(hours=1) + return doorbell @pytest.fixture -def mock_light(): +def unadopted_camera(camera: Camera): + """Mock UniFi Protect Camera device (unadopted).""" + + no_camera = camera.copy() + no_camera.channels = [c.copy() for c in no_camera.channels] + no_camera.name = "Unadopted Camera" + no_camera.is_adopted = False + return no_camera + + +@pytest.fixture(name="light") +def light_fixture(): """Mock UniFi Protect Light device.""" + # disable pydantic validation so mocking can happen + Light.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_light.json", integration=DOMAIN)) - return Light.from_unifi_dict(**data) + yield Light.from_unifi_dict(**data) + + Light.__config__.validate_assignment = True @pytest.fixture -def mock_viewer(): +def unadopted_light(light: Light): + """Mock UniFi Protect Light device (unadopted).""" + + no_light = light.copy() + no_light.name = "Unadopted Light" + no_light.is_adopted = False + return no_light + + +@pytest.fixture +def viewer(): """Mock UniFi Protect Viewport device.""" + # disable pydantic validation so mocking can happen + Viewer.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_viewport.json", integration=DOMAIN)) - return Viewer.from_unifi_dict(**data) + yield Viewer.from_unifi_dict(**data) + + Viewer.__config__.validate_assignment = True -@pytest.fixture -def mock_sensor(): +@pytest.fixture(name="sensor") +def sensor_fixture(fixed_now: datetime): """Mock UniFi Protect Sensor device.""" + # disable pydantic validation so mocking can happen + Sensor.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_sensor.json", integration=DOMAIN)) - return Sensor.from_unifi_dict(**data) + sensor: Sensor = Sensor.from_unifi_dict(**data) + sensor.motion_detected_at = fixed_now - timedelta(hours=1) + sensor.open_status_changed_at = fixed_now - timedelta(hours=1) + sensor.alarm_triggered_at = fixed_now - timedelta(hours=1) + yield sensor + + Sensor.__config__.validate_assignment = True -@pytest.fixture -def mock_doorlock(): +@pytest.fixture(name="sensor_all") +def csensor_all_fixture(sensor: Sensor): + """Mock UniFi Protect Sensor device.""" + + all_sensor = sensor.copy() + all_sensor.light_settings.is_enabled = True + all_sensor.humidity_settings.is_enabled = True + all_sensor.temperature_settings.is_enabled = True + all_sensor.alarm_settings.is_enabled = True + all_sensor.led_settings.is_enabled = True + all_sensor.motion_settings.is_enabled = True + + return all_sensor + + +@pytest.fixture(name="doorlock") +def doorlock_fixture(): """Mock UniFi Protect Doorlock device.""" + # disable pydantic validation so mocking can happen + Doorlock.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_doorlock.json", integration=DOMAIN)) - return Doorlock.from_unifi_dict(**data) + yield Doorlock.from_unifi_dict(**data) + + Doorlock.__config__.validate_assignment = True @pytest.fixture -def mock_chime(): +def unadopted_doorlock(doorlock: Doorlock): + """Mock UniFi Protect Light device (unadopted).""" + + no_doorlock = doorlock.copy() + no_doorlock.name = "Unadopted Lock" + no_doorlock.is_adopted = False + return no_doorlock + + +@pytest.fixture +def chime(): """Mock UniFi Protect Chime device.""" + # disable pydantic validation so mocking can happen + Chime.__config__.validate_assignment = False + data = json.loads(load_fixture("sample_chime.json", integration=DOMAIN)) - return Chime.from_unifi_dict(**data) + yield Chime.from_unifi_dict(**data) + + Chime.__config__.validate_assignment = True -@pytest.fixture -def now(): +@pytest.fixture(name="fixed_now") +def fixed_now_fixture(): """Return datetime object that will be consistent throughout test.""" return dt_util.utcnow() - - -async def time_changed(hass: HomeAssistant, seconds: int) -> None: - """Trigger time changed.""" - next_update = dt_util.utcnow() + timedelta(seconds) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() - - -async def enable_entity( - hass: HomeAssistant, entry_id: str, entity_id: str -) -> er.RegistryEntry: - """Enable a disabled entity.""" - entity_registry = er.async_get(hass) - - updated_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) - assert not updated_entity.disabled - await hass.config_entries.async_reload(entry_id) - await hass.async_block_till_done() - - return updated_entity - - -def assert_entity_counts( - hass: HomeAssistant, platform: Platform, total: int, enabled: int -) -> None: - """Assert entity counts for a given platform.""" - - entity_registry = er.async_get(hass) - - entities = [ - e for e in entity_registry.entities if split_entity_id(e)[0] == platform.value - ] - - assert len(entities) == total - assert len(hass.states.async_all(platform.value)) == enabled - - -def ids_from_device_description( - platform: Platform, - device: ProtectAdoptableDeviceModel, - description: EntityDescription, -) -> tuple[str, str]: - """Return expected unique_id and entity_id for a give platform/device/description combination.""" - - entity_name = ( - device.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") - ) - description_entity_name = ( - description.name.lower().replace(":", "").replace(" ", "_").replace("-", "_") - ) - - unique_id = f"{device.mac}_{description.key}" - entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" - - return unique_id, entity_id - - -def generate_random_ids() -> tuple[str, str]: - """Generate random IDs for device.""" - - return random_hex(24).lower(), random_hex(12).upper() - - -def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None: - """Regenerate the IDs on UFP device.""" - - device.id, device.mac = generate_random_ids() diff --git a/tests/components/unifiprotect/fixtures/sample_camera.json b/tests/components/unifiprotect/fixtures/sample_camera.json index eb07c6df63b..e7ffbd0abcc 100644 --- a/tests/components/unifiprotect/fixtures/sample_camera.json +++ b/tests/components/unifiprotect/fixtures/sample_camera.json @@ -4,7 +4,7 @@ "host": "192.168.6.90", "connectionHost": "192.168.178.217", "type": "UVC G4 Instant", - "name": "Fufail Qqjx", + "name": "Test Camera", "upSince": 1640020678036, "uptime": 3203, "lastSeen": 1640023881036, @@ -20,18 +20,18 @@ "isAdoptedByOther": false, "isProvisioned": true, "isRebooting": false, - "isSshEnabled": true, + "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "lastMotion": 1640021213927, - "micVolume": 100, + "micVolume": 0, "isMicEnabled": true, "isRecording": false, "isWirelessUplinkEnabled": true, "isMotionDetected": false, "isSmartDetected": false, "phyRate": 72, - "hdrMode": true, + "hdrMode": false, "videoMode": "default", "isProbingForWifi": false, "apMac": null, @@ -57,18 +57,18 @@ } }, "videoReconfigurationInProgress": false, - "voltage": null, + "voltage": 20.0, "wiredConnectionState": { - "phyRate": null + "phyRate": 1000 }, "channels": [ { "id": 0, "videoId": "video1", - "name": "Jzi Bftu", + "name": "High", "enabled": true, "isRtspEnabled": true, - "rtspAlias": "ANOAPfoKMW7VixG1", + "rtspAlias": "test_high_alias", "width": 2688, "height": 1512, "fps": 30, @@ -83,10 +83,10 @@ { "id": 1, "videoId": "video2", - "name": "Rgcpxsf Xfwt", + "name": "Medium", "enabled": true, - "isRtspEnabled": true, - "rtspAlias": "XHXAdHVKGVEzMNTP", + "isRtspEnabled": false, + "rtspAlias": null, "width": 1280, "height": 720, "fps": 30, @@ -101,7 +101,7 @@ { "id": 2, "videoId": "video3", - "name": "Umefvk Fug", + "name": "Low", "enabled": true, "isRtspEnabled": false, "rtspAlias": null, @@ -121,7 +121,7 @@ "aeMode": "auto", "irLedMode": "auto", "irLedLevel": 255, - "wdr": 1, + "wdr": 0, "icrSensitivity": 0, "brightness": 50, "contrast": 50, @@ -161,8 +161,8 @@ "quality": 100 }, "osdSettings": { - "isNameEnabled": true, - "isDateEnabled": true, + "isNameEnabled": false, + "isDateEnabled": false, "isLogoEnabled": false, "isDebugEnabled": false }, @@ -181,7 +181,7 @@ "minMotionEventTrigger": 1000, "endMotionEventDelay": 3000, "suppressIlluminationSurge": false, - "mode": "detections", + "mode": "always", "geofencing": "off", "motionAlgorithm": "enhanced", "enablePirTimelapse": false, @@ -223,8 +223,8 @@ ], "smartDetectLines": [], "stats": { - "rxBytes": 33684237, - "txBytes": 1208318620, + "rxBytes": 100, + "txBytes": 100, "wifi": { "channel": 6, "frequency": 2437, @@ -248,8 +248,8 @@ "timelapseEndLQ": 1640021765237 }, "storage": { - "used": 20401094656, - "rate": 693.424269097809 + "used": 100, + "rate": 0.1 }, "wifiQuality": 100, "wifiStrength": -35 @@ -257,7 +257,7 @@ "featureFlags": { "canAdjustIrLedLevel": false, "canMagicZoom": false, - "canOpticalZoom": false, + "canOpticalZoom": true, "canTouchFocus": false, "hasAccelerometer": true, "hasAec": true, @@ -268,15 +268,15 @@ "hasIcrSensitivity": true, "hasLdc": false, "hasLedIr": true, - "hasLedStatus": true, + "hasLedStatus": false, "hasLineIn": false, "hasMic": true, - "hasPrivacyMask": true, + "hasPrivacyMask": false, "hasRtc": false, "hasSdCard": false, - "hasSpeaker": true, + "hasSpeaker": false, "hasWifi": true, - "hasHdr": true, + "hasHdr": false, "hasAutoICROnly": true, "videoModes": ["default"], "videoModeMaxFps": [], @@ -353,14 +353,14 @@ "frequency": 2437, "phyRate": 72, "signalQuality": 100, - "signalStrength": -35, + "signalStrength": -50, "ssid": "Mortis Camera" }, "lenses": [], "id": "0de062b4f6922d489d3b312d", "isConnected": true, "platform": "sav530q", - "hasSpeaker": true, + "hasSpeaker": false, "hasWifi": true, "audioBitrate": 64000, "canManage": false, diff --git a/tests/components/unifiprotect/fixtures/sample_chime.json b/tests/components/unifiprotect/fixtures/sample_chime.json index 975cfcebaea..4a2637fc700 100644 --- a/tests/components/unifiprotect/fixtures/sample_chime.json +++ b/tests/components/unifiprotect/fixtures/sample_chime.json @@ -3,7 +3,7 @@ "host": "192.168.144.146", "connectionHost": "192.168.234.27", "type": "UP Chime", - "name": "Xaorvu Tvsv", + "name": "Test Chime", "upSince": 1651882870009, "uptime": 567870, "lastSeen": 1652450740009, diff --git a/tests/components/unifiprotect/fixtures/sample_doorlock.json b/tests/components/unifiprotect/fixtures/sample_doorlock.json index 12cd7858e9d..a2e2ba0ab89 100644 --- a/tests/components/unifiprotect/fixtures/sample_doorlock.json +++ b/tests/components/unifiprotect/fixtures/sample_doorlock.json @@ -3,7 +3,7 @@ "host": null, "connectionHost": "192.168.102.63", "type": "UFP-LOCK-R", - "name": "Wkltg Qcjxv", + "name": "Test Lock", "upSince": 1643050461849, "uptime": null, "lastSeen": 1643052750858, @@ -23,9 +23,9 @@ "canAdopt": false, "isAttemptingToConnect": false, "credentials": "955756200c7f43936df9d5f7865f058e1528945aac0f0cb27cef960eb58f17db", - "lockStatus": "CLOSING", + "lockStatus": "OPEN", "enableHomekit": false, - "autoCloseTimeMs": 15000, + "autoCloseTimeMs": 45000, "wiredConnectionState": { "phyRate": null }, diff --git a/tests/components/unifiprotect/fixtures/sample_light.json b/tests/components/unifiprotect/fixtures/sample_light.json index ed0f89f3a11..ce7de9e852c 100644 --- a/tests/components/unifiprotect/fixtures/sample_light.json +++ b/tests/components/unifiprotect/fixtures/sample_light.json @@ -3,7 +3,7 @@ "host": "192.168.10.86", "connectionHost": "192.168.178.217", "type": "UP FloodLight", - "name": "Byyfbpe Ufoka", + "name": "Test Light", "upSince": 1638128991022, "uptime": 1894890, "lastSeen": 1640023881022, @@ -19,7 +19,7 @@ "isAdoptedByOther": false, "isProvisioned": false, "isRebooting": false, - "isSshEnabled": true, + "isSshEnabled": false, "canAdopt": false, "isAttemptingToConnect": false, "isPirMotionDetected": false, @@ -31,20 +31,20 @@ "phyRate": 100 }, "lightDeviceSettings": { - "isIndicatorEnabled": true, + "isIndicatorEnabled": false, "ledLevel": 6, "luxSensitivity": "medium", - "pirDuration": 120000, - "pirSensitivity": 46 + "pirDuration": 45000, + "pirSensitivity": 45 }, "lightOnSettings": { "isLedForceOn": false }, "lightModeSettings": { - "mode": "off", + "mode": "motion", "enableAt": "fulltime" }, - "camera": "193be66559c03ec5629f54cd", + "camera": null, "id": "37dd610720816cfb5c547967", "isConnected": true, "isCameraPaired": true, diff --git a/tests/components/unifiprotect/fixtures/sample_sensor.json b/tests/components/unifiprotect/fixtures/sample_sensor.json index 08ce9a17be2..cbba1f7583e 100644 --- a/tests/components/unifiprotect/fixtures/sample_sensor.json +++ b/tests/components/unifiprotect/fixtures/sample_sensor.json @@ -2,7 +2,7 @@ "mac": "26DBAFF133A4", "connectionHost": "192.168.216.198", "type": "UFP-SENSE", - "name": "Egdczv Urg", + "name": "Test Sensor", "upSince": 1641256963255, "uptime": null, "lastSeen": 1641259127934, @@ -25,7 +25,7 @@ "mountType": "door", "leakDetectedAt": null, "tamperingDetectedAt": null, - "isOpened": true, + "isOpened": false, "openStatusChangedAt": 1641269036582, "alarmTriggeredAt": null, "motionDetectedAt": 1641269044824, @@ -34,53 +34,53 @@ }, "stats": { "light": { - "value": 0, + "value": 10.0, "status": "neutral" }, "humidity": { - "value": 35, + "value": 10.0, "status": "neutral" }, "temperature": { - "value": 17.23, + "value": 10.0, "status": "neutral" } }, "bluetoothConnectionState": { "signalQuality": 15, - "signalStrength": -84 + "signalStrength": -50 }, "batteryStatus": { - "percentage": 100, + "percentage": 10, "isLow": false }, "alarmSettings": { "isEnabled": false }, "lightSettings": { - "isEnabled": true, + "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 10 }, "motionSettings": { - "isEnabled": true, + "isEnabled": false, "sensitivity": 100 }, "temperatureSettings": { - "isEnabled": true, + "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 0.1 }, "humiditySettings": { - "isEnabled": true, + "isEnabled": false, "lowThreshold": null, "highThreshold": null, "margin": 1 }, "ledSettings": { - "isEnabled": true + "isEnabled": false }, "bridge": "61b3f5c90050a703e700042a", "camera": "2f9beb2e6f79af3c32c22d49", diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index da9969ad868..856c034905f 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -2,11 +2,9 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from datetime import datetime, timedelta from unittest.mock import Mock -import pytest from pyunifiprotect.data import Camera, Event, EventType, Light, MountType, Sensor from pyunifiprotect.data.nvr import EventMetadata @@ -32,218 +30,25 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, assert_entity_counts, ids_from_device_description, - regenerate_device_ids, - reset_objects, + init_entry, ) LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] SENSE_SENSORS_WRITE = SENSE_SENSORS[:4] -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_camera: Camera, - now: datetime, -): - """Fixture for a single camera for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_chime = True - camera_obj.last_ring = now - timedelta(hours=1) - camera_obj.is_dark = False - camera_obj.is_motion_detected = False - regenerate_device_ids(camera_obj) - - no_camera_obj = mock_camera.copy() - no_camera_obj._api = mock_entry.api - no_camera_obj.channels[0]._api = mock_entry.api - no_camera_obj.channels[1]._api = mock_entry.api - no_camera_obj.channels[2]._api = mock_entry.api - no_camera_obj.name = "Unadopted Camera" - no_camera_obj.is_adopted = False - regenerate_device_ids(no_camera_obj) - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - no_camera_obj.id: no_camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 9) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light, now: datetime -): - """Fixture for a single light for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy() - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.is_dark = False - light_obj.is_pir_motion_detected = False - light_obj.last_motion = now - timedelta(hours=1) - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) - - yield light_obj - - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_none") -async def camera_none_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_chime = False - camera_obj.is_dark = False - camera_obj.is_motion_detected = False - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.nvr.system_info.ustorage = None - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="sensor") -async def sensor_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, -): - """Fixture for a single sensor for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy() - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.mount_type = MountType.DOOR - sensor_obj.is_opened = False - sensor_obj.battery_status.is_low = False - sensor_obj.is_motion_detected = False - sensor_obj.alarm_settings.is_enabled = True - sensor_obj.motion_detected_at = now - timedelta(hours=1) - sensor_obj.open_status_changed_at = now - timedelta(hours=1) - sensor_obj.alarm_triggered_at = now - timedelta(hours=1) - sensor_obj.tampering_detected_at = None - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) - - yield sensor_obj - - Sensor.__config__.validate_assignment = True - - -@pytest.fixture(name="sensor_none") -async def sensor_none_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, -): - """Fixture for a single sensor for testing the binary_sensor platform.""" - - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy() - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.mount_type = MountType.LEAK - sensor_obj.battery_status.is_low = False - sensor_obj.alarm_settings.is_enabled = False - sensor_obj.tampering_detected_at = None - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) - - yield sensor_obj - - Sensor.__config__.validate_assignment = True - - async def test_binary_sensor_setup_light( - hass: HomeAssistant, light: Light, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test binary_sensor entity setup for light devices.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + entity_registry = er.async_get(hass) for description in LIGHT_SENSOR_WRITE: @@ -262,15 +67,22 @@ async def test_binary_sensor_setup_light( async def test_binary_sensor_setup_camera_all( - hass: HomeAssistant, camera: Camera, now: datetime + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test binary_sensor entity setup for camera devices (all features).""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + entity_registry = er.async_get(hass) description = CAMERA_SENSORS[0] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -285,7 +97,7 @@ async def test_binary_sensor_setup_camera_all( # Is Dark description = CAMERA_SENSORS[1] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -300,7 +112,7 @@ async def test_binary_sensor_setup_camera_all( # Motion description = MOTION_SENSORS[0] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, description + Platform.BINARY_SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -315,16 +127,19 @@ async def test_binary_sensor_setup_camera_all( async def test_binary_sensor_setup_camera_none( - hass: HomeAssistant, - camera_none: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test binary_sensor entity setup for camera devices (no features).""" + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + entity_registry = er.async_get(hass) description = CAMERA_SENSORS[1] unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera_none, description + Platform.BINARY_SENSOR, camera, description ) entity = entity_registry.async_get(entity_id) @@ -338,15 +153,18 @@ async def test_binary_sensor_setup_camera_none( async def test_binary_sensor_setup_sensor( - hass: HomeAssistant, sensor: Sensor, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test binary_sensor entity setup for sensor devices.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + entity_registry = er.async_get(hass) for description in SENSE_SENSORS_WRITE: unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, description + Platform.BINARY_SENSOR, sensor_all, description ) entity = entity_registry.async_get(entity_id) @@ -360,10 +178,14 @@ async def test_binary_sensor_setup_sensor( async def test_binary_sensor_setup_sensor_none( - hass: HomeAssistant, sensor_none: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor ): """Test binary_sensor entity setup for sensor with most sensors disabled.""" + sensor.mount_type = MountType.LEAK + await init_entry(hass, ufp, [sensor]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + entity_registry = er.async_get(hass) expected = [ @@ -374,7 +196,7 @@ async def test_binary_sensor_setup_sensor_none( ] for index, description in enumerate(SENSE_SENSORS_WRITE): unique_id, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor_none, description + Platform.BINARY_SENSOR, sensor, description ) entity = entity_registry.async_get(entity_id) @@ -388,27 +210,33 @@ async def test_binary_sensor_setup_sensor_none( async def test_binary_sensor_update_motion( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, + fixed_now: datetime, ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 9, 9) + _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, camera, MOTION_SENSORS[0] + Platform.BINARY_SENSOR, doorbell, MOTION_SENSORS[0] ) event = Event( id="test_event_id", type=EventType.MOTION, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[], smart_detect_event_ids=[], - camera_id=camera.id, + camera_id=doorbell.id, ) - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera.copy() + new_camera = doorbell.copy() new_camera.is_motion_detected = True new_camera.last_motion_event_id = event.id @@ -416,10 +244,9 @@ async def test_binary_sensor_update_motion( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -430,10 +257,13 @@ async def test_binary_sensor_update_motion( async def test_binary_sensor_update_light_motion( - hass: HomeAssistant, mock_entry: MockEntityFixture, light: Light, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, fixed_now: datetime ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 8, 8) + _, entity_id = ids_from_device_description( Platform.BINARY_SENSOR, light, LIGHT_SENSOR_WRITE[1] ) @@ -442,16 +272,15 @@ async def test_binary_sensor_update_light_motion( event = Event( id="test_event_id", type=EventType.MOTION_LIGHT, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[], smart_detect_event_ids=[], metadata=event_metadata, - api=mock_entry.api, + api=ufp.api, ) - new_bootstrap = copy(mock_entry.api.bootstrap) new_light = light.copy() new_light.is_pir_motion_detected = True new_light.last_motion_event_id = event.id @@ -460,10 +289,9 @@ async def test_binary_sensor_update_light_motion( mock_msg.changed_data = {} mock_msg.new_obj = event - new_bootstrap.lights = {new_light.id: new_light} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.lights = {new_light.id: new_light} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -472,29 +300,30 @@ async def test_binary_sensor_update_light_motion( async def test_binary_sensor_update_mount_type_window( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, SENSE_SENSORS_WRITE[0] + Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] ) state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_bootstrap = copy(mock_entry.api.bootstrap) - new_sensor = sensor.copy() + new_sensor = sensor_all.copy() new_sensor.mount_type = MountType.WINDOW mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_sensor - new_bootstrap.sensors = {new_sensor.id: new_sensor} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.sensors = {new_sensor.id: new_sensor} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -503,29 +332,30 @@ async def test_binary_sensor_update_mount_type_window( async def test_binary_sensor_update_mount_type_garage( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test binary_sensor motion entity.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 10, 10) + _, entity_id = ids_from_device_description( - Platform.BINARY_SENSOR, sensor, SENSE_SENSORS_WRITE[0] + Platform.BINARY_SENSOR, sensor_all, SENSE_SENSORS_WRITE[0] ) state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.DOOR.value - new_bootstrap = copy(mock_entry.api.bootstrap) - new_sensor = sensor.copy() + new_sensor = sensor_all.copy() new_sensor.mount_type = MountType.GARAGE mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_sensor - new_bootstrap.sensors = {new_sensor.id: new_sensor} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.sensors = {new_sensor.id: new_sensor} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index 9a1c7009660..a846214a7aa 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -4,7 +4,6 @@ from __future__ import annotations from unittest.mock import AsyncMock -import pytest from pyunifiprotect.data.devices import Chime from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -12,39 +11,20 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts, enable_entity - - -@pytest.fixture(name="chime") -async def chime_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_chime: Chime -): - """Fixture for a single camera for testing the button platform.""" - - chime_obj = mock_chime.copy() - chime_obj._api = mock_entry.api - chime_obj.name = "Test Chime" - - mock_entry.api.bootstrap.chimes = { - chime_obj.id: chime_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.BUTTON, 3, 2) - - return chime_obj +from .utils import MockUFPFixture, assert_entity_counts, enable_entity, init_entry async def test_reboot_button( hass: HomeAssistant, - mock_entry: MockEntityFixture, + ufp: MockUFPFixture, chime: Chime, ): """Test button entity.""" - mock_entry.api.reboot_device = AsyncMock() + await init_entry(hass, ufp, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 3, 2) + + ufp.api.reboot_device = AsyncMock() unique_id = f"{chime.mac}_reboot" entity_id = "button.test_chime_reboot_device" @@ -55,7 +35,7 @@ async def test_reboot_button( assert entity.disabled assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION @@ -63,17 +43,20 @@ async def test_reboot_button( await hass.services.async_call( "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - mock_entry.api.reboot_device.assert_called_once() + ufp.api.reboot_device.assert_called_once() async def test_chime_button( hass: HomeAssistant, - mock_entry: MockEntityFixture, + ufp: MockUFPFixture, chime: Chime, ): """Test button entity.""" - mock_entry.api.play_speaker = AsyncMock() + await init_entry(hass, ufp, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 3, 2) + + ufp.api.play_speaker = AsyncMock() unique_id = f"{chime.mac}_play" entity_id = "button.test_chime_play_chime" @@ -91,4 +74,4 @@ async def test_chime_button( await hass.services.async_call( "button", "press", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - mock_entry.api.play_speaker.assert_called_once() + ufp.api.play_speaker.assert_called_once() diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 66da8e8ec04..6fad7cb899e 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -2,16 +2,13 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import Camera as ProtectCamera, CameraChannel, StateType from pyunifiprotect.exceptions import NvrError from homeassistant.components.camera import ( SUPPORT_STREAM, - Camera, async_get_image, async_get_stream_source, ) @@ -34,87 +31,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, assert_entity_counts, enable_entity, - regenerate_device_ids, + init_entry, time_changed, ) -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the camera platform.""" - - # disable pydantic validation so mocking can happen - ProtectCamera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.channels[0].is_rtsp_enabled = True - camera_obj.channels[0].name = "High" - camera_obj.channels[1].is_rtsp_enabled = False - camera_obj.channels[2].is_rtsp_enabled = False - - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.CAMERA, 2, 1) - - yield (camera_obj, "camera.test_camera_high") - - ProtectCamera.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_package") -async def camera_package_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the camera platform.""" - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_package_camera = True - camera_obj.channels[0].is_rtsp_enabled = True - camera_obj.channels[0].name = "High" - camera_obj.channels[0].rtsp_alias = "test_high_alias" - camera_obj.channels[1].is_rtsp_enabled = False - camera_obj.channels[2].is_rtsp_enabled = False - package_channel = camera_obj.channels[0].copy() - package_channel.is_rtsp_enabled = False - package_channel.name = "Package Camera" - package_channel.id = 3 - package_channel.fps = 2 - package_channel.rtsp_alias = "test_package_alias" - camera_obj.channels.append(package_channel) - - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.CAMERA, 3, 2) - - return (camera_obj, "camera.test_camera_package_camera") - - def validate_default_camera_entity( hass: HomeAssistant, camera_obj: ProtectCamera, @@ -242,99 +167,46 @@ async def validate_no_stream_camera_state( async def test_basic_setup( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera + hass: HomeAssistant, + ufp: MockUFPFixture, + camera_all: ProtectCamera, + doorbell: ProtectCamera, ): """Test working setup of unifiprotect entry.""" - camera_high_only = mock_camera.copy() - camera_high_only._api = mock_entry.api - camera_high_only.channels = [c.copy() for c in mock_camera.channels] - camera_high_only.channels[0]._api = mock_entry.api - camera_high_only.channels[1]._api = mock_entry.api - camera_high_only.channels[2]._api = mock_entry.api + camera_high_only = camera_all.copy() + camera_high_only.channels = [c.copy() for c in camera_all.channels] camera_high_only.name = "Test Camera 1" camera_high_only.channels[0].is_rtsp_enabled = True - camera_high_only.channels[0].name = "High" - camera_high_only.channels[0].rtsp_alias = "test_high_alias" camera_high_only.channels[1].is_rtsp_enabled = False camera_high_only.channels[2].is_rtsp_enabled = False - regenerate_device_ids(camera_high_only) - camera_medium_only = mock_camera.copy() - camera_medium_only._api = mock_entry.api - camera_medium_only.channels = [c.copy() for c in mock_camera.channels] - camera_medium_only.channels[0]._api = mock_entry.api - camera_medium_only.channels[1]._api = mock_entry.api - camera_medium_only.channels[2]._api = mock_entry.api + camera_medium_only = camera_all.copy() + camera_medium_only.channels = [c.copy() for c in camera_all.channels] camera_medium_only.name = "Test Camera 2" camera_medium_only.channels[0].is_rtsp_enabled = False camera_medium_only.channels[1].is_rtsp_enabled = True - camera_medium_only.channels[1].name = "Medium" - camera_medium_only.channels[1].rtsp_alias = "test_medium_alias" camera_medium_only.channels[2].is_rtsp_enabled = False - regenerate_device_ids(camera_medium_only) - camera_all_channels = mock_camera.copy() - camera_all_channels._api = mock_entry.api - camera_all_channels.channels = [c.copy() for c in mock_camera.channels] - camera_all_channels.channels[0]._api = mock_entry.api - camera_all_channels.channels[1]._api = mock_entry.api - camera_all_channels.channels[2]._api = mock_entry.api - camera_all_channels.name = "Test Camera 3" - camera_all_channels.channels[0].is_rtsp_enabled = True - camera_all_channels.channels[0].name = "High" - camera_all_channels.channels[0].rtsp_alias = "test_high_alias" - camera_all_channels.channels[1].is_rtsp_enabled = True - camera_all_channels.channels[1].name = "Medium" - camera_all_channels.channels[1].rtsp_alias = "test_medium_alias" - camera_all_channels.channels[2].is_rtsp_enabled = True - camera_all_channels.channels[2].name = "Low" - camera_all_channels.channels[2].rtsp_alias = "test_low_alias" - regenerate_device_ids(camera_all_channels) + camera_all.name = "Test Camera 3" - camera_no_channels = mock_camera.copy() - camera_no_channels._api = mock_entry.api - camera_no_channels.channels = [c.copy() for c in camera_no_channels.channels] - camera_no_channels.channels[0]._api = mock_entry.api - camera_no_channels.channels[1]._api = mock_entry.api - camera_no_channels.channels[2]._api = mock_entry.api + camera_no_channels = camera_all.copy() + camera_no_channels.channels = [c.copy() for c in camera_all.channels] camera_no_channels.name = "Test Camera 4" camera_no_channels.channels[0].is_rtsp_enabled = False - camera_no_channels.channels[0].name = "High" camera_no_channels.channels[1].is_rtsp_enabled = False camera_no_channels.channels[2].is_rtsp_enabled = False - regenerate_device_ids(camera_no_channels) - camera_package = mock_camera.copy() - camera_package._api = mock_entry.api - camera_package.channels = [c.copy() for c in mock_camera.channels] - camera_package.channels[0]._api = mock_entry.api - camera_package.channels[1]._api = mock_entry.api - camera_package.channels[2]._api = mock_entry.api - camera_package.name = "Test Camera 5" - camera_package.channels[0].is_rtsp_enabled = True - camera_package.channels[0].name = "High" - camera_package.channels[0].rtsp_alias = "test_high_alias" - camera_package.channels[1].is_rtsp_enabled = False - camera_package.channels[2].is_rtsp_enabled = False - regenerate_device_ids(camera_package) - package_channel = camera_package.channels[0].copy() - package_channel.is_rtsp_enabled = False - package_channel.name = "Package Camera" - package_channel.id = 3 - package_channel.fps = 2 - package_channel.rtsp_alias = "test_package_alias" - camera_package.channels.append(package_channel) + doorbell.name = "Test Camera 5" - mock_entry.api.bootstrap.cameras = { - camera_high_only.id: camera_high_only, - camera_medium_only.id: camera_medium_only, - camera_all_channels.id: camera_all_channels, - camera_no_channels.id: camera_no_channels, - camera_package.id: camera_package, - } - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + devices = [ + camera_high_only, + camera_medium_only, + camera_all, + camera_no_channels, + doorbell, + ] + await init_entry(hass, ufp, devices) assert_entity_counts(hass, Platform.CAMERA, 14, 6) @@ -343,7 +215,7 @@ async def test_basic_setup( await validate_rtsps_camera_state(hass, camera_high_only, 0, entity_id) entity_id = validate_rtsp_camera_entity(hass, camera_high_only, 0) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) await validate_rtsp_camera_state(hass, camera_high_only, 0, entity_id) # test camera 2 @@ -351,32 +223,32 @@ async def test_basic_setup( await validate_rtsps_camera_state(hass, camera_medium_only, 1, entity_id) entity_id = validate_rtsp_camera_entity(hass, camera_medium_only, 1) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) await validate_rtsp_camera_state(hass, camera_medium_only, 1, entity_id) # test camera 3 - entity_id = validate_default_camera_entity(hass, camera_all_channels, 0) - await validate_rtsps_camera_state(hass, camera_all_channels, 0, entity_id) + entity_id = validate_default_camera_entity(hass, camera_all, 0) + await validate_rtsps_camera_state(hass, camera_all, 0, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 0) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_all_channels, 0, entity_id) + entity_id = validate_rtsp_camera_entity(hass, camera_all, 0) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all, 0, entity_id) - entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 1) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsps_camera_state(hass, camera_all_channels, 1, entity_id) + entity_id = validate_rtsps_camera_entity(hass, camera_all, 1) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsps_camera_state(hass, camera_all, 1, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 1) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_all_channels, 1, entity_id) + entity_id = validate_rtsp_camera_entity(hass, camera_all, 1) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all, 1, entity_id) - entity_id = validate_rtsps_camera_entity(hass, camera_all_channels, 2) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsps_camera_state(hass, camera_all_channels, 2, entity_id) + entity_id = validate_rtsps_camera_entity(hass, camera_all, 2) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsps_camera_state(hass, camera_all, 2, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_all_channels, 2) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_all_channels, 2, entity_id) + entity_id = validate_rtsp_camera_entity(hass, camera_all, 2) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, camera_all, 2, entity_id) # test camera 4 entity_id = validate_default_camera_entity(hass, camera_no_channels, 0) @@ -385,197 +257,194 @@ async def test_basic_setup( ) # test camera 5 - entity_id = validate_default_camera_entity(hass, camera_package, 0) - await validate_rtsps_camera_state(hass, camera_package, 0, entity_id) + entity_id = validate_default_camera_entity(hass, doorbell, 0) + await validate_rtsps_camera_state(hass, doorbell, 0, entity_id) - entity_id = validate_rtsp_camera_entity(hass, camera_package, 0) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - await validate_rtsp_camera_state(hass, camera_package, 0, entity_id) + entity_id = validate_rtsp_camera_entity(hass, doorbell, 0) + await enable_entity(hass, ufp.entry.entry_id, entity_id) + await validate_rtsp_camera_state(hass, doorbell, 0, entity_id) - entity_id = validate_default_camera_entity(hass, camera_package, 3) - await validate_no_stream_camera_state( - hass, camera_package, 3, entity_id, features=0 - ) + entity_id = validate_default_camera_entity(hass, doorbell, 3) + await validate_no_stream_camera_state(hass, doorbell, 3, entity_id, features=0) async def test_missing_channels( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: ProtectCamera + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Test setting up camera with no camera channels.""" - camera = mock_camera.copy() - camera.channels = [] + camera1 = camera.copy() + camera1.channels = [] - mock_entry.api.bootstrap.cameras = {camera.id: camera} + await init_entry(hass, ufp, [camera1]) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - entity_registry = er.async_get(hass) - - assert len(hass.states.async_all()) == 0 - assert len(entity_registry.entities) == 0 + assert_entity_counts(hass, Platform.CAMERA, 0, 0) async def test_camera_image( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Test retrieving camera image.""" - mock_entry.api.get_camera_snapshot = AsyncMock() + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) - await async_get_image(hass, camera[1]) - mock_entry.api.get_camera_snapshot.assert_called_once() + ufp.api.get_camera_snapshot = AsyncMock() + + await async_get_image(hass, "camera.test_camera_high") + ufp.api.get_camera_snapshot.assert_called_once() async def test_package_camera_image( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera_package: tuple[Camera, str], + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: ProtectCamera ): """Test retrieving package camera image.""" - mock_entry.api.get_package_camera_snapshot = AsyncMock() + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.CAMERA, 3, 2) - await async_get_image(hass, camera_package[1]) - mock_entry.api.get_package_camera_snapshot.assert_called_once() + ufp.api.get_package_camera_snapshot = AsyncMock() + + await async_get_image(hass, "camera.test_camera_package_camera") + ufp.api.get_package_camera_snapshot.assert_called_once() async def test_camera_generic_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Tests generic entity update service.""" + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + assert await async_setup_component(hass, "homeassistant", {}) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" - mock_entry.api.update = AsyncMock(return_value=None) + ufp.api.update = AsyncMock(return_value=None) await hass.services.async_call( "homeassistant", "update_entity", - {ATTR_ENTITY_ID: camera[1]}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" async def test_camera_interval_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Interval updates updates camera entity.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + new_camera = camera.copy() new_camera.is_recording = True - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.update = AsyncMock(return_value=new_bootstrap) - mock_entry.api.bootstrap = new_bootstrap + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap) await time_changed(hass, DEFAULT_SCAN_INTERVAL) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "recording" async def test_camera_bad_interval_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Interval updates marks camera unavailable.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" # update fails - mock_entry.api.update = AsyncMock(side_effect=NvrError) + ufp.api.update = AsyncMock(side_effect=NvrError) await time_changed(hass, DEFAULT_SCAN_INTERVAL) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "unavailable" # next update succeeds - mock_entry.api.update = AsyncMock(return_value=mock_entry.api.bootstrap) + ufp.api.update = AsyncMock(return_value=ufp.api.bootstrap) await time_changed(hass, DEFAULT_SCAN_INTERVAL) - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" async def test_camera_ws_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """WS update updates camera entity.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + new_camera = camera.copy() new_camera.is_recording = True - no_camera = camera[0].copy() + no_camera = camera.copy() no_camera.is_adopted = False - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_camera - mock_entry.api.ws_subscription(mock_msg) + ufp.ws_msg(mock_msg) mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = no_camera - mock_entry.api.ws_subscription(mock_msg) + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "recording" async def test_camera_ws_update_offline( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """WS updates marks camera unavailable.""" - state = hass.states.get(camera[1]) + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + state = hass.states.get(entity_id) assert state and state.state == "idle" # camera goes offline - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + new_camera = camera.copy() new_camera.state = StateType.DISCONNECTED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "unavailable" # camera comes back online @@ -585,50 +454,53 @@ async def test_camera_ws_update_offline( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get(entity_id) assert state and state.state == "idle" async def test_camera_enable_motion( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Tests generic entity update service.""" - camera[0].__fields__["set_motion_detection"] = Mock() - camera[0].set_motion_detection = AsyncMock() + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + camera.__fields__["set_motion_detection"] = Mock() + camera.set_motion_detection = AsyncMock() await hass.services.async_call( "camera", "enable_motion_detection", - {ATTR_ENTITY_ID: camera[1]}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - camera[0].set_motion_detection.assert_called_once_with(True) + camera.set_motion_detection.assert_called_once_with(True) async def test_camera_disable_motion( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[ProtectCamera, str], + hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera ): """Tests generic entity update service.""" - camera[0].__fields__["set_motion_detection"] = Mock() - camera[0].set_motion_detection = AsyncMock() + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + entity_id = "camera.test_camera_high" + + camera.__fields__["set_motion_detection"] = Mock() + camera.set_motion_detection = AsyncMock() await hass.services.async_call( "camera", "disable_motion_detection", - {ATTR_ENTITY_ID: camera[1]}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) - camera[0].set_motion_detection.assert_called_once_with(False) + camera.set_motion_detection.assert_called_once_with(False) diff --git a/tests/components/unifiprotect/test_config_flow.py b/tests/components/unifiprotect/test_config_flow.py index 75f08acb37c..3d561f2d781 100644 --- a/tests/components/unifiprotect/test_config_flow.py +++ b/tests/components/unifiprotect/test_config_flow.py @@ -6,7 +6,7 @@ import socket from unittest.mock import patch import pytest -from pyunifiprotect import NotAuthorized, NvrError +from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import NVR from homeassistant import config_entries @@ -61,7 +61,7 @@ UNIFI_DISCOVERY_DICT = asdict(UNIFI_DISCOVERY) UNIFI_DISCOVERY_DICT_PARTIAL = asdict(UNIFI_DISCOVERY_PARTIAL) -async def test_form(hass: HomeAssistant, mock_nvr: NVR) -> None: +async def test_form(hass: HomeAssistant, nvr: NVR) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -71,7 +71,7 @@ async def test_form(hass: HomeAssistant, mock_nvr: NVR) -> None: with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -99,7 +99,7 @@ async def test_form(hass: HomeAssistant, mock_nvr: NVR) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_version_too_old(hass: HomeAssistant, mock_old_nvr: NVR) -> None: +async def test_form_version_too_old(hass: HomeAssistant, old_nvr: NVR) -> None: """Test we handle the version being too old.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -107,7 +107,7 @@ async def test_form_version_too_old(hass: HomeAssistant, mock_old_nvr: NVR) -> N with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_old_nvr, + return_value=old_nvr, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -168,7 +168,7 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_reauth_auth(hass: HomeAssistant, mock_nvr: NVR) -> None: +async def test_form_reauth_auth(hass: HomeAssistant, nvr: NVR) -> None: """Test we handle reauth auth.""" mock_config = MockConfigEntry( domain=DOMAIN, @@ -217,7 +217,7 @@ async def test_form_reauth_auth(hass: HomeAssistant, mock_nvr: NVR) -> None: with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ): result3 = await hass.config_entries.flow.async_configure( result2["flow_id"], @@ -231,7 +231,7 @@ async def test_form_reauth_auth(hass: HomeAssistant, mock_nvr: NVR) -> None: assert result3["reason"] == "reauth_successful" -async def test_form_options(hass: HomeAssistant, mock_client) -> None: +async def test_form_options(hass: HomeAssistant, ufp_client: ProtectApiClient) -> None: """Test we handle options flows.""" mock_config = MockConfigEntry( domain=DOMAIN, @@ -251,7 +251,7 @@ async def test_form_options(hass: HomeAssistant, mock_client) -> None: with _patch_discovery(), patch( "homeassistant.components.unifiprotect.ProtectApiClient" ) as mock_api: - mock_api.return_value = mock_client + mock_api.return_value = ufp_client await hass.config_entries.async_setup(mock_config.entry_id) await hass.async_block_till_done() @@ -300,7 +300,7 @@ async def test_discovered_by_ssdp_or_dhcp( async def test_discovered_by_unifi_discovery_direct_connect( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, nvr: NVR ) -> None: """Test a discovery from unifi-discovery.""" @@ -324,7 +324,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -352,7 +352,7 @@ async def test_discovered_by_unifi_discovery_direct_connect( async def test_discovered_by_unifi_discovery_direct_connect_updated( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery updates the direct connect host.""" mock_config = MockConfigEntry( @@ -384,7 +384,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated( async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_using_direct_connect( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery updates the host but not direct connect if its not in use.""" mock_config = MockConfigEntry( @@ -419,7 +419,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_updated_but_not_usin async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_still_online( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery does not update the ip unless the console at the old ip is offline.""" mock_config = MockConfigEntry( @@ -454,7 +454,7 @@ async def test_discovered_by_unifi_discovery_does_not_update_ip_when_console_is_ async def test_discovered_host_not_updated_if_existing_is_a_hostname( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test we only update the host if its an ip address from discovery.""" mock_config = MockConfigEntry( @@ -484,9 +484,7 @@ async def test_discovered_host_not_updated_if_existing_is_a_hostname( assert mock_config.data[CONF_HOST] == "a.hostname" -async def test_discovered_by_unifi_discovery( - hass: HomeAssistant, mock_nvr: NVR -) -> None: +async def test_discovered_by_unifi_discovery(hass: HomeAssistant, nvr: NVR) -> None: """Test a discovery from unifi-discovery.""" with _patch_discovery(): @@ -509,7 +507,7 @@ async def test_discovered_by_unifi_discovery( with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - side_effect=[NotAuthorized, mock_nvr], + side_effect=[NotAuthorized, nvr], ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -537,7 +535,7 @@ async def test_discovered_by_unifi_discovery( async def test_discovered_by_unifi_discovery_partial( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, nvr: NVR ) -> None: """Test a discovery from unifi-discovery partial.""" @@ -561,7 +559,7 @@ async def test_discovered_by_unifi_discovery_partial( with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -589,7 +587,7 @@ async def test_discovered_by_unifi_discovery_partial( async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface.""" mock_config = MockConfigEntry( @@ -619,7 +617,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_ip_matches( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface when the ip matches.""" mock_config = MockConfigEntry( @@ -649,7 +647,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface when direct connect domain resolves to host ip.""" mock_config = MockConfigEntry( @@ -687,7 +685,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_fails( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, nvr: NVR ) -> None: """Test we can still configure if the resolver fails.""" mock_config = MockConfigEntry( @@ -730,7 +728,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa with patch( "homeassistant.components.unifiprotect.config_flow.ProtectApiClient.get_nvr", - return_value=mock_nvr, + return_value=nvr, ), patch( "homeassistant.components.unifiprotect.async_setup_entry", return_value=True, @@ -758,7 +756,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa async def test_discovered_by_unifi_discovery_direct_connect_on_different_interface_resolver_no_result( - hass: HomeAssistant, mock_nvr: NVR + hass: HomeAssistant, ) -> None: """Test a discovery from unifi-discovery from an alternate interface when direct connect domain resolve has no result.""" mock_config = MockConfigEntry( @@ -791,7 +789,7 @@ async def test_discovered_by_unifi_discovery_direct_connect_on_different_interfa assert result["reason"] == "already_configured" -async def test_discovery_can_be_ignored(hass: HomeAssistant, mock_nvr: NVR) -> None: +async def test_discovery_can_be_ignored(hass: HomeAssistant) -> None: """Test a discovery can be ignored.""" mock_config = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/unifiprotect/test_diagnostics.py b/tests/components/unifiprotect/test_diagnostics.py index 2e7f8c0e4b4..a0ed8f0d882 100644 --- a/tests/components/unifiprotect/test_diagnostics.py +++ b/tests/components/unifiprotect/test_diagnostics.py @@ -4,53 +4,44 @@ from pyunifiprotect.data import NVR, Light from homeassistant.core import HomeAssistant -from .conftest import MockEntityFixture, regenerate_device_ids +from .utils import MockUFPFixture, init_entry from tests.components.diagnostics import get_diagnostics_for_config_entry async def test_diagnostics( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light, hass_client + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, hass_client ): """Test generating diagnostics for a config entry.""" - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - regenerate_device_ids(light1) + await init_entry(hass, ufp, [light]) - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + diag = await get_diagnostics_for_config_entry(hass, hass_client, ufp.entry) - diag = await get_diagnostics_for_config_entry(hass, hass_client, mock_entry.entry) - - nvr_obj: NVR = mock_entry.api.bootstrap.nvr + nvr: NVR = ufp.api.bootstrap.nvr # validate some of the data assert "nvr" in diag and isinstance(diag["nvr"], dict) - nvr = diag["nvr"] + nvr_dict = diag["nvr"] # should have been anonymized - assert nvr["id"] != nvr_obj.id - assert nvr["mac"] != nvr_obj.mac - assert nvr["host"] != str(nvr_obj.host) + assert nvr_dict["id"] != nvr.id + assert nvr_dict["mac"] != nvr.mac + assert nvr_dict["host"] != str(nvr.host) # should have been kept - assert nvr["firmwareVersion"] == nvr_obj.firmware_version - assert nvr["version"] == str(nvr_obj.version) - assert nvr["type"] == nvr_obj.type + assert nvr_dict["firmwareVersion"] == nvr.firmware_version + assert nvr_dict["version"] == str(nvr.version) + assert nvr_dict["type"] == nvr.type assert ( "lights" in diag and isinstance(diag["lights"], list) and len(diag["lights"]) == 1 ) - light = diag["lights"][0] + light_dict = diag["lights"][0] # should have been anonymized - assert light["id"] != light1.id - assert light["name"] != light1.mac - assert light["mac"] != light1.mac - assert light["host"] != str(light1.host) + assert light_dict["id"] != light.id + assert light_dict["name"] != light.mac + assert light_dict["mac"] != light.mac + assert light_dict["host"] != str(light.host) # should have been kept - assert light["firmwareVersion"] == light1.firmware_version - assert light["type"] == light1.type + assert light_dict["firmwareVersion"] == light.firmware_version + assert light_dict["type"] == light.type diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index 5c06eedc4c9..c0ad30ad115 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -6,7 +6,7 @@ from collections.abc import Awaitable, Callable from unittest.mock import AsyncMock, patch import aiohttp -from pyunifiprotect import NotAuthorized, NvrError +from pyunifiprotect import NotAuthorized, NvrError, ProtectApiClient from pyunifiprotect.data import NVR, Bootstrap, Light from homeassistant.components.unifiprotect.const import CONF_DISABLE_RTSP, DOMAIN @@ -16,7 +16,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.setup import async_setup_component from . import _patch_discovery -from .conftest import MockEntityFixture, regenerate_device_ids +from .utils import MockUFPFixture, init_entry from tests.common import MockConfigEntry @@ -37,37 +37,36 @@ async def remove_device( return response["success"] -async def test_setup(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_setup(hass: HomeAssistant, ufp: MockUFPFixture): """Test working setup of unifiprotect entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac async def test_setup_multiple( hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_client, - mock_bootstrap: Bootstrap, + ufp: MockUFPFixture, + bootstrap: Bootstrap, ): """Test working setup of unifiprotect entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac - nvr = mock_bootstrap.nvr - nvr._api = mock_client + nvr = bootstrap.nvr + nvr._api = ufp.api nvr.mac = "A1E00C826983" nvr.id - mock_client.get_nvr = AsyncMock(return_value=nvr) + ufp.api.get_nvr = AsyncMock(return_value=nvr) with patch("homeassistant.components.unifiprotect.ProtectApiClient") as mock_api: mock_config = MockConfigEntry( @@ -84,148 +83,134 @@ async def test_setup_multiple( ) mock_config.add_to_hass(hass) - mock_api.return_value = mock_client + mock_api.return_value = ufp.api await hass.config_entries.async_setup(mock_config.entry_id) await hass.async_block_till_done() assert mock_config.state == ConfigEntryState.LOADED - assert mock_client.update.called - assert mock_config.unique_id == mock_client.bootstrap.nvr.mac + assert ufp.api.update.called + assert mock_config.unique_id == ufp.api.bootstrap.nvr.mac -async def test_reload(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_reload(hass: HomeAssistant, ufp: MockUFPFixture): """Test updating entry reload entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED + assert ufp.entry.state == ConfigEntryState.LOADED - options = dict(mock_entry.entry.options) + options = dict(ufp.entry.options) options[CONF_DISABLE_RTSP] = True - hass.config_entries.async_update_entry(mock_entry.entry, options=options) + hass.config_entries.async_update_entry(ufp.entry, options=options) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.async_disconnect_ws.called + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.async_disconnect_ws.called -async def test_unload(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture): """Test unloading of unifiprotect entry.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED + assert ufp.entry.state == ConfigEntryState.LOADED - await hass.config_entries.async_unload(mock_entry.entry.entry_id) - assert mock_entry.entry.state == ConfigEntryState.NOT_LOADED - assert mock_entry.api.async_disconnect_ws.called + await hass.config_entries.async_unload(ufp.entry.entry_id) + assert ufp.entry.state == ConfigEntryState.NOT_LOADED + assert ufp.api.async_disconnect_ws.called -async def test_setup_too_old( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_old_nvr: NVR -): +async def test_setup_too_old(hass: HomeAssistant, ufp: MockUFPFixture, old_nvr: NVR): """Test setup of unifiprotect entry with too old of version of UniFi Protect.""" - mock_entry.api.get_nvr.return_value = mock_old_nvr + ufp.api.get_nvr.return_value = old_nvr - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR - assert not mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_ERROR + assert not ufp.api.update.called -async def test_setup_failed_update(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_setup_failed_update(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with failed update.""" - mock_entry.api.update = AsyncMock(side_effect=NvrError) + ufp.api.update = AsyncMock(side_effect=NvrError) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY - assert mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY + assert ufp.api.update.called -async def test_setup_failed_update_reauth( - hass: HomeAssistant, mock_entry: MockEntityFixture -): +async def test_setup_failed_update_reauth(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with update that gives unauthroized error.""" - mock_entry.api.update = AsyncMock(side_effect=NotAuthorized) + ufp.api.update = AsyncMock(side_effect=NotAuthorized) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY - assert mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY + assert ufp.api.update.called -async def test_setup_failed_error(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_setup_failed_error(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with generic error.""" - mock_entry.api.get_nvr = AsyncMock(side_effect=NvrError) + ufp.api.get_nvr = AsyncMock(side_effect=NvrError) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY - assert not mock_entry.api.update.called + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY + assert not ufp.api.update.called -async def test_setup_failed_auth(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def test_setup_failed_auth(hass: HomeAssistant, ufp: MockUFPFixture): """Test setup of unifiprotect entry with unauthorized error.""" - mock_entry.api.get_nvr = AsyncMock(side_effect=NotAuthorized) + ufp.api.get_nvr = AsyncMock(side_effect=NotAuthorized) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - assert mock_entry.entry.state == ConfigEntryState.SETUP_ERROR - assert not mock_entry.api.update.called + await hass.config_entries.async_setup(ufp.entry.entry_id) + assert ufp.entry.state == ConfigEntryState.SETUP_ERROR + assert not ufp.api.update.called async def test_setup_starts_discovery( - hass: HomeAssistant, mock_ufp_config_entry: ConfigEntry, mock_client + hass: HomeAssistant, ufp_config_entry: ConfigEntry, ufp_client: ProtectApiClient ): """Test setting up will start discovery.""" with _patch_discovery(), patch( "homeassistant.components.unifiprotect.ProtectApiClient" ) as mock_api: - mock_ufp_config_entry.add_to_hass(hass) - mock_api.return_value = mock_client - mock_entry = MockEntityFixture(mock_ufp_config_entry, mock_client) + ufp_config_entry.add_to_hass(hass) + mock_api.return_value = ufp_client + ufp = MockUFPFixture(ufp_config_entry, ufp_client) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - assert mock_entry.entry.state == ConfigEntryState.LOADED + assert ufp.entry.state == ConfigEntryState.LOADED await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress_by_handler(DOMAIN)) == 1 async def test_device_remove_devices( hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_light: Light, + ufp: MockUFPFixture, + light: Light, hass_ws_client: Callable[ [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] ], ) -> None: """Test we can only remove a device that no longer exists.""" + + await init_entry(hass, ufp, [light]) assert await async_setup_component(hass, "config", {}) - - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - regenerate_device_ids(light1) - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - light_entity_id = "light.test_light_1" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - entry_id = mock_entry.entry.entry_id + entity_id = "light.test_light" + entry_id = ufp.entry.entry_id registry: er.EntityRegistry = er.async_get(hass) - entity = registry.entities[light_entity_id] + entity = registry.async_get(entity_id) + assert entity is not None device_registry = dr.async_get(hass) live_device_entry = device_registry.async_get(entity.device_id) @@ -246,7 +231,7 @@ async def test_device_remove_devices( async def test_device_remove_devices_nvr( hass: HomeAssistant, - mock_entry: MockEntityFixture, + ufp: MockUFPFixture, hass_ws_client: Callable[ [HomeAssistant], Awaitable[aiohttp.ClientWebSocketResponse] ], @@ -254,10 +239,10 @@ async def test_device_remove_devices_nvr( """Test we can only remove a NVR device that no longer exists.""" assert await async_setup_component(hass, "config", {}) - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - entry_id = mock_entry.entry.entry_id + entry_id = ufp.entry.entry_id device_registry = dr.async_get(hass) diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 3bcca436911..3c575de8d00 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -2,11 +2,10 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import Light +from pyunifiprotect.data.types import LEDLevel from homeassistant.components.light import ATTR_BRIGHTNESS from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -20,53 +19,19 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids - - -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Fixture for a single light for testing the light platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy() - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.is_light_on = False - regenerate_device_ids(light_obj) - - no_light_obj = mock_light.copy() - no_light_obj._api = mock_entry.api - no_light_obj.name = "Unadopted Light" - no_light_obj.is_adopted = False - regenerate_device_ids(no_light_obj) - - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - no_light_obj.id: no_light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.LIGHT, 1, 1) - - yield (light_obj, "light.test_light") - - Light.__config__.validate_assignment = True +from .utils import MockUFPFixture, assert_entity_counts, init_entry async def test_light_setup( - hass: HomeAssistant, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity setup.""" - unique_id = light[0].mac - entity_id = light[1] + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + unique_id = light.mac + entity_id = "light.test_light" entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) @@ -80,41 +45,42 @@ async def test_light_setup( async def test_light_update( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity update.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_light = light[0].copy() + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + new_light = light.copy() new_light.is_light_on = True - new_light.light_device_settings.led_level = 3 + new_light.light_device_settings.led_level = LEDLevel(3) mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_light - new_bootstrap.lights = {new_light.id: new_light} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.lights = {new_light.id: new_light} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(light[1]) + state = hass.states.get("light.test_light") assert state assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 128 async def test_light_turn_on( - hass: HomeAssistant, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity turn off.""" - entity_id = light[1] - light[0].__fields__["set_light"] = Mock() - light[0].set_light = AsyncMock() + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + entity_id = "light.test_light" + light.__fields__["set_light"] = Mock() + light.set_light = AsyncMock() await hass.services.async_call( "light", @@ -123,18 +89,20 @@ async def test_light_turn_on( blocking=True, ) - light[0].set_light.assert_called_once_with(True, 3) + light.set_light.assert_called_once_with(True, 3) async def test_light_turn_off( - hass: HomeAssistant, - light: tuple[Light, str], + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, unadopted_light: Light ): """Test light entity turn on.""" - entity_id = light[1] - light[0].__fields__["set_light"] = Mock() - light[0].set_light = AsyncMock() + await init_entry(hass, ufp, [light, unadopted_light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + + entity_id = "light.test_light" + light.__fields__["set_light"] = Mock() + light.set_light = AsyncMock() await hass.services.async_call( "light", @@ -143,4 +111,4 @@ async def test_light_turn_off( blocking=True, ) - light[0].set_light.assert_called_once_with(False) + light.set_light.assert_called_once_with(False) diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 3ebfd2de22f..21b3c77deb5 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -2,10 +2,8 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import Doorlock, LockStatusType from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION @@ -23,53 +21,22 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids - - -@pytest.fixture(name="doorlock") -async def doorlock_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_doorlock: Doorlock -): - """Fixture for a single doorlock for testing the lock platform.""" - - # disable pydantic validation so mocking can happen - Doorlock.__config__.validate_assignment = False - - lock_obj = mock_doorlock.copy() - lock_obj._api = mock_entry.api - lock_obj.name = "Test Lock" - lock_obj.lock_status = LockStatusType.OPEN - regenerate_device_ids(lock_obj) - - no_lock_obj = mock_doorlock.copy() - no_lock_obj._api = mock_entry.api - no_lock_obj.name = "Unadopted Lock" - no_lock_obj.is_adopted = False - regenerate_device_ids(no_lock_obj) - - mock_entry.api.bootstrap.doorlocks = { - lock_obj.id: lock_obj, - no_lock_obj.id: no_lock_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.LOCK, 1, 1) - - yield (lock_obj, "lock.test_lock_lock") - - Doorlock.__config__.validate_assignment = True +from .utils import MockUFPFixture, assert_entity_counts, init_entry async def test_lock_setup( hass: HomeAssistant, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity setup.""" - unique_id = f"{doorlock[0].mac}_lock" - entity_id = doorlock[1] + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + unique_id = f"{doorlock.mac}_lock" + entity_id = "lock.test_lock_lock" entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) @@ -84,166 +51,183 @@ async def test_lock_setup( async def test_lock_locked( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity locked.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_LOCKED async def test_lock_unlocking( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity unlocking.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.OPENING mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_UNLOCKING async def test_lock_locking( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity locking.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.CLOSING mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_LOCKING async def test_lock_jammed( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity jammed.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.JAMMED_WHILE_CLOSING mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_JAMMED async def test_lock_unavailable( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity unavailable.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.NOT_CALIBRATED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(doorlock[1]) + state = hass.states.get("lock.test_lock_lock") assert state assert state.state == STATE_UNAVAILABLE async def test_lock_do_lock( hass: HomeAssistant, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity lock service.""" - doorlock[0].__fields__["close_lock"] = Mock() - doorlock[0].close_lock = AsyncMock() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + doorlock.__fields__["close_lock"] = Mock() + doorlock.close_lock = AsyncMock() await hass.services.async_call( "lock", "lock", - {ATTR_ENTITY_ID: doorlock[1]}, + {ATTR_ENTITY_ID: "lock.test_lock_lock"}, blocking=True, ) - doorlock[0].close_lock.assert_called_once() + doorlock.close_lock.assert_called_once() async def test_lock_do_unlock( hass: HomeAssistant, - mock_entry: MockEntityFixture, - doorlock: tuple[Doorlock, str], + ufp: MockUFPFixture, + doorlock: Doorlock, + unadopted_doorlock: Doorlock, ): """Test lock entity unlock service.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_lock = doorlock[0].copy() + await init_entry(hass, ufp, [doorlock, unadopted_doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + + new_lock = doorlock.copy() new_lock.lock_status = LockStatusType.CLOSED mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_lock - new_bootstrap.doorlocks = {new_lock.id: new_lock} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.doorlocks = {new_lock.id: new_lock} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() new_lock.__fields__["open_lock"] = Mock() @@ -252,7 +236,7 @@ async def test_lock_do_unlock( await hass.services.async_call( "lock", "unlock", - {ATTR_ENTITY_ID: doorlock[1]}, + {ATTR_ENTITY_ID: "lock.test_lock_lock"}, blocking=True, ) diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index c18a407eadb..678fa0c9be4 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -2,7 +2,6 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from unittest.mock import AsyncMock, Mock, patch import pytest @@ -26,66 +25,29 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, assert_entity_counts, regenerate_device_ids - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the media_player platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_speaker = True - regenerate_device_ids(camera_obj) - - no_camera_obj = mock_camera.copy() - no_camera_obj._api = mock_entry.api - no_camera_obj.channels[0]._api = mock_entry.api - no_camera_obj.channels[1]._api = mock_entry.api - no_camera_obj.channels[2]._api = mock_entry.api - no_camera_obj.name = "Unadopted Camera" - no_camera_obj.is_adopted = False - regenerate_device_ids(no_camera_obj) - - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - no_camera_obj.id: no_camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) - - yield (camera_obj, "media_player.test_camera_speaker") - - Camera.__config__.validate_assignment = True +from .utils import MockUFPFixture, assert_entity_counts, init_entry async def test_media_player_setup( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity setup.""" - unique_id = f"{camera[0].mac}_speaker" - entity_id = camera[1] + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + unique_id = f"{doorbell.mac}_speaker" + entity_id = "media_player.test_camera_speaker" entity_registry = er.async_get(hass) entity = entity_registry.async_get(entity_id) assert entity assert entity.unique_id == unique_id - expected_volume = float(camera[0].speaker_settings.volume / 100) + expected_volume = float(doorbell.speaker_settings.volume / 100) state = hass.states.get(entity_id) assert state @@ -98,13 +60,16 @@ async def test_media_player_setup( async def test_media_player_update( hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity update.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + new_camera = doorbell.copy() new_camera.talkback_stream = Mock() new_camera.talkback_stream.is_running = True @@ -112,44 +77,51 @@ async def test_media_player_update( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() - state = hass.states.get(camera[1]) + state = hass.states.get("media_player.test_camera_speaker") assert state assert state.state == STATE_PLAYING async def test_media_player_set_volume( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test set_volume_level.""" - camera[0].__fields__["set_speaker_volume"] = Mock() - camera[0].set_speaker_volume = AsyncMock() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["set_speaker_volume"] = Mock() + doorbell.set_speaker_volume = AsyncMock() await hass.services.async_call( "media_player", "volume_set", - {ATTR_ENTITY_ID: camera[1], "volume_level": 0.5}, + {ATTR_ENTITY_ID: "media_player.test_camera_speaker", "volume_level": 0.5}, blocking=True, ) - camera[0].set_speaker_volume.assert_called_once_with(50) + doorbell.set_speaker_volume.assert_called_once_with(50) async def test_media_player_stop( hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test media_stop.""" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera[0].copy() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + new_camera = doorbell.copy() new_camera.talkback_stream = AsyncMock() new_camera.talkback_stream.is_running = True @@ -157,15 +129,14 @@ async def test_media_player_stop( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() await hass.services.async_call( "media_player", "media_stop", - {ATTR_ENTITY_ID: camera[1]}, + {ATTR_ENTITY_ID: "media_player.test_camera_speaker"}, blocking=True, ) @@ -174,44 +145,56 @@ async def test_media_player_stop( async def test_media_player_play( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media.""" - camera[0].__fields__["stop_audio"] = Mock() - camera[0].__fields__["play_audio"] = Mock() - camera[0].__fields__["wait_until_audio_completes"] = Mock() - camera[0].stop_audio = AsyncMock() - camera[0].play_audio = AsyncMock() - camera[0].wait_until_audio_completes = AsyncMock() + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["stop_audio"] = Mock() + doorbell.__fields__["play_audio"] = Mock() + doorbell.__fields__["wait_until_audio_completes"] = Mock() + doorbell.stop_audio = AsyncMock() + doorbell.play_audio = AsyncMock() + doorbell.wait_until_audio_completes = AsyncMock() await hass.services.async_call( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "http://example.com/test.mp3", "media_content_type": "music", }, blocking=True, ) - camera[0].play_audio.assert_called_once_with( + doorbell.play_audio.assert_called_once_with( "http://example.com/test.mp3", blocking=False ) - camera[0].wait_until_audio_completes.assert_called_once() + doorbell.wait_until_audio_completes.assert_called_once() async def test_media_player_play_media_source( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media.""" - camera[0].__fields__["stop_audio"] = Mock() - camera[0].__fields__["play_audio"] = Mock() - camera[0].__fields__["wait_until_audio_completes"] = Mock() - camera[0].stop_audio = AsyncMock() - camera[0].play_audio = AsyncMock() - camera[0].wait_until_audio_completes = AsyncMock() + + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["stop_audio"] = Mock() + doorbell.__fields__["play_audio"] = Mock() + doorbell.__fields__["wait_until_audio_completes"] = Mock() + doorbell.stop_audio = AsyncMock() + doorbell.play_audio = AsyncMock() + doorbell.wait_until_audio_completes = AsyncMock() with patch( "homeassistant.components.media_source.async_resolve_media", @@ -221,65 +204,75 @@ async def test_media_player_play_media_source( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "media-source://some_source/some_id", "media_content_type": "audio/mpeg", }, blocking=True, ) - camera[0].play_audio.assert_called_once_with( + doorbell.play_audio.assert_called_once_with( "http://example.com/test.mp3", blocking=False ) - camera[0].wait_until_audio_completes.assert_called_once() + doorbell.wait_until_audio_completes.assert_called_once() async def test_media_player_play_invalid( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media, not music.""" - camera[0].__fields__["play_audio"] = Mock() - camera[0].play_audio = AsyncMock() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["play_audio"] = Mock() + doorbell.play_audio = AsyncMock() with pytest.raises(HomeAssistantError): await hass.services.async_call( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "/test.png", "media_content_type": "image", }, blocking=True, ) - assert not camera[0].play_audio.called + assert not doorbell.play_audio.called async def test_media_player_play_error( hass: HomeAssistant, - camera: tuple[Camera, str], + ufp: MockUFPFixture, + doorbell: Camera, + unadopted_camera: Camera, ): """Test media_player entity test play_media, not music.""" - camera[0].__fields__["play_audio"] = Mock() - camera[0].__fields__["wait_until_audio_completes"] = Mock() - camera[0].play_audio = AsyncMock(side_effect=StreamError) - camera[0].wait_until_audio_completes = AsyncMock() + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + + doorbell.__fields__["play_audio"] = Mock() + doorbell.__fields__["wait_until_audio_completes"] = Mock() + doorbell.play_audio = AsyncMock(side_effect=StreamError) + doorbell.wait_until_audio_completes = AsyncMock() with pytest.raises(HomeAssistantError): await hass.services.async_call( "media_player", "play_media", { - ATTR_ENTITY_ID: camera[1], + ATTR_ENTITY_ID: "media_player.test_camera_speaker", "media_content_id": "/test.mp3", "media_content_type": "music", }, blocking=True, ) - assert camera[0].play_audio.called - assert not camera[0].wait_until_audio_completes.called + assert doorbell.play_audio.called + assert not doorbell.wait_until_audio_completes.called diff --git a/tests/components/unifiprotect/test_migrate.py b/tests/components/unifiprotect/test_migrate.py index 206c85e3654..64c8384d400 100644 --- a/tests/components/unifiprotect/test_migrate.py +++ b/tests/components/unifiprotect/test_migrate.py @@ -5,7 +5,6 @@ from __future__ import annotations from unittest.mock import AsyncMock from pyunifiprotect.data import Light -from pyunifiprotect.data.bootstrap import ProtectDeviceRef from pyunifiprotect.exceptions import NvrError from homeassistant.components.unifiprotect.const import DOMAIN @@ -14,56 +13,47 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import MockEntityFixture, generate_random_ids, regenerate_device_ids +from .utils import ( + MockUFPFixture, + generate_random_ids, + init_entry, + regenerate_device_ids, +) async def test_migrate_reboot_button( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test migrating unique ID of reboot button.""" - light1 = mock_light.copy() - light1._api = mock_entry.api + light1 = light.copy() light1.name = "Test Light 1" regenerate_device_ids(light1) - light2 = mock_light.copy() - light2._api = mock_entry.api + light2 = light.copy() light2.name = "Test Light 2" regenerate_device_ids(light2) - mock_entry.api.bootstrap.lights = { - light1.id: light1, - light2.id: light2, - } - mock_entry.api.bootstrap.id_lookup = { - light1.id: ProtectDeviceRef(id=light1.id, model=light1.model), - light2.id: ProtectDeviceRef(id=light2.id, model=light2.model), - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - registry = er.async_get(hass) registry.async_get_or_create( - Platform.BUTTON, DOMAIN, light1.id, config_entry=mock_entry.entry + Platform.BUTTON, DOMAIN, light1.id, config_entry=ufp.entry ) registry.async_get_or_create( Platform.BUTTON, DOMAIN, f"{light2.mac}_reboot", - config_entry=mock_entry.entry, + config_entry=ufp.entry, ) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light1, light2], regenerate_ids=False) - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac buttons = [] - for entity in er.async_entries_for_config_entry( - registry, mock_entry.entry.entry_id - ): + for entity in er.async_entries_for_config_entry(registry, ufp.entry.entry_id): if entity.domain == Platform.BUTTON.value: buttons.append(entity) assert len(buttons) == 2 @@ -83,29 +73,33 @@ async def test_migrate_reboot_button( assert light.unique_id == f"{light2.mac}_reboot" -async def test_migrate_nvr_mac( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): +async def test_migrate_nvr_mac(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): """Test migrating unique ID of NVR to use MAC address.""" - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - nvr = mock_entry.api.bootstrap.nvr - regenerate_device_ids(nvr) + light1 = light.copy() + light1.name = "Test Light 1" + regenerate_device_ids(light1) + light2 = light.copy() + light2.name = "Test Light 2" + regenerate_device_ids(light2) + + nvr = ufp.api.bootstrap.nvr + regenerate_device_ids(nvr) registry = er.async_get(hass) registry.async_get_or_create( Platform.SENSOR, DOMAIN, f"{nvr.id}_storage_utilization", - config_entry=mock_entry.entry, + config_entry=ufp.entry, ) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light1, light2], regenerate_ids=False) - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac assert registry.async_get(f"{Platform.SENSOR}.{DOMAIN}_storage_utilization") is None assert ( @@ -119,171 +113,123 @@ async def test_migrate_nvr_mac( async def test_migrate_reboot_button_no_device( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test migrating unique ID of reboot button if UniFi Protect device ID changed.""" - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - regenerate_device_ids(light1) - light2_id, _ = generate_random_ids() - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - registry = er.async_get(hass) registry.async_get_or_create( - Platform.BUTTON, DOMAIN, light2_id, config_entry=mock_entry.entry + Platform.BUTTON, DOMAIN, light2_id, config_entry=ufp.entry ) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light], regenerate_ids=False) - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac buttons = [] - for entity in er.async_entries_for_config_entry( - registry, mock_entry.entry.entry_id - ): + for entity in er.async_entries_for_config_entry(registry, ufp.entry.entry_id): if entity.domain == Platform.BUTTON.value: buttons.append(entity) assert len(buttons) == 2 - light = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light2_id.lower()}") - assert light is not None - assert light.unique_id == light2_id + entity = registry.async_get(f"{Platform.BUTTON}.unifiprotect_{light2_id.lower()}") + assert entity is not None + assert entity.unique_id == light2_id async def test_migrate_reboot_button_fail( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test migrating unique ID of reboot button.""" - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - regenerate_device_ids(light1) - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.bootstrap.id_lookup = { - light1.id: ProtectDeviceRef(id=light1.id, model=light1.model), - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - registry = er.async_get(hass) registry.async_get_or_create( Platform.BUTTON, DOMAIN, - light1.id, - config_entry=mock_entry.entry, - suggested_object_id=light1.name, + light.id, + config_entry=ufp.entry, + suggested_object_id=light.display_name, ) registry.async_get_or_create( Platform.BUTTON, DOMAIN, - f"{light1.id}_reboot", - config_entry=mock_entry.entry, - suggested_object_id=light1.name, + f"{light.id}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.display_name, ) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light], regenerate_ids=False) - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac - light = registry.async_get(f"{Platform.BUTTON}.test_light_1") - assert light is not None - assert light.unique_id == f"{light1.mac}" + entity = registry.async_get(f"{Platform.BUTTON}.test_light") + assert entity is not None + assert entity.unique_id == f"{light.mac}" async def test_migrate_device_mac_button_fail( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test migrating unique ID to MAC format.""" - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - regenerate_device_ids(light1) - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.bootstrap.id_lookup = { - light1.id: ProtectDeviceRef(id=light1.id, model=light1.model) - } - mock_entry.api.get_bootstrap = AsyncMock(return_value=mock_entry.api.bootstrap) - registry = er.async_get(hass) registry.async_get_or_create( Platform.BUTTON, DOMAIN, - f"{light1.id}_reboot", - config_entry=mock_entry.entry, - suggested_object_id=light1.name, + f"{light.id}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.display_name, ) registry.async_get_or_create( Platform.BUTTON, DOMAIN, - f"{light1.mac}_reboot", - config_entry=mock_entry.entry, - suggested_object_id=light1.name, + f"{light.mac}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.display_name, ) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + ufp.api.get_bootstrap = AsyncMock(return_value=ufp.api.bootstrap) + await init_entry(hass, ufp, [light], regenerate_ids=False) - assert mock_entry.entry.state == ConfigEntryState.LOADED - assert mock_entry.api.update.called - assert mock_entry.entry.unique_id == mock_entry.api.bootstrap.nvr.mac + assert ufp.entry.state == ConfigEntryState.LOADED + assert ufp.api.update.called + assert ufp.entry.unique_id == ufp.api.bootstrap.nvr.mac - light = registry.async_get(f"{Platform.BUTTON}.test_light_1") - assert light is not None - assert light.unique_id == f"{light1.id}_reboot" + entity = registry.async_get(f"{Platform.BUTTON}.test_light") + assert entity is not None + assert entity.unique_id == f"{light.id}_reboot" async def test_migrate_device_mac_bootstrap_fail( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test migrating with a network error.""" - light1 = mock_light.copy() - light1._api = mock_entry.api - light1.name = "Test Light 1" - regenerate_device_ids(light1) - - mock_entry.api.bootstrap.lights = { - light1.id: light1, - } - mock_entry.api.get_bootstrap = AsyncMock(side_effect=NvrError) - registry = er.async_get(hass) registry.async_get_or_create( Platform.BUTTON, DOMAIN, - f"{light1.id}_reboot", - config_entry=mock_entry.entry, - suggested_object_id=light1.name, + f"{light.id}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.name, ) registry.async_get_or_create( Platform.BUTTON, DOMAIN, - f"{light1.mac}_reboot", - config_entry=mock_entry.entry, - suggested_object_id=light1.name, + f"{light.mac}_reboot", + config_entry=ufp.entry, + suggested_object_id=light.name, ) - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + ufp.api.get_bootstrap = AsyncMock(side_effect=NvrError) + await init_entry(hass, ufp, [light], regenerate_ids=False) - assert mock_entry.entry.state == ConfigEntryState.SETUP_RETRY + assert ufp.entry.state == ConfigEntryState.SETUP_RETRY diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 043feae7925..656f7d08ba5 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -19,119 +19,23 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, assert_entity_counts, ids_from_device_description, - reset_objects, + init_entry, ) -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Fixture for a single light for testing the number platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy() - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.light_device_settings.pir_sensitivity = 45 - light_obj.light_device_settings.pir_duration = timedelta(seconds=45) - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.NUMBER, 2, 2) - - yield light_obj - - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the number platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.can_optical_zoom = True - camera_obj.feature_flags.has_mic = True - # has_wdr is an the inverse of has HDR - camera_obj.feature_flags.has_hdr = False - camera_obj.isp_settings.wdr = 0 - camera_obj.mic_volume = 0 - camera_obj.isp_settings.zoom_position = 0 - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.NUMBER, 3, 3) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="doorlock") -async def doorlock_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_doorlock: Doorlock -): - """Fixture for a single doorlock for testing the number platform.""" - - # disable pydantic validation so mocking can happen - Doorlock.__config__.validate_assignment = False - - lock_obj = mock_doorlock.copy() - lock_obj._api = mock_entry.api - lock_obj.name = "Test Lock" - lock_obj.auto_close_time = timedelta(seconds=45) - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.doorlocks = { - lock_obj.id: lock_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.NUMBER, 1, 1) - - yield lock_obj - - Doorlock.__config__.validate_assignment = True - - async def test_number_setup_light( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test number entity setup for light devices.""" - entity_registry = er.async_get(hass) + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + entity_registry = er.async_get(hass) for description in LIGHT_NUMBERS: unique_id, entity_id = ids_from_device_description( Platform.NUMBER, light, description @@ -148,11 +52,13 @@ async def test_number_setup_light( async def test_number_setup_camera_all( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test number entity setup for camera devices (all features).""" + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + entity_registry = er.async_get(hass) for description in CAMERA_NUMBERS: @@ -171,64 +77,38 @@ async def test_number_setup_camera_all( async def test_number_setup_camera_none( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test number entity setup for camera devices (no features).""" - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.can_optical_zoom = False - camera_obj.feature_flags.has_mic = False + camera.feature_flags.can_optical_zoom = False + camera.feature_flags.has_mic = False # has_wdr is an the inverse of has HDR - camera_obj.feature_flags.has_hdr = True - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + camera.feature_flags.has_hdr = True + await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.NUMBER, 0, 0) async def test_number_setup_camera_missing_attr( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera ): """Test number entity setup for camera devices (no features, bad attrs).""" - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags = None - - Camera.__config__.validate_assignment = True - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + camera.feature_flags = None + await init_entry(hass, ufp, [camera]) assert_entity_counts(hass, Platform.NUMBER, 0, 0) -async def test_number_light_sensitivity(hass: HomeAssistant, light: Light): +async def test_number_light_sensitivity( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): """Test sensitivity number entity for lights.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + description = LIGHT_NUMBERS[0] assert description.ufp_set_method is not None @@ -244,9 +124,14 @@ async def test_number_light_sensitivity(hass: HomeAssistant, light: Light): light.set_sensitivity.assert_called_once_with(15.0) -async def test_number_light_duration(hass: HomeAssistant, light: Light): +async def test_number_light_duration( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): """Test auto-shutoff duration number entity for lights.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + description = LIGHT_NUMBERS[1] light.__fields__["set_duration"] = Mock() @@ -263,10 +148,16 @@ async def test_number_light_duration(hass: HomeAssistant, light: Light): @pytest.mark.parametrize("description", CAMERA_NUMBERS) async def test_number_camera_simple( - hass: HomeAssistant, camera: Camera, description: ProtectNumberEntityDescription + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: Camera, + description: ProtectNumberEntityDescription, ): """Tests all simple numbers for cameras.""" + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + assert description.ufp_set_method is not None camera.__fields__[description.ufp_set_method] = Mock() @@ -282,9 +173,14 @@ async def test_number_camera_simple( set_method.assert_called_once_with(1.0) -async def test_number_lock_auto_close(hass: HomeAssistant, doorlock: Doorlock): +async def test_number_lock_auto_close( + hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock +): """Test auto-lock timeout for locks.""" + await init_entry(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.NUMBER, 1, 1) + description = DOORLOCK_NUMBERS[0] doorlock.__fields__["set_auto_close_time"] = Mock() diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 01263a13cd9..637a0d4ad5d 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -3,7 +3,7 @@ from __future__ import annotations from copy import copy -from datetime import timedelta +from datetime import datetime, timedelta from unittest.mock import AsyncMock, Mock, patch import pytest @@ -38,162 +38,24 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, ATTR_OPTION, P from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from homeassistant.util.dt import utcnow -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, assert_entity_counts, ids_from_device_description, - reset_objects, + init_entry, ) -@pytest.fixture(name="viewer") -async def viewer_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_viewer: Viewer, - mock_liveview: Liveview, -): - """Fixture for a single viewport for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Viewer.__config__.validate_assignment = False - - viewer_obj = mock_viewer.copy() - viewer_obj._api = mock_entry.api - viewer_obj.name = "Test Viewer" - viewer_obj.liveview_id = mock_liveview.id - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.viewers = { - viewer_obj.id: viewer_obj, - } - mock_entry.api.bootstrap.liveviews = {mock_liveview.id: mock_liveview} - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SELECT, 1, 1) - - yield viewer_obj - - Viewer.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_lcd_screen = True - camera_obj.feature_flags.has_chime = True - camera_obj.recording_settings.mode = RecordingMode.ALWAYS - camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO - camera_obj.lcd_message = None - camera_obj.chime_duration = 0 - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SELECT, 4, 4) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_light: Light, - camera: Camera, -): - """Fixture for a single light for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy() - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.camera_id = None - light_obj.light_mode_settings.mode = LightModeType.MOTION - light_obj.light_mode_settings.enable_at = LightModeEnableType.DARK - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = {camera.id: camera} - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_reload(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SELECT, 6, 6) - - yield light_obj - - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_none") -async def camera_none_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the select platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_lcd_screen = False - camera_obj.feature_flags.has_chime = False - camera_obj.recording_settings.mode = RecordingMode.ALWAYS - camera_obj.isp_settings.ir_led_mode = IRLEDMode.AUTO - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SELECT, 2, 2) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - async def test_select_setup_light( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test select entity setup for light devices.""" + light.light_mode_settings.enable_at = LightModeEnableType.DARK + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + entity_registry = er.async_get(hass) expected_values = ("On Motion - When Dark", "Not Paired") @@ -213,11 +75,14 @@ async def test_select_setup_light( async def test_select_setup_viewer( - hass: HomeAssistant, - viewer: Viewer, + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer, liveview: Liveview ): """Test select entity setup for light devices.""" + ufp.api.bootstrap.liveviews = {liveview.id: liveview} + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + entity_registry = er.async_get(hass) description = VIEWER_SELECTS[0] @@ -236,15 +101,46 @@ async def test_select_setup_viewer( async def test_select_setup_camera_all( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test select entity setup for camera devices (all features).""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + entity_registry = er.async_get(hass) expected_values = ("Always", "Auto", "Default Message (Welcome)", "None") for index, description in enumerate(CAMERA_SELECTS): + unique_id, entity_id = ids_from_device_description( + Platform.SELECT, doorbell, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_select_setup_camera_none( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera +): + """Test select entity setup for camera devices (no features).""" + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + + entity_registry = er.async_get(hass) + expected_values = ("Always", "Auto", "Default Message (Welcome)") + + for index, description in enumerate(CAMERA_SELECTS): + if index == 2: + return + unique_id, entity_id = ids_from_device_description( Platform.SELECT, camera, description ) @@ -259,41 +155,15 @@ async def test_select_setup_camera_all( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_select_setup_camera_none( - hass: HomeAssistant, - camera_none: Camera, -): - """Test select entity setup for camera devices (no features).""" - - entity_registry = er.async_get(hass) - expected_values = ("Always", "Auto", "Default Message (Welcome)") - - for index, description in enumerate(CAMERA_SELECTS): - if index == 2: - return - - unique_id, entity_id = ids_from_device_description( - Platform.SELECT, camera_none, description - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.unique_id == unique_id - - state = hass.states.get(entity_id) - assert state - assert state.state == expected_values[index] - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - async def test_select_update_liveview( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - viewer: Viewer, - mock_liveview: Liveview, + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer, liveview: Liveview ): """Test select entity update (new Liveview).""" + ufp.api.bootstrap.liveviews = {liveview.id: liveview} + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + _, entity_id = ids_from_device_description( Platform.SELECT, viewer, VIEWER_SELECTS[0] ) @@ -302,17 +172,18 @@ async def test_select_update_liveview( assert state expected_options = state.attributes[ATTR_OPTIONS] - new_bootstrap = copy(mock_entry.api.bootstrap) - new_liveview = copy(mock_liveview) + new_liveview = copy(liveview) new_liveview.id = "test_id" mock_msg = Mock() mock_msg.changed_data = {} mock_msg.new_obj = new_liveview - new_bootstrap.liveviews = {**new_bootstrap.liveviews, new_liveview.id: new_liveview} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.liveviews = { + **ufp.api.bootstrap.liveviews, + new_liveview.id: new_liveview, + } + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -321,16 +192,17 @@ async def test_select_update_liveview( async def test_select_update_doorbell_settings( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test select entity update (new Doorbell Message).""" - expected_length = ( - len(mock_entry.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 - ) + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + + expected_length = len(ufp.api.bootstrap.nvr.doorbell_settings.all_messages) + 1 _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) @@ -338,7 +210,7 @@ async def test_select_update_doorbell_settings( assert len(state.attributes[ATTR_OPTIONS]) == expected_length expected_length += 1 - new_nvr = copy(mock_entry.api.bootstrap.nvr) + new_nvr = copy(ufp.api.bootstrap.nvr) new_nvr.__fields__["update_all_messages"] = Mock() new_nvr.update_all_messages = Mock() @@ -354,8 +226,8 @@ async def test_select_update_doorbell_settings( mock_msg.changed_data = {"doorbell_settings": {}} mock_msg.new_obj = new_nvr - mock_entry.api.bootstrap.nvr = new_nvr - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.nvr = new_nvr + ufp.ws_msg(mock_msg) await hass.async_block_till_done() new_nvr.update_all_messages.assert_called_once() @@ -366,22 +238,22 @@ async def test_select_update_doorbell_settings( async def test_select_update_doorbell_message( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test select entity update (change doorbell message).""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) state = hass.states.get(entity_id) assert state assert state.state == "Default Message (Welcome)" - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera.copy() + new_camera = doorbell.copy() new_camera.lcd_message = LCDMessage( type=DoorbellMessageType.CUSTOM_MESSAGE, text="Test" ) @@ -390,9 +262,8 @@ async def test_select_update_doorbell_message( mock_msg.changed_data = {} mock_msg.new_obj = new_camera - new_bootstrap.cameras = {new_camera.id: new_camera} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -401,10 +272,13 @@ async def test_select_update_doorbell_message( async def test_select_set_option_light_motion( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): """Test Light Mode select.""" + + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[0]) light.__fields__["set_light_settings"] = Mock() @@ -423,10 +297,13 @@ async def test_select_set_option_light_motion( async def test_select_set_option_light_camera( - hass: HomeAssistant, - light: Light, + hass: HomeAssistant, ufp: MockUFPFixture, light: Light, camera: Camera ): """Test Paired Camera select.""" + + await init_entry(hass, ufp, [light, camera]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description(Platform.SELECT, light, LIGHT_SELECTS[1]) light.__fields__["set_paired_camera"] = Mock() @@ -454,16 +331,19 @@ async def test_select_set_option_light_camera( async def test_select_set_option_camera_recording( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Recording Mode select.""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[0] + Platform.SELECT, doorbell, CAMERA_SELECTS[0] ) - camera.__fields__["set_recording_mode"] = Mock() - camera.set_recording_mode = AsyncMock() + doorbell.__fields__["set_recording_mode"] = Mock() + doorbell.set_recording_mode = AsyncMock() await hass.services.async_call( "select", @@ -472,20 +352,23 @@ async def test_select_set_option_camera_recording( blocking=True, ) - camera.set_recording_mode.assert_called_once_with(RecordingMode.NEVER) + doorbell.set_recording_mode.assert_called_once_with(RecordingMode.NEVER) async def test_select_set_option_camera_ir( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Infrared Mode select.""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[1] + Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) - camera.__fields__["set_ir_led_model"] = Mock() - camera.set_ir_led_model = AsyncMock() + doorbell.__fields__["set_ir_led_model"] = Mock() + doorbell.set_ir_led_model = AsyncMock() await hass.services.async_call( "select", @@ -494,20 +377,23 @@ async def test_select_set_option_camera_ir( blocking=True, ) - camera.set_ir_led_model.assert_called_once_with(IRLEDMode.ON) + doorbell.set_ir_led_model.assert_called_once_with(IRLEDMode.ON) async def test_select_set_option_camera_doorbell_custom( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text select (user defined message).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "select", @@ -516,22 +402,25 @@ async def test_select_set_option_camera_doorbell_custom( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.CUSTOM_MESSAGE, text="Test" ) async def test_select_set_option_camera_doorbell_unifi( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text select (unifi message).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "select", @@ -543,7 +432,7 @@ async def test_select_set_option_camera_doorbell_unifi( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.LEAVE_PACKAGE_AT_DOOR ) @@ -557,20 +446,23 @@ async def test_select_set_option_camera_doorbell_unifi( blocking=True, ) - camera.set_lcd_text.assert_called_with(None) + doorbell.set_lcd_text.assert_called_with(None) async def test_select_set_option_camera_doorbell_default( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text select (default message).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "select", @@ -582,14 +474,18 @@ async def test_select_set_option_camera_doorbell_default( blocking=True, ) - camera.set_lcd_text.assert_called_once_with(None) + doorbell.set_lcd_text.assert_called_once_with(None) async def test_select_set_option_viewer( - hass: HomeAssistant, - viewer: Viewer, + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer, liveview: Liveview ): """Test Liveview select.""" + + ufp.api.bootstrap.liveviews = {liveview.id: liveview} + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + _, entity_id = ids_from_device_description( Platform.SELECT, viewer, VIEWER_SELECTS[0] ) @@ -610,16 +506,19 @@ async def test_select_set_option_viewer( async def test_select_service_doorbell_invalid( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text service (invalid).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[1] + Platform.SELECT, doorbell, CAMERA_SELECTS[1] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() with pytest.raises(HomeAssistantError): await hass.services.async_call( @@ -629,20 +528,23 @@ async def test_select_service_doorbell_invalid( blocking=True, ) - assert not camera.set_lcd_text.called + assert not doorbell.set_lcd_text.called async def test_select_service_doorbell_success( - hass: HomeAssistant, - camera: Camera, + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Test Doorbell Text service (success).""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "unifiprotect", @@ -654,7 +556,7 @@ async def test_select_service_doorbell_success( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.CUSTOM_MESSAGE, "Test", reset_at=None ) @@ -663,18 +565,23 @@ async def test_select_service_doorbell_success( async def test_select_service_doorbell_with_reset( mock_now, hass: HomeAssistant, - camera: Camera, + ufp: MockUFPFixture, + doorbell: Camera, + fixed_now: datetime, ): """Test Doorbell Text service (success with reset time).""" - now = utcnow() - mock_now.return_value = now + + mock_now.return_value = fixed_now _, entity_id = ids_from_device_description( - Platform.SELECT, camera, CAMERA_SELECTS[2] + Platform.SELECT, doorbell, CAMERA_SELECTS[2] ) - camera.__fields__["set_lcd_text"] = Mock() - camera.set_lcd_text = AsyncMock() + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + + doorbell.__fields__["set_lcd_text"] = Mock() + doorbell.set_lcd_text = AsyncMock() await hass.services.async_call( "unifiprotect", @@ -687,8 +594,8 @@ async def test_select_service_doorbell_with_reset( blocking=True, ) - camera.set_lcd_text.assert_called_once_with( + doorbell.set_lcd_text.assert_called_once_with( DoorbellMessageType.CUSTOM_MESSAGE, "Test", - reset_at=now + timedelta(minutes=60), + reset_at=fixed_now + timedelta(minutes=60), ) diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index 1a84c4f55ca..e204b09b1b0 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -2,11 +2,9 @@ # pylint: disable=protected-access from __future__ import annotations -from copy import copy from datetime import datetime, timedelta from unittest.mock import AsyncMock, Mock -import pytest from pyunifiprotect.data import ( NVR, Camera, @@ -15,7 +13,6 @@ from pyunifiprotect.data import ( Sensor, SmartDetectObjectType, ) -from pyunifiprotect.data.base import WifiConnectionState, WiredConnectionState from pyunifiprotect.data.nvr import EventMetadata from homeassistant.components.unifiprotect.const import ( @@ -42,11 +39,12 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, assert_entity_counts, enable_entity, ids_from_device_description, + init_entry, reset_objects, time_changed, ) @@ -55,136 +53,12 @@ CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] -@pytest.fixture(name="sensor") -async def sensor_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, -): - """Fixture for a single sensor for testing the sensor platform.""" - - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy() - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.battery_status.percentage = 10.0 - sensor_obj.light_settings.is_enabled = True - sensor_obj.humidity_settings.is_enabled = True - sensor_obj.temperature_settings.is_enabled = True - sensor_obj.alarm_settings.is_enabled = True - sensor_obj.stats.light.value = 10.0 - sensor_obj.stats.humidity.value = 10.0 - sensor_obj.stats.temperature.value = 10.0 - sensor_obj.up_since = now - sensor_obj.bluetooth_connection_state.signal_strength = -50.0 - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - yield sensor_obj - - Sensor.__config__.validate_assignment = True - - -@pytest.fixture(name="sensor_none") -async def sensor_none_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_sensor: Sensor, - now: datetime, -): - """Fixture for a single sensor for testing the sensor platform.""" - - # disable pydantic validation so mocking can happen - Sensor.__config__.validate_assignment = False - - sensor_obj = mock_sensor.copy() - sensor_obj._api = mock_entry.api - sensor_obj.name = "Test Sensor" - sensor_obj.battery_status.percentage = 10.0 - sensor_obj.light_settings.is_enabled = False - sensor_obj.humidity_settings.is_enabled = False - sensor_obj.temperature_settings.is_enabled = False - sensor_obj.alarm_settings.is_enabled = False - sensor_obj.up_since = now - sensor_obj.bluetooth_connection_state.signal_strength = -50.0 - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.sensors = { - sensor_obj.id: sensor_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - # 4 from all, 5 from sense, 12 NVR - assert_entity_counts(hass, Platform.SENSOR, 22, 14) - - yield sensor_obj - - Sensor.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_camera: Camera, - now: datetime, -): - """Fixture for a single camera for testing the sensor platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.feature_flags.has_smart_detect = True - camera_obj.feature_flags.has_chime = True - camera_obj.is_smart_detected = False - camera_obj.wired_connection_state = WiredConnectionState(phy_rate=1000) - camera_obj.wifi_connection_state = WifiConnectionState( - signal_quality=100, signal_strength=-50 - ) - camera_obj.stats.rx_bytes = 100.0 - camera_obj.stats.tx_bytes = 100.0 - camera_obj.stats.video.recording_start = now - camera_obj.stats.storage.used = 100.0 - camera_obj.stats.storage.used = 100.0 - camera_obj.stats.storage.rate = 0.1 - camera_obj.voltage = 20.0 - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.nvr.system_info.storage.devices = [] - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - yield camera_obj - - Camera.__config__.validate_assignment = True - - async def test_sensor_setup_sensor( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): """Test sensor entity setup for sensor devices.""" - # 5 from all, 5 from sense, 12 NVR + + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) entity_registry = er.async_get(hass) @@ -196,6 +70,57 @@ async def test_sensor_setup_sensor( "10.0", "none", ) + for index, description in enumerate(SENSE_SENSORS_WRITE): + if not description.entity_registry_enabled_default: + continue + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor_all, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == expected_values[index] + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + # BLE signal + unique_id, entity_id = ids_from_device_description( + Platform.SENSOR, sensor_all, ALL_DEVICES_SENSORS[1] + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == "-50" + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_sensor_setup_sensor_none( + hass: HomeAssistant, ufp: MockUFPFixture, sensor: Sensor +): + """Test sensor entity setup for sensor devices with no sensors enabled.""" + + await init_entry(hass, ufp, [sensor]) + assert_entity_counts(hass, Platform.SENSOR, 22, 14) + + entity_registry = er.async_get(hass) + + expected_values = ( + "10", + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + STATE_UNAVAILABLE, + ) for index, description in enumerate(SENSE_SENSORS_WRITE): if not description.entity_registry_enabled_default: continue @@ -212,63 +137,15 @@ async def test_sensor_setup_sensor( assert state.state == expected_values[index] assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - # BLE signal - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, ALL_DEVICES_SENSORS[1] - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.disabled is True - assert entity.unique_id == unique_id - - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - - state = hass.states.get(entity_id) - assert state - assert state.state == "-50" - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - -async def test_sensor_setup_sensor_none( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor_none: Sensor -): - """Test sensor entity setup for sensor devices with no sensors enabled.""" - - entity_registry = er.async_get(hass) - - expected_values = ( - "10", - STATE_UNAVAILABLE, - STATE_UNAVAILABLE, - STATE_UNAVAILABLE, - STATE_UNAVAILABLE, - ) - for index, description in enumerate(SENSE_SENSORS_WRITE): - if not description.entity_registry_enabled_default: - continue - unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor_none, description - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.unique_id == unique_id - - state = hass.states.get(entity_id) - assert state - assert state.state == expected_values[index] - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - async def test_sensor_setup_nvr( - hass: HomeAssistant, mock_entry: MockEntityFixture, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, fixed_now: datetime ): """Test sensor entity setup for NVR device.""" - reset_objects(mock_entry.api.bootstrap) - nvr: NVR = mock_entry.api.bootstrap.nvr - nvr.up_since = now + reset_objects(ufp.api.bootstrap) + nvr: NVR = ufp.api.bootstrap.nvr + nvr.up_since = fixed_now nvr.system_info.cpu.average_load = 50.0 nvr.system_info.cpu.temperature = 50.0 nvr.storage_stats.utilization = 50.0 @@ -282,16 +159,15 @@ async def test_sensor_setup_nvr( nvr.storage_stats.storage_distribution.free.percentage = 50.0 nvr.storage_stats.capacity = 50.0 - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - # 2 from all, 4 from sense, 12 NVR assert_entity_counts(hass, Platform.SENSOR, 12, 9) entity_registry = er.async_get(hass) expected_values = ( - now.replace(second=0, microsecond=0).isoformat(), + fixed_now.replace(second=0, microsecond=0).isoformat(), "50.0", "50.0", "50.0", @@ -312,7 +188,7 @@ async def test_sensor_setup_nvr( assert entity.unique_id == unique_id if not description.entity_registry_enabled_default: - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -330,7 +206,7 @@ async def test_sensor_setup_nvr( assert entity.disabled is not description.entity_registry_enabled_default assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -338,22 +214,19 @@ async def test_sensor_setup_nvr( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_sensor_nvr_missing_values( - hass: HomeAssistant, mock_entry: MockEntityFixture, now: datetime -): +async def test_sensor_nvr_missing_values(hass: HomeAssistant, ufp: MockUFPFixture): """Test NVR sensor sensors if no data available.""" - reset_objects(mock_entry.api.bootstrap) - nvr: NVR = mock_entry.api.bootstrap.nvr + reset_objects(ufp.api.bootstrap) + nvr: NVR = ufp.api.bootstrap.nvr nvr.system_info.memory.available = None nvr.system_info.memory.total = None nvr.up_since = None nvr.storage_stats.capacity = None - await hass.config_entries.async_setup(mock_entry.entry.entry_id) + await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() - # 2 from all, 4 from sense, 12 NVR assert_entity_counts(hass, Platform.SENSOR, 12, 9) entity_registry = er.async_get(hass) @@ -368,7 +241,7 @@ async def test_sensor_nvr_missing_values( assert entity assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -401,7 +274,7 @@ async def test_sensor_nvr_missing_values( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -410,16 +283,17 @@ async def test_sensor_nvr_missing_values( async def test_sensor_setup_camera( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime ): """Test sensor entity setup for camera devices.""" - # 3 from all, 7 from camera, 12 NVR + + await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SENSOR, 25, 13) entity_registry = er.async_get(hass) expected_values = ( - now.replace(microsecond=0).isoformat(), + fixed_now.replace(microsecond=0).isoformat(), "100", "100.0", "20.0", @@ -428,7 +302,7 @@ async def test_sensor_setup_camera( if not description.entity_registry_enabled_default: continue unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, description + Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -444,7 +318,7 @@ async def test_sensor_setup_camera( expected_values = ("100", "100") for index, description in enumerate(CAMERA_DISABLED_SENSORS): unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, description + Platform.SENSOR, doorbell, description ) entity = entity_registry.async_get(entity_id) @@ -452,7 +326,7 @@ async def test_sensor_setup_camera( assert entity.disabled is not description.entity_registry_enabled_default assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -461,7 +335,7 @@ async def test_sensor_setup_camera( # Wired signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, ALL_DEVICES_SENSORS[2] + Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[2] ) entity = entity_registry.async_get(entity_id) @@ -469,7 +343,7 @@ async def test_sensor_setup_camera( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -478,7 +352,7 @@ async def test_sensor_setup_camera( # WiFi signal unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, ALL_DEVICES_SENSORS[3] + Platform.SENSOR, doorbell, ALL_DEVICES_SENSORS[3] ) entity = entity_registry.async_get(entity_id) @@ -486,7 +360,7 @@ async def test_sensor_setup_camera( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -495,7 +369,7 @@ async def test_sensor_setup_camera( # Detected Object unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, MOTION_SENSORS[0] + Platform.SENSOR, doorbell, MOTION_SENSORS[0] ) entity = entity_registry.async_get(entity_id) @@ -512,16 +386,20 @@ async def test_sensor_setup_camera( async def test_sensor_setup_camera_with_last_trip_time( hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock, - mock_entry: MockEntityFixture, - camera: Camera, - now: datetime, + ufp: MockUFPFixture, + doorbell: Camera, + fixed_now: datetime, ): """Test sensor entity setup for camera devices with last trip time.""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SENSOR, 25, 25) + entity_registry = er.async_get(hass) # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, camera, MOTION_TRIP_SENSORS[0] + Platform.SENSOR, doorbell, MOTION_TRIP_SENSORS[0] ) entity = entity_registry.async_get(entity_id) @@ -530,35 +408,38 @@ async def test_sensor_setup_camera_with_last_trip_time( state = hass.states.get(entity_id) assert state - assert state.state == "2021-12-20T17:26:53+00:00" + assert ( + state.state + == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + ) assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION async def test_sensor_update_motion( - hass: HomeAssistant, mock_entry: MockEntityFixture, camera: Camera, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, fixed_now: datetime ): """Test sensor motion entity.""" - # 3 from all, 7 from camera, 12 NVR + + await init_entry(hass, ufp, [doorbell]) assert_entity_counts(hass, Platform.SENSOR, 25, 13) _, entity_id = ids_from_device_description( - Platform.SENSOR, camera, MOTION_SENSORS[0] + Platform.SENSOR, doorbell, MOTION_SENSORS[0] ) event = Event( id="test_event_id", type=EventType.SMART_DETECT, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[SmartDetectObjectType.PERSON], smart_detect_event_ids=[], - camera_id=camera.id, - api=mock_entry.api, + camera_id=doorbell.id, + api=ufp.api, ) - new_bootstrap = copy(mock_entry.api.bootstrap) - new_camera = camera.copy() + new_camera = doorbell.copy() new_camera.is_smart_detected = True new_camera.last_smart_detect_event_id = event.id @@ -566,10 +447,9 @@ async def test_sensor_update_motion( mock_msg.changed_data = {} mock_msg.new_obj = event - new_bootstrap.cameras = {new_camera.id: new_camera} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.cameras = {new_camera.id: new_camera} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -580,31 +460,31 @@ async def test_sensor_update_motion( async def test_sensor_update_alarm( - hass: HomeAssistant, mock_entry: MockEntityFixture, sensor: Sensor, now: datetime + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor, fixed_now: datetime ): """Test sensor motion entity.""" - # 5 from all, 5 from sense, 12 NVR + + await init_entry(hass, ufp, [sensor_all]) assert_entity_counts(hass, Platform.SENSOR, 22, 14) _, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, SENSE_SENSORS_WRITE[4] + Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[4] ) - event_metadata = EventMetadata(sensor_id=sensor.id, alarm_type="smoke") + event_metadata = EventMetadata(sensor_id=sensor_all.id, alarm_type="smoke") event = Event( id="test_event_id", type=EventType.SENSOR_ALARM, - start=now - timedelta(seconds=1), + start=fixed_now - timedelta(seconds=1), end=None, score=100, smart_detect_types=[], smart_detect_event_ids=[], metadata=event_metadata, - api=mock_entry.api, + api=ufp.api, ) - new_bootstrap = copy(mock_entry.api.bootstrap) - new_sensor = sensor.copy() + new_sensor = sensor_all.copy() new_sensor.set_alarm_timeout() new_sensor.last_alarm_event_id = event.id @@ -612,10 +492,9 @@ async def test_sensor_update_alarm( mock_msg.changed_data = {} mock_msg.new_obj = event - new_bootstrap.sensors = {new_sensor.id: new_sensor} - new_bootstrap.events = {event.id: event} - mock_entry.api.bootstrap = new_bootstrap - mock_entry.api.ws_subscription(mock_msg) + ufp.api.bootstrap.sensors = {new_sensor.id: new_sensor} + ufp.api.bootstrap.events = {event.id: event} + ufp.ws_msg(mock_msg) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -627,15 +506,18 @@ async def test_sensor_update_alarm( async def test_sensor_update_alarm_with_last_trip_time( hass: HomeAssistant, entity_registry_enabled_by_default: AsyncMock, - mock_entry: MockEntityFixture, - sensor: Sensor, - now: datetime, + ufp: MockUFPFixture, + sensor_all: Sensor, + fixed_now: datetime, ): """Test sensor motion entity with last trip time.""" + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 22, 22) + # Last Trip Time unique_id, entity_id = ids_from_device_description( - Platform.SENSOR, sensor, SENSE_SENSORS_WRITE[-3] + Platform.SENSOR, sensor_all, SENSE_SENSORS_WRITE[-3] ) entity_registry = er.async_get(hass) @@ -645,5 +527,8 @@ async def test_sensor_update_alarm_with_last_trip_time( state = hass.states.get(entity_id) assert state - assert state.state == "2022-01-04T04:03:56+00:00" + assert ( + state.state + == (fixed_now - timedelta(hours=1)).replace(microsecond=0).isoformat() + ) assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION diff --git a/tests/components/unifiprotect/test_services.py b/tests/components/unifiprotect/test_services.py index d957fe16b4b..460ba488cb2 100644 --- a/tests/components/unifiprotect/test_services.py +++ b/tests/components/unifiprotect/test_services.py @@ -6,7 +6,6 @@ from unittest.mock import AsyncMock, Mock import pytest from pyunifiprotect.data import Camera, Chime, Light, ModelType -from pyunifiprotect.data.bootstrap import ProtectDeviceRef from pyunifiprotect.exceptions import BadRequest from homeassistant.components.unifiprotect.const import ATTR_MESSAGE, DOMAIN @@ -21,15 +20,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er -from .conftest import MockEntityFixture, regenerate_device_ids +from .utils import MockUFPFixture, init_entry @pytest.fixture(name="device") -async def device_fixture(hass: HomeAssistant, mock_entry: MockEntityFixture): +async def device_fixture(hass: HomeAssistant, ufp: MockUFPFixture): """Fixture with entry setup to call services with.""" - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, []) device_registry = dr.async_get(hass) @@ -37,30 +35,20 @@ async def device_fixture(hass: HomeAssistant, mock_entry: MockEntityFixture): @pytest.fixture(name="subdevice") -async def subdevice_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): +async def subdevice_fixture(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): """Fixture with entry setup to call services with.""" - mock_light._api = mock_entry.api - mock_entry.api.bootstrap.lights = { - mock_light.id: mock_light, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, [light]) device_registry = dr.async_get(hass) return [d for d in device_registry.devices.values() if d.name != "UnifiProtect"][0] -async def test_global_service_bad_device( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture -): +async def test_global_service_bad_device(hass: HomeAssistant, ufp: MockUFPFixture): """Test global service, invalid device ID.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["add_custom_doorbell_message"] = Mock() nvr.add_custom_doorbell_message = AsyncMock() @@ -75,11 +63,11 @@ async def test_global_service_bad_device( async def test_global_service_exception( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture ): """Test global service, unexpected error.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["add_custom_doorbell_message"] = Mock() nvr.add_custom_doorbell_message = AsyncMock(side_effect=BadRequest) @@ -94,11 +82,11 @@ async def test_global_service_exception( async def test_add_doorbell_text( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture ): """Test add_doorbell_text service.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["add_custom_doorbell_message"] = Mock() nvr.add_custom_doorbell_message = AsyncMock() @@ -112,11 +100,11 @@ async def test_add_doorbell_text( async def test_remove_doorbell_text( - hass: HomeAssistant, subdevice: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, subdevice: dr.DeviceEntry, ufp: MockUFPFixture ): """Test remove_doorbell_text service.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["remove_custom_doorbell_message"] = Mock() nvr.remove_custom_doorbell_message = AsyncMock() @@ -130,11 +118,11 @@ async def test_remove_doorbell_text( async def test_set_default_doorbell_text( - hass: HomeAssistant, device: dr.DeviceEntry, mock_entry: MockEntityFixture + hass: HomeAssistant, device: dr.DeviceEntry, ufp: MockUFPFixture ): """Test set_default_doorbell_text service.""" - nvr = mock_entry.api.bootstrap.nvr + nvr = ufp.api.bootstrap.nvr nvr.__fields__["set_default_doorbell_message"] = Mock() nvr.set_default_doorbell_message = AsyncMock() @@ -149,57 +137,21 @@ async def test_set_default_doorbell_text( async def test_set_chime_paired_doorbells( hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_chime: Chime, - mock_camera: Camera, + ufp: MockUFPFixture, + chime: Chime, + doorbell: Camera, ): """Test set_chime_paired_doorbells.""" - mock_entry.api.update_device = AsyncMock() + ufp.api.update_device = AsyncMock() - mock_chime._api = mock_entry.api - mock_chime.name = "Test Chime" - mock_chime._initial_data = mock_chime.dict() - mock_entry.api.bootstrap.chimes = { - mock_chime.id: mock_chime, - } - mock_entry.api.bootstrap.mac_lookup = { - mock_chime.mac.lower(): ProtectDeviceRef( - model=mock_chime.model, id=mock_chime.id - ) - } - - camera1 = mock_camera.copy() + camera1 = doorbell.copy() camera1.name = "Test Camera 1" - camera1._api = mock_entry.api - camera1.channels[0]._api = mock_entry.api - camera1.channels[1]._api = mock_entry.api - camera1.channels[2]._api = mock_entry.api - camera1.feature_flags.has_chime = True - regenerate_device_ids(camera1) - camera2 = mock_camera.copy() + camera2 = doorbell.copy() camera2.name = "Test Camera 2" - camera2._api = mock_entry.api - camera2.channels[0]._api = mock_entry.api - camera2.channels[1]._api = mock_entry.api - camera2.channels[2]._api = mock_entry.api - camera2.feature_flags.has_chime = True - regenerate_device_ids(camera2) - mock_entry.api.bootstrap.cameras = { - camera1.id: camera1, - camera2.id: camera2, - } - mock_entry.api.bootstrap.mac_lookup[camera1.mac.lower()] = ProtectDeviceRef( - model=camera1.model, id=camera1.id - ) - mock_entry.api.bootstrap.mac_lookup[camera2.mac.lower()] = ProtectDeviceRef( - model=camera2.model, id=camera2.id - ) - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, [camera1, camera2, chime]) registry = er.async_get(hass) chime_entry = registry.async_get("button.test_chime_play_chime") @@ -220,6 +172,6 @@ async def test_set_chime_paired_doorbells( blocking=True, ) - mock_entry.api.update_device.assert_called_once_with( - ModelType.CHIME, mock_chime.id, {"cameraIds": sorted([camera1.id, camera2.id])} + ufp.api.update_device.assert_called_once_with( + ModelType.CHIME, chime.id, {"cameraIds": sorted([camera1.id, camera2.id])} ) diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 6c9340af5d5..0c45ec28b7b 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -5,14 +5,7 @@ from __future__ import annotations from unittest.mock import AsyncMock, Mock import pytest -from pyunifiprotect.data import ( - Camera, - Light, - Permission, - RecordingMode, - SmartDetectObjectType, - VideoMode, -) +from pyunifiprotect.data import Camera, Light, Permission, RecordingMode, VideoMode from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION from homeassistant.components.unifiprotect.switch import ( @@ -24,12 +17,12 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_OFF, Pla from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .conftest import ( - MockEntityFixture, +from .utils import ( + MockUFPFixture, assert_entity_counts, enable_entity, ids_from_device_description, - reset_objects, + init_entry, ) CAMERA_SWITCHES_BASIC = [ @@ -44,218 +37,33 @@ CAMERA_SWITCHES_NO_EXTRA = [ ] -@pytest.fixture(name="light") -async def light_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_light: Light -): - """Fixture for a single light for testing the switch platform.""" - - # disable pydantic validation so mocking can happen - Light.__config__.validate_assignment = False - - light_obj = mock_light.copy() - light_obj._api = mock_entry.api - light_obj.name = "Test Light" - light_obj.is_ssh_enabled = False - light_obj.light_device_settings.is_indicator_enabled = False - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SWITCH, 2, 1) - - yield light_obj - - Light.__config__.validate_assignment = True - - -@pytest.fixture(name="camera") -async def camera_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the switch platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.recording_settings.mode = RecordingMode.DETECTIONS - camera_obj.feature_flags.has_led_status = True - camera_obj.feature_flags.has_hdr = True - camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT, VideoMode.HIGH_FPS] - camera_obj.feature_flags.has_privacy_mask = True - camera_obj.feature_flags.has_speaker = True - camera_obj.feature_flags.has_smart_detect = True - camera_obj.feature_flags.smart_detect_types = [ - SmartDetectObjectType.PERSON, - SmartDetectObjectType.VEHICLE, - ] - camera_obj.is_ssh_enabled = False - camera_obj.led_settings.is_enabled = False - camera_obj.hdr_mode = False - camera_obj.video_mode = VideoMode.DEFAULT - camera_obj.remove_privacy_zone() - camera_obj.speaker_settings.are_system_sounds_enabled = False - camera_obj.osd_settings.is_name_enabled = False - camera_obj.osd_settings.is_date_enabled = False - camera_obj.osd_settings.is_logo_enabled = False - camera_obj.osd_settings.is_debug_enabled = False - camera_obj.smart_detect_settings.object_types = [] - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SWITCH, 13, 12) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_none") -async def camera_none_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the switch platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.recording_settings.mode = RecordingMode.DETECTIONS - camera_obj.feature_flags.has_led_status = False - camera_obj.feature_flags.has_hdr = False - camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] - camera_obj.feature_flags.has_privacy_mask = False - camera_obj.feature_flags.has_speaker = False - camera_obj.feature_flags.has_smart_detect = False - camera_obj.is_ssh_enabled = False - camera_obj.osd_settings.is_name_enabled = False - camera_obj.osd_settings.is_date_enabled = False - camera_obj.osd_settings.is_logo_enabled = False - camera_obj.osd_settings.is_debug_enabled = False - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SWITCH, 6, 5) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - -@pytest.fixture(name="camera_privacy") -async def camera_privacy_fixture( - hass: HomeAssistant, mock_entry: MockEntityFixture, mock_camera: Camera -): - """Fixture for a single camera for testing the switch platform.""" - - # disable pydantic validation so mocking can happen - Camera.__config__.validate_assignment = False - - # mock_camera._update_lock = None - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - camera_obj.name = "Test Camera" - camera_obj.recording_settings.mode = RecordingMode.NEVER - camera_obj.feature_flags.has_led_status = False - camera_obj.feature_flags.has_hdr = False - camera_obj.feature_flags.video_modes = [VideoMode.DEFAULT] - camera_obj.feature_flags.has_privacy_mask = True - camera_obj.feature_flags.has_speaker = False - camera_obj.feature_flags.has_smart_detect = False - camera_obj.add_privacy_zone() - camera_obj.is_ssh_enabled = False - camera_obj.osd_settings.is_name_enabled = False - camera_obj.osd_settings.is_date_enabled = False - camera_obj.osd_settings.is_logo_enabled = False - camera_obj.osd_settings.is_debug_enabled = False - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() - - assert_entity_counts(hass, Platform.SWITCH, 7, 6) - - yield camera_obj - - Camera.__config__.validate_assignment = True - - async def test_switch_setup_no_perm( hass: HomeAssistant, - mock_entry: MockEntityFixture, - mock_light: Light, - mock_camera: Camera, + ufp: MockUFPFixture, + light: Light, + doorbell: Camera, ): """Test switch entity setup for light devices.""" - light_obj = mock_light.copy() - light_obj._api = mock_entry.api - - camera_obj = mock_camera.copy() - camera_obj._api = mock_entry.api - camera_obj.channels[0]._api = mock_entry.api - camera_obj.channels[1]._api = mock_entry.api - camera_obj.channels[2]._api = mock_entry.api - - reset_objects(mock_entry.api.bootstrap) - mock_entry.api.bootstrap.lights = { - light_obj.id: light_obj, - } - mock_entry.api.bootstrap.cameras = { - camera_obj.id: camera_obj, - } - mock_entry.api.bootstrap.auth_user.all_permissions = [ + ufp.api.bootstrap.auth_user.all_permissions = [ Permission.unifi_dict_to_dict({"rawPermission": "light:read:*"}) ] - await hass.config_entries.async_setup(mock_entry.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, [light, doorbell]) assert_entity_counts(hass, Platform.SWITCH, 0, 0) async def test_switch_setup_light( hass: HomeAssistant, - mock_entry: MockEntityFixture, + ufp: MockUFPFixture, light: Light, ): """Test switch entity setup for light devices.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + entity_registry = er.async_get(hass) description = LIGHT_SWITCHES[1] @@ -283,7 +91,7 @@ async def test_switch_setup_light( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -293,14 +101,67 @@ async def test_switch_setup_light( async def test_switch_setup_camera_all( hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera: Camera, + ufp: MockUFPFixture, + doorbell: Camera, ): """Test switch entity setup for camera devices (all enabled feature flags).""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + entity_registry = er.async_get(hass) for description in CAMERA_SWITCHES_BASIC: + unique_id, entity_id = ids_from_device_description( + Platform.SWITCH, doorbell, description + ) + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.unique_id == unique_id + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + description = CAMERA_SWITCHES[0] + + description_entity_name = ( + description.name.lower().replace(":", "").replace(" ", "_") + ) + unique_id = f"{doorbell.mac}_{description.key}" + entity_id = f"switch.test_camera_{description_entity_name}" + + entity = entity_registry.async_get(entity_id) + assert entity + assert entity.disabled is True + assert entity.unique_id == unique_id + + await enable_entity(hass, ufp.entry.entry_id, entity_id) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION + + +async def test_switch_setup_camera_none( + hass: HomeAssistant, + ufp: MockUFPFixture, + camera: Camera, +): + """Test switch entity setup for camera devices (no enabled feature flags).""" + + await init_entry(hass, ufp, [camera]) + assert_entity_counts(hass, Platform.SWITCH, 6, 5) + + entity_registry = er.async_get(hass) + + for description in CAMERA_SWITCHES_BASIC: + if description.ufp_required_field is not None: + continue + unique_id, entity_id = ids_from_device_description( Platform.SWITCH, camera, description ) @@ -327,7 +188,7 @@ async def test_switch_setup_camera_all( assert entity.disabled is True assert entity.unique_id == unique_id - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + await enable_entity(hass, ufp.entry.entry_id, entity_id) state = hass.states.get(entity_id) assert state @@ -335,56 +196,14 @@ async def test_switch_setup_camera_all( assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION -async def test_switch_setup_camera_none( - hass: HomeAssistant, - mock_entry: MockEntityFixture, - camera_none: Camera, +async def test_switch_light_status( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): - """Test switch entity setup for camera devices (no enabled feature flags).""" - - entity_registry = er.async_get(hass) - - for description in CAMERA_SWITCHES_BASIC: - if description.ufp_required_field is not None: - continue - - unique_id, entity_id = ids_from_device_description( - Platform.SWITCH, camera_none, description - ) - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.unique_id == unique_id - - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - description = CAMERA_SWITCHES[0] - - description_entity_name = ( - description.name.lower().replace(":", "").replace(" ", "_") - ) - unique_id = f"{camera_none.mac}_{description.key}" - entity_id = f"switch.test_camera_{description_entity_name}" - - entity = entity_registry.async_get(entity_id) - assert entity - assert entity.disabled is True - assert entity.unique_id == unique_id - - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) - - state = hass.states.get(entity_id) - assert state - assert state.state == STATE_OFF - assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION - - -async def test_switch_light_status(hass: HomeAssistant, light: Light): """Tests status light switch for lights.""" + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + description = LIGHT_SWITCHES[1] light.__fields__["set_status_light"] = Mock() @@ -406,44 +225,53 @@ async def test_switch_light_status(hass: HomeAssistant, light: Light): async def test_switch_camera_ssh( - hass: HomeAssistant, camera: Camera, mock_entry: MockEntityFixture + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Tests SSH switch for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[0] - camera.__fields__["set_ssh"] = Mock() - camera.set_ssh = AsyncMock() + doorbell.__fields__["set_ssh"] = Mock() + doorbell.set_ssh = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) - await enable_entity(hass, mock_entry.entry.entry_id, entity_id) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) + await enable_entity(hass, ufp.entry.entry_id, entity_id) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_ssh.assert_called_once_with(True) + doorbell.set_ssh.assert_called_once_with(True) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_ssh.assert_called_with(False) + doorbell.set_ssh.assert_called_with(False) @pytest.mark.parametrize("description", CAMERA_SWITCHES_NO_EXTRA) async def test_switch_camera_simple( - hass: HomeAssistant, camera: Camera, description: ProtectSwitchEntityDescription + hass: HomeAssistant, + ufp: MockUFPFixture, + doorbell: Camera, + description: ProtectSwitchEntityDescription, ): """Tests all simple switches for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + assert description.ufp_set_method is not None - camera.__fields__[description.ufp_set_method] = Mock() - setattr(camera, description.ufp_set_method, AsyncMock()) - set_method = getattr(camera, description.ufp_set_method) + doorbell.__fields__[description.ufp_set_method] = Mock() + setattr(doorbell, description.ufp_set_method, AsyncMock()) + set_method = getattr(doorbell, description.ufp_set_method) - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True @@ -458,70 +286,82 @@ async def test_switch_camera_simple( set_method.assert_called_with(False) -async def test_switch_camera_highfps(hass: HomeAssistant, camera: Camera): +async def test_switch_camera_highfps( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +): """Tests High FPS switch for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[3] - camera.__fields__["set_video_mode"] = Mock() - camera.set_video_mode = AsyncMock() + doorbell.__fields__["set_video_mode"] = Mock() + doorbell.set_video_mode = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_video_mode.assert_called_once_with(VideoMode.HIGH_FPS) + doorbell.set_video_mode.assert_called_once_with(VideoMode.HIGH_FPS) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_video_mode.assert_called_with(VideoMode.DEFAULT) + doorbell.set_video_mode.assert_called_with(VideoMode.DEFAULT) -async def test_switch_camera_privacy(hass: HomeAssistant, camera: Camera): +async def test_switch_camera_privacy( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +): """Tests Privacy Mode switch for cameras.""" + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[4] - camera.__fields__["set_privacy"] = Mock() - camera.set_privacy = AsyncMock() + doorbell.__fields__["set_privacy"] = Mock() + doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description(Platform.SWITCH, camera, description) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_on", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_privacy.assert_called_once_with(True, 0, RecordingMode.NEVER) + doorbell.set_privacy.assert_called_once_with(True, 0, RecordingMode.NEVER) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera.set_privacy.assert_called_with( - False, camera.mic_volume, camera.recording_settings.mode + doorbell.set_privacy.assert_called_with( + False, doorbell.mic_volume, doorbell.recording_settings.mode ) async def test_switch_camera_privacy_already_on( - hass: HomeAssistant, camera_privacy: Camera + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera ): """Tests Privacy Mode switch for cameras with privacy mode defaulted on.""" + doorbell.add_privacy_zone() + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + description = CAMERA_SWITCHES[4] - camera_privacy.__fields__["set_privacy"] = Mock() - camera_privacy.set_privacy = AsyncMock() + doorbell.__fields__["set_privacy"] = Mock() + doorbell.set_privacy = AsyncMock() - _, entity_id = ids_from_device_description( - Platform.SWITCH, camera_privacy, description - ) + _, entity_id = ids_from_device_description(Platform.SWITCH, doorbell, description) await hass.services.async_call( "switch", "turn_off", {ATTR_ENTITY_ID: entity_id}, blocking=True ) - camera_privacy.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS) + doorbell.set_privacy.assert_called_once_with(False, 100, RecordingMode.ALWAYS) diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py new file mode 100644 index 00000000000..517da9e73c6 --- /dev/null +++ b/tests/components/unifiprotect/utils.py @@ -0,0 +1,168 @@ +"""Test helpers for UniFi Protect.""" +# pylint: disable=protected-access +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import Any, Callable, Sequence + +from pyunifiprotect import ProtectApiClient +from pyunifiprotect.data import ( + Bootstrap, + Camera, + ProtectAdoptableDeviceModel, + WSSubscriptionMessage, +) +from pyunifiprotect.data.bootstrap import ProtectDeviceRef +from pyunifiprotect.test_util.anonymize import random_hex + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant, split_entity_id +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityDescription +import homeassistant.util.dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed + + +@dataclass +class MockUFPFixture: + """Mock for NVR.""" + + entry: MockConfigEntry + api: ProtectApiClient + ws_subscription: Callable[[WSSubscriptionMessage], None] | None = None + + def ws_msg(self, msg: WSSubscriptionMessage) -> Any: + """Emit WS message for testing.""" + + if self.ws_subscription is not None: + return self.ws_subscription(msg) + + +def reset_objects(bootstrap: Bootstrap): + """Reset bootstrap objects.""" + + bootstrap.cameras = {} + bootstrap.lights = {} + bootstrap.sensors = {} + bootstrap.viewers = {} + bootstrap.events = {} + bootstrap.doorlocks = {} + bootstrap.chimes = {} + + +async def time_changed(hass: HomeAssistant, seconds: int) -> None: + """Trigger time changed.""" + next_update = dt_util.utcnow() + timedelta(seconds) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + +async def enable_entity( + hass: HomeAssistant, entry_id: str, entity_id: str +) -> er.RegistryEntry: + """Enable a disabled entity.""" + entity_registry = er.async_get(hass) + + updated_entity = entity_registry.async_update_entity(entity_id, disabled_by=None) + assert not updated_entity.disabled + await hass.config_entries.async_reload(entry_id) + await hass.async_block_till_done() + + return updated_entity + + +def assert_entity_counts( + hass: HomeAssistant, platform: Platform, total: int, enabled: int +) -> None: + """Assert entity counts for a given platform.""" + + entity_registry = er.async_get(hass) + + entities = [ + e for e in entity_registry.entities if split_entity_id(e)[0] == platform.value + ] + + assert len(entities) == total + assert len(hass.states.async_all(platform.value)) == enabled + + +def normalize_name(name: str) -> str: + """Normalize name.""" + + return name.lower().replace(":", "").replace(" ", "_").replace("-", "_") + + +def ids_from_device_description( + platform: Platform, + device: ProtectAdoptableDeviceModel, + description: EntityDescription, +) -> tuple[str, str]: + """Return expected unique_id and entity_id for a give platform/device/description combination.""" + + entity_name = normalize_name(device.display_name) + description_entity_name = normalize_name(str(description.name)) + + unique_id = f"{device.mac}_{description.key}" + entity_id = f"{platform.value}.{entity_name}_{description_entity_name}" + + return unique_id, entity_id + + +def generate_random_ids() -> tuple[str, str]: + """Generate random IDs for device.""" + + return random_hex(24).lower(), random_hex(12).upper() + + +def regenerate_device_ids(device: ProtectAdoptableDeviceModel) -> None: + """Regenerate the IDs on UFP device.""" + + device.id, device.mac = generate_random_ids() + + +def add_device_ref(bootstrap: Bootstrap, device: ProtectAdoptableDeviceModel) -> None: + """Manually add device ref to bootstrap for lookup.""" + + ref = ProtectDeviceRef(id=device.id, model=device.model) + bootstrap.id_lookup[device.id] = ref + bootstrap.mac_lookup[device.mac.lower()] = ref + + +def add_device( + bootstrap: Bootstrap, device: ProtectAdoptableDeviceModel, regenerate_ids: bool +) -> None: + """Add test device to bootstrap.""" + + if device.model is None: + return + + device._api = bootstrap.api + if isinstance(device, Camera): + for channel in device.channels: + channel._api = bootstrap.api + + if regenerate_ids: + regenerate_device_ids(device) + device._initial_data = device.dict() + + devices = getattr(bootstrap, f"{device.model.value}s") + devices[device.id] = device + add_device_ref(bootstrap, device) + + +async def init_entry( + hass: HomeAssistant, + ufp: MockUFPFixture, + devices: Sequence[ProtectAdoptableDeviceModel], + regenerate_ids: bool = True, +) -> None: + """Initialize Protect entry with given devices.""" + + reset_objects(ufp.api.bootstrap) + for device in devices: + add_device(ufp.api.bootstrap, device, regenerate_ids) + + await hass.config_entries.async_setup(ufp.entry.entry_id) + await hass.async_block_till_done() From 15ed329108af095e21f1d8eb987430a7a6e707d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jun 2022 11:02:38 -0500 Subject: [PATCH 687/947] Add async_remove_config_entry_device support to nexia (#73966) Co-authored-by: Paulus Schoutsen --- homeassistant/components/nexia/__init__.py | 23 +++++++-- tests/components/nexia/test_init.py | 60 ++++++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) create mode 100644 tests/components/nexia/test_init.py diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index ab491c0a271..355c17a2ed1 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -5,11 +5,13 @@ import logging import aiohttp from nexia.const import BRAND_NEXIA from nexia.home import NexiaHome +from nexia.thermostat import NexiaThermostat from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -66,8 +68,23 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: 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: + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a nexia config entry from a device.""" + coordinator: NexiaDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + nexia_home: NexiaHome = coordinator.nexia_home + dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN} + for thermostat_id in nexia_home.get_thermostat_ids(): + if thermostat_id in dev_ids: + return False + thermostat: NexiaThermostat = nexia_home.get_thermostat_by_id(thermostat_id) + for zone_id in thermostat.get_zone_ids(): + if zone_id in dev_ids: + return False + return True diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py new file mode 100644 index 00000000000..667c03a23cf --- /dev/null +++ b/tests/components/nexia/test_init.py @@ -0,0 +1,60 @@ +"""The init tests for the nexia platform.""" + + +from homeassistant.components.nexia.const import DOMAIN +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component + +from .util import async_init_integration + + +async def remove_device(ws_client, device_id, config_entry_id): + """Remove config entry from a device.""" + await ws_client.send_json( + { + "id": 5, + "type": "config/device_registry/remove_config_entry", + "config_entry_id": config_entry_id, + "device_id": device_id, + } + ) + response = await ws_client.receive_json() + return response["success"] + + +async def test_device_remove_devices(hass, hass_ws_client): + """Test we can only remove a device that no longer exists.""" + await async_setup_component(hass, "config", {}) + config_entry = await async_init_integration(hass) + entry_id = config_entry.entry_id + device_registry = dr.async_get(hass) + + registry: EntityRegistry = er.async_get(hass) + entity = registry.entities["sensor.nick_office_temperature"] + + live_zone_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), live_zone_device_entry.id, entry_id + ) + is False + ) + + entity = registry.entities["sensor.master_suite_relative_humidity"] + live_thermostat_device_entry = device_registry.async_get(entity.device_id) + assert ( + await remove_device( + await hass_ws_client(hass), live_thermostat_device_entry.id, entry_id + ) + is False + ) + + dead_device_entry = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, "unused")}, + ) + assert ( + await remove_device(await hass_ws_client(hass), dead_device_entry.id, entry_id) + is True + ) From 949922ef2cb7a7c6e4a83cb91856c70728acd806 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jun 2022 11:19:11 -0500 Subject: [PATCH 688/947] Fix exception when as_dict is called on a TemplateState (#73984) --- homeassistant/helpers/template.py | 4 +++- tests/helpers/test_template.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index eca76a8c7bc..53e2eb122f6 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -5,7 +5,7 @@ from ast import literal_eval import asyncio import base64 import collections.abc -from collections.abc import Callable, Generator, Iterable +from collections.abc import Callable, Collection, Generator, Iterable from contextlib import contextmanager, suppress from contextvars import ContextVar from datetime import datetime, timedelta @@ -54,6 +54,7 @@ from homeassistant.util import ( slugify as slugify_util, ) from homeassistant.util.async_ import run_callback_threadsafe +from homeassistant.util.read_only_dict import ReadOnlyDict from homeassistant.util.thread import ThreadWithException from . import area_registry, device_registry, entity_registry, location as loc_helper @@ -764,6 +765,7 @@ class TemplateStateBase(State): self._hass = hass self._collect = collect self._entity_id = entity_id + self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None def _collect_state(self) -> None: if self._collect and _RENDER_INFO in self._hass.data: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 69a3af22759..59b653bc23e 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -26,6 +26,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.helpers import device_registry as dr, entity, template from homeassistant.helpers.entity_platform import EntityPlatform +from homeassistant.helpers.json import json_dumps from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import UnitSystem @@ -3841,3 +3842,12 @@ async def test_template_states_blocks_setitem(hass): template_state = template.TemplateState(hass, state, True) with pytest.raises(RuntimeError): template_state["any"] = "any" + + +async def test_template_states_can_serialize(hass): + """Test TemplateState is serializable.""" + hass.states.async_set("light.new", STATE_ON) + state = hass.states.get("light.new") + template_state = template.TemplateState(hass, state, True) + assert template_state.as_dict() is template_state.as_dict() + assert json_dumps(template_state) == json_dumps(template_state) From f78d209f9387386f0888fc791b822b6de90f12b4 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Sat, 25 Jun 2022 19:26:57 +0200 Subject: [PATCH 689/947] Bump bimmer_connected to 0.9.6 (#73977) Co-authored-by: rikroe --- homeassistant/components/bmw_connected_drive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index 40af0b9e210..b10d4842163 100644 --- a/homeassistant/components/bmw_connected_drive/manifest.json +++ b/homeassistant/components/bmw_connected_drive/manifest.json @@ -2,7 +2,7 @@ "domain": "bmw_connected_drive", "name": "BMW Connected Drive", "documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive", - "requirements": ["bimmer_connected==0.9.5"], + "requirements": ["bimmer_connected==0.9.6"], "codeowners": ["@gerard33", "@rikroe"], "config_flow": true, "iot_class": "cloud_polling", diff --git a/requirements_all.txt b/requirements_all.txt index c76bc5c26f3..656cd4d5bcd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -393,7 +393,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.5 +bimmer_connected==0.9.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7c50b7ee62b..4728716b779 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -308,7 +308,7 @@ beautifulsoup4==4.11.1 bellows==0.30.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.9.5 +bimmer_connected==0.9.6 # homeassistant.components.blebox blebox_uniapi==1.3.3 From ce144bf63145813c76fbbe4f9423341764695057 Mon Sep 17 00:00:00 2001 From: Khole Date: Sat, 25 Jun 2022 23:13:30 +0100 Subject: [PATCH 690/947] Add Hive device configuration to config flow (#73955) Co-authored-by: Martin Hjelmare --- homeassistant/components/hive/config_flow.py | 37 +++- homeassistant/components/hive/const.py | 1 + homeassistant/components/hive/manifest.json | 2 +- homeassistant/components/hive/strings.json | 9 +- .../components/hive/translations/en.json | 9 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/hive/test_config_flow.py | 162 +++++++++++++++--- 8 files changed, 190 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 16d83dc311d..5368aa22c3f 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -14,7 +14,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback -from .const import CONF_CODE, CONFIG_ENTRY_VERSION, DOMAIN +from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -29,6 +29,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.tokens = {} self.entry = None self.device_registration = False + self.device_name = "Home Assistant" async def async_step_user(self, user_input=None): """Prompt user input. Create or edit entry.""" @@ -60,7 +61,7 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_2fa() if not errors: - # Complete the entry setup. + # Complete the entry. try: return await self.async_setup_hive_entry() except UnknownHiveError: @@ -89,15 +90,36 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "no_internet_available" if not errors: - try: - self.device_registration = True + if self.context["source"] == config_entries.SOURCE_REAUTH: return await self.async_setup_hive_entry() - except UnknownHiveError: - errors["base"] = "unknown" + self.device_registration = True + return await self.async_step_configuration() schema = vol.Schema({vol.Required(CONF_CODE): str}) return self.async_show_form(step_id="2fa", data_schema=schema, errors=errors) + async def async_step_configuration(self, user_input=None): + """Handle hive configuration step.""" + errors = {} + + if user_input: + if self.device_registration: + self.device_name = user_input["device_name"] + await self.hive_auth.device_registration(user_input["device_name"]) + self.data["device_data"] = await self.hive_auth.get_device_data() + + try: + return await self.async_setup_hive_entry() + except UnknownHiveError: + errors["base"] = "unknown" + + schema = vol.Schema( + {vol.Optional(CONF_DEVICE_NAME, default=self.device_name): str} + ) + return self.async_show_form( + step_id="configuration", data_schema=schema, errors=errors + ) + async def async_setup_hive_entry(self): """Finish setup and create the config entry.""" @@ -105,9 +127,6 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): raise UnknownHiveError # Setup the config entry - if self.device_registration: - await self.hive_auth.device_registration("Home Assistant") - self.data["device_data"] = await self.hive_auth.getDeviceData() self.data["tokens"] = self.tokens if self.context["source"] == config_entries.SOURCE_REAUTH: self.hass.config_entries.async_update_entry( diff --git a/homeassistant/components/hive/const.py b/homeassistant/components/hive/const.py index 82c07761eef..b7a2be6910f 100644 --- a/homeassistant/components/hive/const.py +++ b/homeassistant/components/hive/const.py @@ -5,6 +5,7 @@ ATTR_MODE = "mode" ATTR_TIME_PERIOD = "time_period" ATTR_ONOFF = "on_off" CONF_CODE = "2fa" +CONF_DEVICE_NAME = "device_name" CONFIG_ENTRY_VERSION = 1 DEFAULT_NAME = "Hive" DOMAIN = "hive" diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 29477bf7414..406b32d86f8 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -6,7 +6,7 @@ "models": ["HHKBridge*"] }, "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.5.11"], + "requirements": ["pyhiveapi==0.5.13"], "codeowners": ["@Rendili", "@KJonline"], "iot_class": "cloud_polling", "loggers": ["apyhiveapi"] diff --git a/homeassistant/components/hive/strings.json b/homeassistant/components/hive/strings.json index 7628abc5b06..3435517aec7 100644 --- a/homeassistant/components/hive/strings.json +++ b/homeassistant/components/hive/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "title": "Hive Login", - "description": "Enter your Hive login information and configuration.", + "description": "Enter your Hive login information.", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", @@ -17,6 +17,13 @@ "2fa": "Two-factor code" } }, + "configuration": { + "data": { + "device_name": "Device Name" + }, + "description": "Enter your Hive configuration ", + "title": "Hive Configuration." + }, "reauth": { "title": "Hive Login", "description": "Re-enter your Hive login information.", diff --git a/homeassistant/components/hive/translations/en.json b/homeassistant/components/hive/translations/en.json index 32453da0a0c..3ef7b3d0f43 100644 --- a/homeassistant/components/hive/translations/en.json +++ b/homeassistant/components/hive/translations/en.json @@ -20,6 +20,13 @@ "description": "Enter your Hive authentication code. \n \n Please enter code 0000 to request another code.", "title": "Hive Two-factor Authentication." }, + "configuration": { + "data": { + "device_name": "Device Name" + }, + "description": "Enter your Hive configuration ", + "title": "Hive Configuration." + }, "reauth": { "data": { "password": "Password", @@ -34,7 +41,7 @@ "scan_interval": "Scan Interval (seconds)", "username": "Username" }, - "description": "Enter your Hive login information and configuration.", + "description": "Enter your Hive login information.", "title": "Hive Login" } } diff --git a/requirements_all.txt b/requirements_all.txt index 656cd4d5bcd..dca486c0d3f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1541,7 +1541,7 @@ pyheos==0.7.2 pyhik==0.3.0 # homeassistant.components.hive -pyhiveapi==0.5.11 +pyhiveapi==0.5.13 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4728716b779..73f98c5eafc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1035,7 +1035,7 @@ pyhaversion==22.4.1 pyheos==0.7.2 # homeassistant.components.hive -pyhiveapi==0.5.11 +pyhiveapi==0.5.13 # homeassistant.components.homematic pyhomematic==0.1.77 diff --git a/tests/components/hive/test_config_flow.py b/tests/components/hive/test_config_flow.py index 35e20e8eee3..e6e2a06501a 100644 --- a/tests/components/hive/test_config_flow.py +++ b/tests/components/hive/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from apyhiveapi.helper import hive_exceptions from homeassistant import config_entries, data_entry_flow -from homeassistant.components.hive.const import CONF_CODE, DOMAIN +from homeassistant.components.hive.const import CONF_CODE, CONF_DEVICE_NAME, DOMAIN from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from tests.common import MockConfigEntry @@ -16,6 +16,7 @@ UPDATED_PASSWORD = "updated-password" INCORRECT_PASSWORD = "incorrect-password" SCAN_INTERVAL = 120 UPDATED_SCAN_INTERVAL = 60 +DEVICE_NAME = "Test Home Assistant" MFA_CODE = "1234" MFA_RESEND_CODE = "0000" MFA_INVALID_CODE = "HIVE" @@ -148,11 +149,23 @@ async def test_user_flow_2fa(hass): "AccessToken": "mock-access-token", }, }, - ), patch( + ): + result3 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: MFA_CODE, + }, + ) + + assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result3["step_id"] == "configuration" + assert result3["errors"] == {} + + with patch( "homeassistant.components.hive.config_flow.Auth.device_registration", return_value=True, ), patch( - "homeassistant.components.hive.config_flow.Auth.getDeviceData", + "homeassistant.components.hive.config_flow.Auth.get_device_data", return_value=[ "mock-device-group-key", "mock-device-key", @@ -164,14 +177,17 @@ async def test_user_flow_2fa(hass): "homeassistant.components.hive.async_setup_entry", return_value=True, ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], {CONF_CODE: MFA_CODE} + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_DEVICE_NAME: DEVICE_NAME, + }, ) await hass.async_block_till_done() - assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result3["title"] == USERNAME - assert result3["data"] == { + assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result4["title"] == USERNAME + assert result4["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -235,9 +251,6 @@ async def test_reauth_flow(hass): "AccessToken": "mock-access-token", }, }, - ), patch( - "homeassistant.components.hive.config_flow.Auth.device_registration", - return_value=True, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -255,6 +268,82 @@ async def test_reauth_flow(hass): assert len(hass.config_entries.async_entries(DOMAIN)) == 1 +async def test_reauth_2fa_flow(hass): + """Test the reauth flow.""" + + mock_config = MockConfigEntry( + domain=DOMAIN, + unique_id=USERNAME, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: INCORRECT_PASSWORD, + "tokens": { + "AccessToken": "mock-access-token", + "RefreshToken": "mock-refresh-token", + }, + }, + ) + mock_config.add_to_hass(hass) + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + side_effect=hive_exceptions.HiveInvalidPassword(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_config.unique_id, + }, + data=mock_config.data, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_password"} + + with patch( + "homeassistant.components.hive.config_flow.Auth.login", + return_value={ + "ChallengeName": "SMS_MFA", + }, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: USERNAME, + CONF_PASSWORD: UPDATED_PASSWORD, + }, + ) + + with patch( + "homeassistant.components.hive.config_flow.Auth.sms_2fa", + return_value={ + "ChallengeName": "SUCCESS", + "AuthenticationResult": { + "RefreshToken": "mock-refresh-token", + "AccessToken": "mock-access-token", + }, + }, + ), patch( + "homeassistant.components.hive.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + { + CONF_CODE: MFA_CODE, + }, + ) + await hass.async_block_till_done() + + assert mock_config.data.get("username") == USERNAME + assert mock_config.data.get("password") == UPDATED_PASSWORD + assert result3["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result3["reason"] == "reauth_successful" + assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + async def test_option_flow(hass): """Test config flow options.""" @@ -343,11 +432,23 @@ async def test_user_flow_2fa_send_new_code(hass): "AccessToken": "mock-access-token", }, }, - ), patch( + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_CODE: MFA_CODE, + }, + ) + + assert result4["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result4["step_id"] == "configuration" + assert result4["errors"] == {} + + with patch( "homeassistant.components.hive.config_flow.Auth.device_registration", return_value=True, ), patch( - "homeassistant.components.hive.config_flow.Auth.getDeviceData", + "homeassistant.components.hive.config_flow.Auth.get_device_data", return_value=[ "mock-device-group-key", "mock-device-key", @@ -359,14 +460,14 @@ async def test_user_flow_2fa_send_new_code(hass): "homeassistant.components.hive.async_setup_entry", return_value=True, ) as mock_setup_entry: - result4 = await hass.config_entries.flow.async_configure( - result3["flow_id"], {CONF_CODE: MFA_CODE} + result5 = await hass.config_entries.flow.async_configure( + result4["flow_id"], {CONF_DEVICE_NAME: DEVICE_NAME} ) await hass.async_block_till_done() - assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result4["title"] == USERNAME - assert result4["data"] == { + assert result5["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result5["title"] == USERNAME + assert result5["data"] == { CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, "tokens": { @@ -610,7 +711,28 @@ async def test_user_flow_2fa_unknown_error(hass): result2["flow_id"], {CONF_CODE: MFA_CODE}, ) - await hass.async_block_till_done() assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result3["errors"] == {"base": "unknown"} + assert result3["step_id"] == "configuration" + assert result3["errors"] == {} + + with patch( + "homeassistant.components.hive.config_flow.Auth.device_registration", + return_value=True, + ), patch( + "homeassistant.components.hive.config_flow.Auth.get_device_data", + return_value=[ + "mock-device-group-key", + "mock-device-key", + "mock-device-password", + ], + ): + result4 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE_NAME: DEVICE_NAME}, + ) + await hass.async_block_till_done() + + assert result4["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result4["step_id"] == "configuration" + assert result4["errors"] == {"base": "unknown"} From ba64d9db64a9ceeea0a0a8a6bb6c32014d46d4b3 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 26 Jun 2022 00:28:21 +0000 Subject: [PATCH 691/947] [ci skip] Translation update --- .../components/cast/translations/he.json | 5 ++++ .../components/dnsip/translations/he.json | 29 +++++++++++++++++++ .../components/generic/translations/he.json | 4 +-- .../components/google/translations/he.json | 3 ++ .../components/google/translations/hu.json | 1 + .../components/konnected/translations/he.json | 4 +-- .../components/nest/translations/hu.json | 1 + .../overkiz/translations/sensor.hu.json | 5 ++++ .../radiotherm/translations/he.json | 8 +++++ .../components/rfxtrx/translations/he.json | 7 +++++ .../components/scrape/translations/he.json | 12 ++++++++ .../simplepush/translations/de.json | 21 ++++++++++++++ .../simplepush/translations/hu.json | 21 ++++++++++++++ .../components/skybell/translations/he.json | 14 +++++++++ .../tomorrowio/translations/sensor.he.json | 7 +++++ 15 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/dnsip/translations/he.json create mode 100644 homeassistant/components/radiotherm/translations/he.json create mode 100644 homeassistant/components/scrape/translations/he.json create mode 100644 homeassistant/components/simplepush/translations/de.json create mode 100644 homeassistant/components/simplepush/translations/hu.json create mode 100644 homeassistant/components/skybell/translations/he.json create mode 100644 homeassistant/components/tomorrowio/translations/sensor.he.json diff --git a/homeassistant/components/cast/translations/he.json b/homeassistant/components/cast/translations/he.json index d50e5b20684..2d225eb1fed 100644 --- a/homeassistant/components/cast/translations/he.json +++ b/homeassistant/components/cast/translations/he.json @@ -25,6 +25,11 @@ }, "step": { "advanced_options": { + "data": { + "ignore_cec": "\u05dc\u05d4\u05ea\u05e2\u05dc\u05dd \u05de-CEC", + "uuid": "UUIDs \u05de\u05d5\u05ea\u05e8\u05d9\u05dd" + }, + "description": "UUIDs \u05de\u05d5\u05ea\u05e8\u05d9\u05dd - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc UUIDs \u05e9\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9 Cast \u05dc\u05d4\u05d5\u05e1\u05e4\u05d4 \u05d0\u05dc Home Assistant. \u05d9\u05e9 \u05dc\u05d4\u05e9\u05ea\u05de\u05e9 \u05e8\u05e7 \u05d0\u05dd \u05d0\u05d9\u05df \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d5\u05e1\u05d9\u05e3 \u05d0\u05ea \u05db\u05dc \u05d4\u05ea\u05e7\u05e0\u05d9 cast \u05d4\u05d6\u05de\u05d9\u05e0\u05d9\u05dd.\n\u05dc\u05d4\u05ea\u05e2\u05dc\u05dd \u05de-CEC - \u05e8\u05e9\u05d9\u05de\u05d4 \u05de\u05d5\u05e4\u05e8\u05d3\u05ea \u05d1\u05e4\u05e1\u05d9\u05e7\u05d9\u05dd \u05e9\u05dc Chromecasts \u05e9\u05d0\u05de\u05d5\u05e8\u05d4 \u05dc\u05d4\u05ea\u05e2\u05dc\u05dd \u05de\u05e0\u05ea\u05d5\u05e0\u05d9 CEC \u05dc\u05e7\u05d1\u05d9\u05e2\u05ea \u05d4\u05e7\u05dc\u05d8 \u05d4\u05e4\u05e2\u05d9\u05dc. \u05d6\u05d4 \u05d9\u05d5\u05e2\u05d1\u05e8 \u05dc- pychromecast.IGNORE_CEC.", "title": "\u05ea\u05e6\u05d5\u05e8\u05d4 \u05de\u05ea\u05e7\u05d3\u05de\u05ea \u05e9\u05dc Google Cast" }, "basic_options": { diff --git a/homeassistant/components/dnsip/translations/he.json b/homeassistant/components/dnsip/translations/he.json new file mode 100644 index 00000000000..13f865d61a2 --- /dev/null +++ b/homeassistant/components/dnsip/translations/he.json @@ -0,0 +1,29 @@ +{ + "config": { + "error": { + "invalid_hostname": "\u05e9\u05dd \u05de\u05d0\u05e8\u05d7 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea" + }, + "step": { + "user": { + "data": { + "hostname": "\u05e9\u05dd \u05d4\u05de\u05d0\u05e8\u05d7 \u05e9\u05e2\u05d1\u05d5\u05e8\u05d5 \u05e0\u05d9\u05ea\u05df \u05dc\u05d1\u05e6\u05e2 \u05e9\u05d0\u05d9\u05dc\u05ea\u05ea DNS", + "resolver": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV4", + "resolver_ipv6": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV6" + } + } + } + }, + "options": { + "error": { + "invalid_resolver": "\u05db\u05ea\u05d5\u05d1\u05ea IP \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea \u05dc\u05de\u05e4\u05e2\u05e0\u05d7" + }, + "step": { + "init": { + "data": { + "resolver": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV4", + "resolver_ipv6": "\u05de\u05e4\u05e2\u05e0\u05d7 \u05e2\u05d1\u05d5\u05e8 \u05d1\u05d3\u05d9\u05e7\u05ea IPV6" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/generic/translations/he.json b/homeassistant/components/generic/translations/he.json index f39f78074f5..3d2f8cdca4e 100644 --- a/homeassistant/components/generic/translations/he.json +++ b/homeassistant/components/generic/translations/he.json @@ -36,7 +36,7 @@ "limit_refetch_to_url_change": "\u05d4\u05d2\u05d1\u05dc\u05d4 \u05e9\u05dc \u05d0\u05d7\u05e1\u05d5\u05df \u05d7\u05d5\u05d6\u05e8 \u05dc\u05e9\u05d9\u05e0\u05d5\u05d9 \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05ea\u05e8", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "rtsp_transport": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc \u05ea\u05e2\u05d1\u05d5\u05e8\u05d4 RTSP", - "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, http://...)", + "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, https://...)", "stream_source": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05de\u05e7\u05d5\u05e8 \u05d6\u05e8\u05dd (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, rtsp://...)", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" @@ -75,7 +75,7 @@ "limit_refetch_to_url_change": "\u05d4\u05d2\u05d1\u05dc\u05d4 \u05e9\u05dc \u05d0\u05d7\u05e1\u05d5\u05df \u05d7\u05d5\u05d6\u05e8 \u05dc\u05e9\u05d9\u05e0\u05d5\u05d9 \u05db\u05ea\u05d5\u05d1\u05ea \u05d4\u05d0\u05ea\u05e8", "password": "\u05e1\u05d9\u05e1\u05de\u05d4", "rtsp_transport": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc \u05ea\u05e2\u05d1\u05d5\u05e8\u05d4 RTSP", - "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, http://...)", + "still_image_url": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05ea\u05de\u05d5\u05e0\u05ea \u05e1\u05d8\u05d9\u05dc\u05e1 (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, https://...)", "stream_source": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d0\u05ea\u05e8 \u05e9\u05dc \u05de\u05e7\u05d5\u05e8 \u05d6\u05e8\u05dd (\u05dc\u05d3\u05d5\u05d2\u05de\u05d4, rtsp://...)", "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9", "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" diff --git a/homeassistant/components/google/translations/he.json b/homeassistant/components/google/translations/he.json index df5ec28163e..191450ed8eb 100644 --- a/homeassistant/components/google/translations/he.json +++ b/homeassistant/components/google/translations/he.json @@ -11,6 +11,9 @@ "create_entry": { "default": "\u05d0\u05d5\u05de\u05ea \u05d1\u05d4\u05e6\u05dc\u05d7\u05d4" }, + "progress": { + "exchange": "\u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05d0\u05ea \u05d7\u05e9\u05d1\u05d5\u05df \u05d4\u05d2\u05d5\u05d2\u05dc \u05e9\u05dc\u05da, \u05d9\u05e9 \u05dc\u05d1\u05e7\u05e8 \u05d1\u05db\u05ea\u05d5\u05d1\u05ea [{url}]({url}) \u05d5\u05dc\u05d4\u05d6\u05d9\u05df \u05e7\u05d5\u05d3:\n\n{user_code}" + }, "step": { "auth": { "title": "\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df \u05d2\u05d5\u05d2\u05dc" diff --git a/homeassistant/components/google/translations/hu.json b/homeassistant/components/google/translations/hu.json index 6a5c7d2d68c..0ff516dcfed 100644 --- a/homeassistant/components/google/translations/hu.json +++ b/homeassistant/components/google/translations/hu.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "already_in_progress": "A be\u00e1ll\u00edt\u00e1si folyamat m\u00e1r el lett kezdve", + "cannot_connect": "Sikertelen csatlakoz\u00e1s", "code_expired": "A hiteles\u00edt\u00e9si k\u00f3d lej\u00e1rt vagy a hiteles\u00edt\u0151 adatok be\u00e1ll\u00edt\u00e1sa \u00e9rv\u00e9nytelen, k\u00e9rj\u00fck, pr\u00f3b\u00e1lja meg \u00fajra.", "invalid_access_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", diff --git a/homeassistant/components/konnected/translations/he.json b/homeassistant/components/konnected/translations/he.json index f07caab4ee1..622c73e2e61 100644 --- a/homeassistant/components/konnected/translations/he.json +++ b/homeassistant/components/konnected/translations/he.json @@ -26,7 +26,7 @@ "step": { "options_binary": { "data": { - "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + "name": "\u05e9\u05dd" } }, "options_digital": { @@ -39,7 +39,7 @@ }, "options_switch": { "data": { - "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)" + "name": "\u05e9\u05dd" } } } diff --git a/homeassistant/components/nest/translations/hu.json b/homeassistant/components/nest/translations/hu.json index a93d06c4f6d..228f4f5e745 100644 --- a/homeassistant/components/nest/translations/hu.json +++ b/homeassistant/components/nest/translations/hu.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "A fi\u00f3k m\u00e1r konfigur\u00e1lva van", "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.", "invalid_access_token": "\u00c9rv\u00e9nytelen hozz\u00e1f\u00e9r\u00e9si token", "missing_configuration": "A komponens nincs konfigur\u00e1lva. K\u00e9rem, k\u00f6vesse a dokument\u00e1ci\u00f3t.", diff --git a/homeassistant/components/overkiz/translations/sensor.hu.json b/homeassistant/components/overkiz/translations/sensor.hu.json index a361d0ebde2..5646f6bdd7a 100644 --- a/homeassistant/components/overkiz/translations/sensor.hu.json +++ b/homeassistant/components/overkiz/translations/sensor.hu.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Tiszta", "dirty": "Piszkos" + }, + "overkiz__three_way_handle_direction": { + "closed": "Z\u00e1rva", + "open": "Nyitva", + "tilt": "D\u00f6nt\u00e9s" } } } \ No newline at end of file diff --git a/homeassistant/components/radiotherm/translations/he.json b/homeassistant/components/radiotherm/translations/he.json new file mode 100644 index 00000000000..77232a68dd2 --- /dev/null +++ b/homeassistant/components/radiotherm/translations/he.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "flow_title": "{name} {model} ({host})" + } +} \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/he.json b/homeassistant/components/rfxtrx/translations/he.json index cabe3734e11..2c2ecd6ccae 100644 --- a/homeassistant/components/rfxtrx/translations/he.json +++ b/homeassistant/components/rfxtrx/translations/he.json @@ -32,6 +32,13 @@ "error": { "already_configured_device": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d4\u05ea\u05e7\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4", "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d1\u05dc\u05ea\u05d9 \u05e6\u05e4\u05d5\u05d9\u05d4" + }, + "step": { + "prompt_options": { + "data": { + "protocols": "\u05e4\u05e8\u05d5\u05d8\u05d5\u05e7\u05d5\u05dc\u05d9\u05dd" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/scrape/translations/he.json b/homeassistant/components/scrape/translations/he.json new file mode 100644 index 00000000000..463ce9035f4 --- /dev/null +++ b/homeassistant/components/scrape/translations/he.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "\u05e9\u05dd", + "verify_ssl": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d0\u05d9\u05e9\u05d5\u05e8 SSL" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/de.json b/homeassistant/components/simplepush/translations/de.json new file mode 100644 index 00000000000..c7f633d312d --- /dev/null +++ b/homeassistant/components/simplepush/translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen" + }, + "step": { + "user": { + "data": { + "device_key": "Der Ger\u00e4teschl\u00fcssel deines Ger\u00e4ts", + "event": "Das Ereignis f\u00fcr die Ereignisse.", + "name": "Name", + "password": "Das Passwort der von deinem Ger\u00e4t verwendeten Verschl\u00fcsselung", + "salt": "Das von deinem Ger\u00e4t verwendete Salt." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/hu.json b/homeassistant/components/simplepush/translations/hu.json new file mode 100644 index 00000000000..09195f343aa --- /dev/null +++ b/homeassistant/components/simplepush/translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Az eszk\u00f6z m\u00e1r konfigur\u00e1lva van" + }, + "error": { + "cannot_connect": "Sikertelen csatlakoz\u00e1s" + }, + "step": { + "user": { + "data": { + "device_key": "Az eszk\u00f6z kulcsa", + "event": "Az esem\u00e9ny", + "name": "Elnevez\u00e9s", + "password": "A k\u00e9sz\u00fcl\u00e9k \u00e1ltal haszn\u00e1lt titkos\u00edt\u00e1s jelszava", + "salt": "A k\u00e9sz\u00fcl\u00e9k \u00e1ltal haszn\u00e1lt salt." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/skybell/translations/he.json b/homeassistant/components/skybell/translations/he.json new file mode 100644 index 00000000000..28e8ddd34c9 --- /dev/null +++ b/homeassistant/components/skybell/translations/he.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u05ea\u05e6\u05d5\u05e8\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d1\u05e8 \u05e0\u05e7\u05d1\u05e2\u05d4" + }, + "step": { + "user": { + "data": { + "email": "\u05d3\u05d5\u05d0\"\u05dc" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tomorrowio/translations/sensor.he.json b/homeassistant/components/tomorrowio/translations/sensor.he.json new file mode 100644 index 00000000000..a91a9b3255b --- /dev/null +++ b/homeassistant/components/tomorrowio/translations/sensor.he.json @@ -0,0 +1,7 @@ +{ + "state": { + "tomorrowio__precipitation_type": { + "none": "\u05dc\u05dc\u05d0" + } + } +} \ No newline at end of file From 6ec6f0a835b9f24ae24b73e966d0036d7975ee39 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 25 Jun 2022 21:02:50 -0500 Subject: [PATCH 692/947] Fix file sensor reading the whole file to get the last line (#73986) --- homeassistant/components/file/manifest.json | 3 +- homeassistant/components/file/sensor.py | 4 +- requirements_all.txt | 3 ++ requirements_test_all.txt | 3 ++ tests/components/file/fixtures/file_empty.txt | 0 tests/components/file/fixtures/file_value.txt | 3 ++ .../file/fixtures/file_value_template.txt | 2 + tests/components/file/test_sensor.py | 50 +++++++++---------- 8 files changed, 40 insertions(+), 28 deletions(-) create mode 100644 tests/components/file/fixtures/file_empty.txt create mode 100644 tests/components/file/fixtures/file_value.txt create mode 100644 tests/components/file/fixtures/file_value_template.txt diff --git a/homeassistant/components/file/manifest.json b/homeassistant/components/file/manifest.json index 8688ed7939c..2283e74a5e7 100644 --- a/homeassistant/components/file/manifest.json +++ b/homeassistant/components/file/manifest.json @@ -3,5 +3,6 @@ "name": "File", "documentation": "https://www.home-assistant.io/integrations/file", "codeowners": ["@fabaff"], - "iot_class": "local_polling" + "iot_class": "local_polling", + "requirements": ["file-read-backwards==2.0.0"] } diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py index e69a7701eb9..8c0966f30bd 100644 --- a/homeassistant/components/file/sensor.py +++ b/homeassistant/components/file/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations import logging import os +from file_read_backwards import FileReadBackwards import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -77,9 +78,10 @@ class FileSensor(SensorEntity): def update(self): """Get the latest entry from a file and updates the state.""" try: - with open(self._file_path, encoding="utf-8") as file_data: + with FileReadBackwards(self._file_path, encoding="utf-8") as file_data: for line in file_data: data = line + break data = data.strip() except (IndexError, FileNotFoundError, IsADirectoryError, UnboundLocalError): _LOGGER.warning( diff --git a/requirements_all.txt b/requirements_all.txt index dca486c0d3f..7af4684e169 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -637,6 +637,9 @@ feedparser==6.0.2 # homeassistant.components.fibaro fiblary3==0.1.8 +# homeassistant.components.file +file-read-backwards==2.0.0 + # homeassistant.components.fints fints==3.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73f98c5eafc..04c6f2d2708 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -455,6 +455,9 @@ feedparser==6.0.2 # homeassistant.components.fibaro fiblary3==0.1.8 +# homeassistant.components.file +file-read-backwards==2.0.0 + # homeassistant.components.fivem fivem-api==0.1.2 diff --git a/tests/components/file/fixtures/file_empty.txt b/tests/components/file/fixtures/file_empty.txt new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/components/file/fixtures/file_value.txt b/tests/components/file/fixtures/file_value.txt new file mode 100644 index 00000000000..acfd8fbf1a9 --- /dev/null +++ b/tests/components/file/fixtures/file_value.txt @@ -0,0 +1,3 @@ +43 +45 +21 diff --git a/tests/components/file/fixtures/file_value_template.txt b/tests/components/file/fixtures/file_value_template.txt new file mode 100644 index 00000000000..30a1b0ea8ba --- /dev/null +++ b/tests/components/file/fixtures/file_value_template.txt @@ -0,0 +1,2 @@ +{"temperature": 29, "humidity": 31} +{"temperature": 26, "humidity": 36} diff --git a/tests/components/file/test_sensor.py b/tests/components/file/test_sensor.py index 97fe6250d02..725ccb527f8 100644 --- a/tests/components/file/test_sensor.py +++ b/tests/components/file/test_sensor.py @@ -1,5 +1,5 @@ """The tests for local file sensor platform.""" -from unittest.mock import Mock, mock_open, patch +from unittest.mock import Mock, patch import pytest @@ -7,7 +7,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import mock_registry +from tests.common import get_fixture_path, mock_registry @pytest.fixture @@ -21,13 +21,14 @@ def entity_reg(hass): async def test_file_value(hass: HomeAssistant) -> None: """Test the File sensor.""" config = { - "sensor": {"platform": "file", "name": "file1", "file_path": "mock.file1"} + "sensor": { + "platform": "file", + "name": "file1", + "file_path": get_fixture_path("file_value.txt", "file"), + } } - m_open = mock_open(read_data="43\n45\n21") - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=True): + with patch.object(hass.config, "is_allowed_path", return_value=True): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() @@ -43,19 +44,12 @@ async def test_file_value_template(hass: HomeAssistant) -> None: "sensor": { "platform": "file", "name": "file2", - "file_path": "mock.file2", + "file_path": get_fixture_path("file_value_template.txt", "file"), "value_template": "{{ value_json.temperature }}", } } - data = ( - '{"temperature": 29, "humidity": 31}\n' + '{"temperature": 26, "humidity": 36}' - ) - - m_open = mock_open(read_data=data) - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=True): + with patch.object(hass.config, "is_allowed_path", return_value=True): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() @@ -67,12 +61,15 @@ async def test_file_value_template(hass: HomeAssistant) -> None: @patch("os.access", Mock(return_value=True)) async def test_file_empty(hass: HomeAssistant) -> None: """Test the File sensor with an empty file.""" - config = {"sensor": {"platform": "file", "name": "file3", "file_path": "mock.file"}} + config = { + "sensor": { + "platform": "file", + "name": "file3", + "file_path": get_fixture_path("file_empty.txt", "file"), + } + } - m_open = mock_open(read_data="") - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=True): + with patch.object(hass.config, "is_allowed_path", return_value=True): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() @@ -85,13 +82,14 @@ async def test_file_empty(hass: HomeAssistant) -> None: async def test_file_path_invalid(hass: HomeAssistant) -> None: """Test the File sensor with invalid path.""" config = { - "sensor": {"platform": "file", "name": "file4", "file_path": "mock.file4"} + "sensor": { + "platform": "file", + "name": "file4", + "file_path": get_fixture_path("file_value.txt", "file"), + } } - m_open = mock_open(read_data="43\n45\n21") - with patch( - "homeassistant.components.file.sensor.open", m_open, create=True - ), patch.object(hass.config, "is_allowed_path", return_value=False): + with patch.object(hass.config, "is_allowed_path", return_value=False): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() From fb5e6aaa2965dda7c09b1cb49cde3da70d036d1a Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Sun, 26 Jun 2022 11:33:11 +0100 Subject: [PATCH 693/947] Clean up Glances sensors a bit (#73998) --- homeassistant/components/glances/sensor.py | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 0d60747ecaa..7dfd0c503ef 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -7,6 +7,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import GlancesData from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES, GlancesSensorEntityDescription @@ -31,7 +32,6 @@ async def async_setup_entry( name, disk["mnt_point"], description, - config_entry.entry_id, ) ) elif description.type == "sensors": @@ -44,16 +44,11 @@ async def async_setup_entry( name, sensor["label"], description, - config_entry.entry_id, ) ) elif description.type == "raid": for raid_device in client.api.data[description.type]: - dev.append( - GlancesSensor( - client, name, raid_device, description, config_entry.entry_id - ) - ) + dev.append(GlancesSensor(client, name, raid_device, description)) elif client.api.data[description.type]: dev.append( GlancesSensor( @@ -61,7 +56,6 @@ async def async_setup_entry( name, "", description, - config_entry.entry_id, ) ) @@ -75,12 +69,11 @@ class GlancesSensor(SensorEntity): def __init__( self, - glances_data, - name, - sensor_name_prefix, + glances_data: GlancesData, + name: str, + sensor_name_prefix: str, description: GlancesSensorEntityDescription, - config_entry_id: str, - ): + ) -> None: """Initialize the sensor.""" self.glances_data = glances_data self._sensor_name_prefix = sensor_name_prefix @@ -90,7 +83,7 @@ class GlancesSensor(SensorEntity): self.entity_description = description self._attr_name = f"{name} {sensor_name_prefix} {description.name_suffix}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, config_entry_id)}, + identifiers={(DOMAIN, glances_data.config_entry.entry_id)}, manufacturer="Glances", name=name, ) From 9a0b3796d3fcf45b384604f10cb4367f27b0facb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 26 Jun 2022 03:38:17 -0700 Subject: [PATCH 694/947] Bump xmltodict to 0.13.0 (#73974) Changelog: https://github.com/martinblech/xmltodict/blob/v0.13.0/CHANGELOG.md --- homeassistant/components/bluesound/manifest.json | 2 +- homeassistant/components/fritz/manifest.json | 2 +- homeassistant/components/rest/manifest.json | 2 +- homeassistant/components/startca/manifest.json | 2 +- homeassistant/components/ted5000/manifest.json | 2 +- homeassistant/components/zestimate/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json index bfefff36601..d1b86f80326 100644 --- a/homeassistant/components/bluesound/manifest.json +++ b/homeassistant/components/bluesound/manifest.json @@ -2,7 +2,7 @@ "domain": "bluesound", "name": "Bluesound", "documentation": "https://www.home-assistant.io/integrations/bluesound", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": ["@thrawnarn"], "iot_class": "local_polling" } diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 5eb210d2091..e5828ba76cf 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -2,7 +2,7 @@ "domain": "fritz", "name": "AVM FRITZ!Box Tools", "documentation": "https://www.home-assistant.io/integrations/fritz", - "requirements": ["fritzconnection==1.8.0", "xmltodict==0.12.0"], + "requirements": ["fritzconnection==1.8.0", "xmltodict==0.13.0"], "dependencies": ["network"], "codeowners": ["@mammuth", "@AaronDavidSchneider", "@chemelli74", "@mib1185"], "config_flow": true, diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c81656d82b4..f6e7631e623 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -2,7 +2,7 @@ "domain": "rest", "name": "RESTful", "documentation": "https://www.home-assistant.io/integrations/rest", - "requirements": ["jsonpath==0.82", "xmltodict==0.12.0"], + "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json index d08f276e770..68786ecf341 100644 --- a/homeassistant/components/startca/manifest.json +++ b/homeassistant/components/startca/manifest.json @@ -2,7 +2,7 @@ "domain": "startca", "name": "Start.ca", "documentation": "https://www.home-assistant.io/integrations/startca", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json index 1ab57418af5..8852c0184b2 100644 --- a/homeassistant/components/ted5000/manifest.json +++ b/homeassistant/components/ted5000/manifest.json @@ -2,7 +2,7 @@ "domain": "ted5000", "name": "The Energy Detective TED5000", "documentation": "https://www.home-assistant.io/integrations/ted5000", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": [], "iot_class": "local_polling" } diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json index 4fee44ffcac..d382fc26ab0 100644 --- a/homeassistant/components/zestimate/manifest.json +++ b/homeassistant/components/zestimate/manifest.json @@ -2,7 +2,7 @@ "domain": "zestimate", "name": "Zestimate", "documentation": "https://www.home-assistant.io/integrations/zestimate", - "requirements": ["xmltodict==0.12.0"], + "requirements": ["xmltodict==0.13.0"], "codeowners": [], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 7af4684e169..95cbdd5b0bf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ xknx==0.21.3 # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate -xmltodict==0.12.0 +xmltodict==0.13.0 # homeassistant.components.xs1 xs1-api-client==3.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 04c6f2d2708..f9f095161ea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1638,7 +1638,7 @@ xknx==0.21.3 # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate -xmltodict==0.12.0 +xmltodict==0.13.0 # homeassistant.components.yale_smart_alarm yalesmartalarmclient==0.3.8 From 11ec8b9186219e8e9a9b9b620b1ba18df8a0904c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 27 Jun 2022 00:25:49 +0000 Subject: [PATCH 695/947] [ci skip] Translation update --- .../components/google/translations/el.json | 1 + .../components/hive/translations/ca.json | 9 +++++++- .../components/hive/translations/de.json | 9 +++++++- .../components/hive/translations/el.json | 7 +++++++ .../components/hive/translations/fr.json | 7 +++++++ .../components/hive/translations/hu.json | 7 +++++++ .../components/hive/translations/id.json | 9 +++++++- .../components/hive/translations/it.json | 9 +++++++- .../components/hive/translations/ja.json | 7 +++++++ .../components/hive/translations/pt-BR.json | 9 +++++++- .../components/hive/translations/zh-Hant.json | 9 +++++++- .../components/nest/translations/el.json | 1 + .../overkiz/translations/sensor.el.json | 5 +++++ .../simplepush/translations/ca.json | 21 +++++++++++++++++++ .../simplepush/translations/el.json | 21 +++++++++++++++++++ .../simplepush/translations/hu.json | 2 +- .../simplepush/translations/id.json | 21 +++++++++++++++++++ .../simplepush/translations/it.json | 21 +++++++++++++++++++ .../simplepush/translations/ja.json | 21 +++++++++++++++++++ .../transmission/translations/el.json | 10 ++++++++- 20 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 homeassistant/components/simplepush/translations/ca.json create mode 100644 homeassistant/components/simplepush/translations/el.json create mode 100644 homeassistant/components/simplepush/translations/id.json create mode 100644 homeassistant/components/simplepush/translations/it.json create mode 100644 homeassistant/components/simplepush/translations/ja.json diff --git a/homeassistant/components/google/translations/el.json b/homeassistant/components/google/translations/el.json index fdcf17d26ce..65cc5a0038d 100644 --- a/homeassistant/components/google/translations/el.json +++ b/homeassistant/components/google/translations/el.json @@ -6,6 +6,7 @@ "abort": { "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", "already_in_progress": "\u0397 \u03c1\u03bf\u03ae \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7\u03c2 \u03b2\u03c1\u03af\u03c3\u03ba\u03b5\u03c4\u03b1\u03b9 \u03ae\u03b4\u03b7 \u03c3\u03b5 \u03b5\u03be\u03ad\u03bb\u03b9\u03be\u03b7", + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", "code_expired": "\u039f \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ad\u03bb\u03b7\u03be\u03b5 \u03ae \u03b7 \u03c1\u03cd\u03b8\u03bc\u03b9\u03c3\u03b7 \u03c4\u03c9\u03bd \u03b4\u03b9\u03b1\u03c0\u03b9\u03c3\u03c4\u03b5\u03c5\u03c4\u03b7\u03c1\u03af\u03c9\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03ac\u03ba\u03c5\u03c1\u03b7, \u03b4\u03bf\u03ba\u03b9\u03bc\u03ac\u03c3\u03c4\u03b5 \u03be\u03b1\u03bd\u03ac.", "invalid_access_token": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03b4\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", "missing_configuration": "\u03a4\u03bf \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03bf \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7.", diff --git a/homeassistant/components/hive/translations/ca.json b/homeassistant/components/hive/translations/ca.json index edebafba579..50a06169547 100644 --- a/homeassistant/components/hive/translations/ca.json +++ b/homeassistant/components/hive/translations/ca.json @@ -20,6 +20,13 @@ "description": "Introdueix codi d'autenticaci\u00f3 Hive. \n\n Introdueix el codi 0000 per demanar un altre codi.", "title": "Verificaci\u00f3 en dos passos de Hive." }, + "configuration": { + "data": { + "device_name": "Nom del dispositiu" + }, + "description": "Introdueix la teva configuraci\u00f3 de Hive ", + "title": "Configuraci\u00f3 de Hive." + }, "reauth": { "data": { "password": "Contrasenya", @@ -34,7 +41,7 @@ "scan_interval": "Interval d'escaneig (segons)", "username": "Nom d'usuari" }, - "description": "Actualitza la informaci\u00f3 i configuraci\u00f3 d'inici de sessi\u00f3.", + "description": "Introdueix la informaci\u00f3 d'inici de sessi\u00f3 de Hive.", "title": "Inici de sessi\u00f3 Hive" } } diff --git a/homeassistant/components/hive/translations/de.json b/homeassistant/components/hive/translations/de.json index bd5876bb023..e40fd0f499f 100644 --- a/homeassistant/components/hive/translations/de.json +++ b/homeassistant/components/hive/translations/de.json @@ -20,6 +20,13 @@ "description": "Gib deinen Hive-Authentifizierungscode ein. \n \nBitte gib den Code 0000 ein, um einen anderen Code anzufordern.", "title": "Hive Zwei-Faktor-Authentifizierung." }, + "configuration": { + "data": { + "device_name": "Ger\u00e4tename" + }, + "description": "Gib deine Hive-Konfiguration ein ", + "title": "Hive-Konfiguration." + }, "reauth": { "data": { "password": "Passwort", @@ -34,7 +41,7 @@ "scan_interval": "Scanintervall (Sekunden)", "username": "Benutzername" }, - "description": "Gebe deine Anmeldeinformationen und -konfiguration f\u00fcr Hive ein", + "description": "Gib deine Hive-Anmeldeinformationen ein.", "title": "Hive Anmeldung" } } diff --git a/homeassistant/components/hive/translations/el.json b/homeassistant/components/hive/translations/el.json index 986dd52ef19..3c6be9f4eba 100644 --- a/homeassistant/components/hive/translations/el.json +++ b/homeassistant/components/hive/translations/el.json @@ -20,6 +20,13 @@ "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc \u03b5\u03bb\u03ad\u03b3\u03c7\u03bf\u03c5 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 Hive. \n\n \u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03bf\u03bd \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc 0000 \u03b3\u03b9\u03b1 \u03bd\u03b1 \u03b6\u03b7\u03c4\u03ae\u03c3\u03b5\u03c4\u03b5 \u03ac\u03bb\u03bb\u03bf \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc.", "title": "\u0388\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b4\u03cd\u03bf \u03c0\u03b1\u03c1\u03b1\u03b3\u03cc\u03bd\u03c4\u03c9\u03bd \u03c4\u03bf\u03c5 Hive." }, + "configuration": { + "data": { + "device_name": "\u038c\u03bd\u03bf\u03bc\u03b1 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2" + }, + "description": "\u0395\u03b9\u03c3\u03b1\u03b3\u03ac\u03b3\u03b5\u03c4\u03b5 \u03c4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 \u03c4\u03bf\u03c5 Hive \u03c3\u03b1\u03c2", + "title": "\u0394\u03b9\u03b1\u03bc\u03cc\u03c1\u03c6\u03c9\u03c3\u03b7 Hive." + }, "reauth": { "data": { "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03a0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", diff --git a/homeassistant/components/hive/translations/fr.json b/homeassistant/components/hive/translations/fr.json index 5868a2bf175..118a0ff9b75 100644 --- a/homeassistant/components/hive/translations/fr.json +++ b/homeassistant/components/hive/translations/fr.json @@ -20,6 +20,13 @@ "description": "Entrez votre code d\u2019authentification Hive. \n \nVeuillez entrer le code 0000 pour demander un autre code.", "title": "Authentification \u00e0 deux facteurs Hive." }, + "configuration": { + "data": { + "device_name": "Nom de l'appareil" + }, + "description": "Saisissez votre configuration Hive ", + "title": "Configuration Hive." + }, "reauth": { "data": { "password": "Mot de passe", diff --git a/homeassistant/components/hive/translations/hu.json b/homeassistant/components/hive/translations/hu.json index 9b0d3c21590..8a265ff63c0 100644 --- a/homeassistant/components/hive/translations/hu.json +++ b/homeassistant/components/hive/translations/hu.json @@ -20,6 +20,13 @@ "description": "Adja meg a Hive hiteles\u00edt\u00e9si k\u00f3dj\u00e1t. \n \n\u00cdrd be a 0000 k\u00f3dot m\u00e1sik k\u00f3d k\u00e9r\u00e9s\u00e9hez.", "title": "Hive k\u00e9tfaktoros hiteles\u00edt\u00e9s." }, + "configuration": { + "data": { + "device_name": "Eszk\u00f6zn\u00e9v" + }, + "description": "Adja meg Hive konfigur\u00e1ci\u00f3j\u00e1t", + "title": "Hive konfigur\u00e1ci\u00f3." + }, "reauth": { "data": { "password": "Jelsz\u00f3", diff --git a/homeassistant/components/hive/translations/id.json b/homeassistant/components/hive/translations/id.json index e092515e91e..9c008d06802 100644 --- a/homeassistant/components/hive/translations/id.json +++ b/homeassistant/components/hive/translations/id.json @@ -20,6 +20,13 @@ "description": "Masukkan kode autentikasi Hive Anda. \n \nMasukkan kode 0000 untuk meminta kode lain.", "title": "Autentikasi Dua Faktor Hive." }, + "configuration": { + "data": { + "device_name": "Nama Perangkat" + }, + "description": "Masukkan konfigurasi Hive Anda", + "title": "Konfigurasi Hive" + }, "reauth": { "data": { "password": "Kata Sandi", @@ -34,7 +41,7 @@ "scan_interval": "Interval Pindai (detik)", "username": "Nama Pengguna" }, - "description": "Masukkan informasi masuk dan konfigurasi Hive Anda.", + "description": "Masukkan informasi masuk Hive Anda.", "title": "Info Masuk Hive" } } diff --git a/homeassistant/components/hive/translations/it.json b/homeassistant/components/hive/translations/it.json index 38edac70cb1..fcd841cd1d2 100644 --- a/homeassistant/components/hive/translations/it.json +++ b/homeassistant/components/hive/translations/it.json @@ -20,6 +20,13 @@ "description": "Inserisci il tuo codice di autenticazione Hive. \n\n Inserisci il codice 0000 per richiedere un altro codice.", "title": "Autenticazione a due fattori di Hive." }, + "configuration": { + "data": { + "device_name": "Nome del dispositivo" + }, + "description": "Inserisci la tua configurazione Hive ", + "title": "Configurazione Hive." + }, "reauth": { "data": { "password": "Password", @@ -34,7 +41,7 @@ "scan_interval": "Intervallo di scansione (secondi)", "username": "Nome utente" }, - "description": "Inserisci le informazioni di accesso e la configurazione di Hive.", + "description": "Inserisci le tue informazioni di accesso Hive.", "title": "Accesso Hive" } } diff --git a/homeassistant/components/hive/translations/ja.json b/homeassistant/components/hive/translations/ja.json index 55b18b13427..ed11bbd8b7e 100644 --- a/homeassistant/components/hive/translations/ja.json +++ b/homeassistant/components/hive/translations/ja.json @@ -20,6 +20,13 @@ "description": "Hive\u8a8d\u8a3c\u30b3\u30fc\u30c9\u3092\u5165\u529b\u3057\u307e\u3059\u3002 \n\n\u5225\u306e\u30b3\u30fc\u30c9\u3092\u30ea\u30af\u30a8\u30b9\u30c8\u3059\u308b\u306b\u306f\u3001\u30b3\u30fc\u30c9 0000 \u3092\u5165\u529b\u3057\u3066\u304f\u3060\u3055\u3044\u3002", "title": "Hive 2\u8981\u7d20\u8a8d\u8a3c\u3002" }, + "configuration": { + "data": { + "device_name": "\u30c7\u30d0\u30a4\u30b9\u540d" + }, + "description": "Hive\u306e\u8a2d\u5b9a\u3092\u5165\u529b\u3057\u307e\u3059 ", + "title": "Hive\u306e\u8a2d\u5b9a\u3002" + }, "reauth": { "data": { "password": "\u30d1\u30b9\u30ef\u30fc\u30c9", diff --git a/homeassistant/components/hive/translations/pt-BR.json b/homeassistant/components/hive/translations/pt-BR.json index 5f5f7e857c2..3a0cf19884e 100644 --- a/homeassistant/components/hive/translations/pt-BR.json +++ b/homeassistant/components/hive/translations/pt-BR.json @@ -20,6 +20,13 @@ "description": "Digite seu c\u00f3digo de autentica\u00e7\u00e3o Hive. \n\n Insira o c\u00f3digo 0000 para solicitar outro c\u00f3digo.", "title": "Autentica\u00e7\u00e3o de dois fatores do Hive." }, + "configuration": { + "data": { + "device_name": "Nome do dispositivo" + }, + "description": "Digite sua configura\u00e7\u00e3o de Hive", + "title": "Configura\u00e7\u00e3o Hive" + }, "reauth": { "data": { "password": "Senha", @@ -34,7 +41,7 @@ "scan_interval": "Intervalo de escaneamento (segundos)", "username": "Usu\u00e1rio" }, - "description": "Insira suas informa\u00e7\u00f5es de login e configura\u00e7\u00e3o do Hive.", + "description": "Insira suas informa\u00e7\u00f5es de login de Hive.", "title": "Login do Hive" } } diff --git a/homeassistant/components/hive/translations/zh-Hant.json b/homeassistant/components/hive/translations/zh-Hant.json index 0af7e218f6e..6f23d8299a2 100644 --- a/homeassistant/components/hive/translations/zh-Hant.json +++ b/homeassistant/components/hive/translations/zh-Hant.json @@ -20,6 +20,13 @@ "description": "\u8f38\u5165 Hive \u8a8d\u8b49\u78bc\u3002\n \n \u8acb\u8f38\u5165 0000 \u4ee5\u7372\u53d6\u5176\u4ed6\u8a8d\u8b49\u78bc\u3002", "title": "\u96d9\u91cd\u8a8d\u8b49" }, + "configuration": { + "data": { + "device_name": "\u88dd\u7f6e\u540d\u7a31" + }, + "description": "\u8f38\u5165 Hive \u8a2d\u5b9a", + "title": "Hive \u8a2d\u5b9a\u3002" + }, "reauth": { "data": { "password": "\u5bc6\u78bc", @@ -34,7 +41,7 @@ "scan_interval": "\u6383\u63cf\u9593\u8ddd\uff08\u79d2\uff09", "username": "\u4f7f\u7528\u8005\u540d\u7a31" }, - "description": "\u8f38\u5165 Hive \u767b\u5165\u8cc7\u8a0a\u8207\u8a2d\u5b9a\u3002", + "description": "\u8f38\u5165 Hive \u767b\u5165\u8cc7\u8a0a\u3002", "title": "Hive \u767b\u5165\u8cc7\u8a0a" } } diff --git a/homeassistant/components/nest/translations/el.json b/homeassistant/components/nest/translations/el.json index 26e4622670e..b94ec0ee9df 100644 --- a/homeassistant/components/nest/translations/el.json +++ b/homeassistant/components/nest/translations/el.json @@ -4,6 +4,7 @@ }, "config": { "abort": { + "already_configured": "\u039f \u03bb\u03bf\u03b3\u03b1\u03c1\u03b9\u03b1\u03c3\u03bc\u03cc\u03c2 \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", "authorize_url_timeout": "\u039b\u03ae\u03be\u03b7 \u03c7\u03c1\u03bf\u03bd\u03b9\u03ba\u03bf\u03cd \u03bf\u03c1\u03af\u03bf\u03c5 \u03b4\u03b7\u03bc\u03b9\u03bf\u03c5\u03c1\u03b3\u03af\u03b1\u03c2 \u03b4\u03b9\u03b5\u03cd\u03b8\u03c5\u03bd\u03c3\u03b7\u03c2 URL \u03b5\u03be\u03bf\u03c5\u03c3\u03b9\u03bf\u03b4\u03cc\u03c4\u03b7\u03c3\u03b7\u03c2.", "invalid_access_token": "\u039c\u03b7 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf \u03b4\u03b9\u03b1\u03ba\u03c1\u03b9\u03c4\u03b9\u03ba\u03cc \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2", "missing_configuration": "\u03a4\u03bf \u03c3\u03c4\u03bf\u03b9\u03c7\u03b5\u03af\u03bf \u03b4\u03b5\u03bd \u03ad\u03c7\u03b5\u03b9 \u03c1\u03c5\u03b8\u03bc\u03b9\u03c3\u03c4\u03b5\u03af. \u03a0\u03b1\u03c1\u03b1\u03ba\u03b1\u03bb\u03bf\u03cd\u03bc\u03b5 \u03b1\u03ba\u03bf\u03bb\u03bf\u03c5\u03b8\u03ae\u03c3\u03c4\u03b5 \u03c4\u03b7\u03bd \u03c4\u03b5\u03ba\u03bc\u03b7\u03c1\u03af\u03c9\u03c3\u03b7.", diff --git a/homeassistant/components/overkiz/translations/sensor.el.json b/homeassistant/components/overkiz/translations/sensor.el.json index 445a457195c..5335b9d8049 100644 --- a/homeassistant/components/overkiz/translations/sensor.el.json +++ b/homeassistant/components/overkiz/translations/sensor.el.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "\u039a\u03b1\u03b8\u03b1\u03c1\u03cc\u03c2", "dirty": "\u0392\u03c1\u03ce\u03bc\u03b9\u03ba\u03bf\u03c2" + }, + "overkiz__three_way_handle_direction": { + "closed": "\u039a\u03bb\u03b5\u03b9\u03c3\u03c4\u03cc", + "open": "\u0386\u03bd\u03bf\u03b9\u03b3\u03bc\u03b1", + "tilt": "\u039a\u03bb\u03af\u03c3\u03b7" } } } \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/ca.json b/homeassistant/components/simplepush/translations/ca.json new file mode 100644 index 00000000000..161e1a3c36c --- /dev/null +++ b/homeassistant/components/simplepush/translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "Ha fallat la connexi\u00f3" + }, + "step": { + "user": { + "data": { + "device_key": "Clau del teu dispositiu", + "event": "Esdeveniment per als esdeveniments.", + "name": "Nom", + "password": "Contrasenya del xifrat que utilitza el teu dispositiu", + "salt": "La sal ('salt') que utilitza el teu dispositiu." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/el.json b/homeassistant/components/simplepush/translations/el.json new file mode 100644 index 00000000000..bdcc7239acc --- /dev/null +++ b/homeassistant/components/simplepush/translations/el.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + }, + "error": { + "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2" + }, + "step": { + "user": { + "data": { + "device_key": "\u03a4\u03bf \u03ba\u03bb\u03b5\u03b9\u03b4\u03af \u03c4\u03b7\u03c2 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae\u03c2 \u03c3\u03b1\u03c2", + "event": "\u03a4\u03bf \u03b3\u03b5\u03b3\u03bf\u03bd\u03cc\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03b1 \u03b3\u03b5\u03b3\u03bf\u03bd\u03cc\u03c4\u03b1.", + "name": "\u038c\u03bd\u03bf\u03bc\u03b1", + "password": "\u039f \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03c4\u03b7\u03c2 \u03ba\u03c1\u03c5\u03c0\u03c4\u03bf\u03b3\u03c1\u03ac\u03c6\u03b7\u03c3\u03b7\u03c2 \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af \u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c3\u03b1\u03c2", + "salt": "\u03a4\u03bf \u03b1\u03bb\u03ac\u03c4\u03b9 \u03c0\u03bf\u03c5 \u03c7\u03c1\u03b7\u03c3\u03b9\u03bc\u03bf\u03c0\u03bf\u03b9\u03b5\u03af\u03c4\u03b1\u03b9 \u03b1\u03c0\u03cc \u03c4\u03b7 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03c3\u03b1\u03c2." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/hu.json b/homeassistant/components/simplepush/translations/hu.json index 09195f343aa..e5deb2bf2fc 100644 --- a/homeassistant/components/simplepush/translations/hu.json +++ b/homeassistant/components/simplepush/translations/hu.json @@ -13,7 +13,7 @@ "event": "Az esem\u00e9ny", "name": "Elnevez\u00e9s", "password": "A k\u00e9sz\u00fcl\u00e9k \u00e1ltal haszn\u00e1lt titkos\u00edt\u00e1s jelszava", - "salt": "A k\u00e9sz\u00fcl\u00e9k \u00e1ltal haszn\u00e1lt salt." + "salt": "A k\u00e9sz\u00fcl\u00e9k \u00e1ltal haszn\u00e1lt 'salt'." } } } diff --git a/homeassistant/components/simplepush/translations/id.json b/homeassistant/components/simplepush/translations/id.json new file mode 100644 index 00000000000..35984af5d5f --- /dev/null +++ b/homeassistant/components/simplepush/translations/id.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Perangkat sudah dikonfigurasi" + }, + "error": { + "cannot_connect": "Gagal terhubung" + }, + "step": { + "user": { + "data": { + "device_key": "Kunci perangkat untuk perangkat Anda", + "event": "Event untuk daftar event", + "name": "Nama", + "password": "Kata sandi enkripsi yang digunakan oleh perangkat Anda", + "salt": "Salt yang digunakan oleh perangkat Anda." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/it.json b/homeassistant/components/simplepush/translations/it.json new file mode 100644 index 00000000000..2ba1bea5d96 --- /dev/null +++ b/homeassistant/components/simplepush/translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi" + }, + "step": { + "user": { + "data": { + "device_key": "La chiave del dispositivo", + "event": "L'evento per gli eventi.", + "name": "Nome", + "password": "La password della crittografia utilizzata dal tuo dispositivo", + "salt": "Il salt utilizzato dal tuo dispositivo." + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplepush/translations/ja.json b/homeassistant/components/simplepush/translations/ja.json new file mode 100644 index 00000000000..8e3023e602e --- /dev/null +++ b/homeassistant/components/simplepush/translations/ja.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "\u30c7\u30d0\u30a4\u30b9\u306f\u3059\u3067\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059" + }, + "error": { + "cannot_connect": "\u63a5\u7d9a\u306b\u5931\u6557\u3057\u307e\u3057\u305f" + }, + "step": { + "user": { + "data": { + "device_key": "\u30c7\u30d0\u30a4\u30b9\u306e\u30c7\u30d0\u30a4\u30b9\u30ad\u30fc", + "event": "\u30a4\u30d9\u30f3\u30c8\u306e\u305f\u3081\u306e\u30a4\u30d9\u30f3\u30c8\u3067\u3059\u3002", + "name": "\u540d\u524d", + "password": "\u30c7\u30d0\u30a4\u30b9\u3067\u4f7f\u7528\u3055\u308c\u3066\u3044\u308b\u6697\u53f7\u5316\u306e\u30d1\u30b9\u30ef\u30fc\u30c9", + "salt": "\u30c7\u30d0\u30a4\u30b9\u3067\u4f7f\u7528\u3055\u308c\u3066\u3044\u308bsalt\u3067\u3059\u3002" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/transmission/translations/el.json b/homeassistant/components/transmission/translations/el.json index 4a0701cdaf9..6790e6e351d 100644 --- a/homeassistant/components/transmission/translations/el.json +++ b/homeassistant/components/transmission/translations/el.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af" + "already_configured": "\u0397 \u03c3\u03c5\u03c3\u03ba\u03b5\u03c5\u03ae \u03ad\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7 \u03b4\u03b9\u03b1\u03bc\u03bf\u03c1\u03c6\u03c9\u03b8\u03b5\u03af", + "reauth_successful": "\u039f \u03b5\u03ba \u03bd\u03ad\u03bf\u03c5 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03ae\u03c4\u03b1\u03bd \u03b5\u03c0\u03b9\u03c4\u03c5\u03c7\u03ae\u03c2" }, "error": { "cannot_connect": "\u0391\u03c0\u03bf\u03c4\u03c5\u03c7\u03af\u03b1 \u03c3\u03cd\u03bd\u03b4\u03b5\u03c3\u03b7\u03c2", @@ -9,6 +10,13 @@ "name_exists": "\u03a4\u03bf \u038c\u03bd\u03bf\u03bc\u03b1 \u03c5\u03c0\u03ac\u03c1\u03c7\u03b5\u03b9 \u03ae\u03b4\u03b7" }, "step": { + "reauth_confirm": { + "data": { + "password": "\u039a\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2" + }, + "description": "\u039f \u03ba\u03c9\u03b4\u03b9\u03ba\u03cc\u03c2 \u03c0\u03c1\u03cc\u03c3\u03b2\u03b1\u03c3\u03b7\u03c2 \u03b3\u03b9\u03b1 \u03c4\u03bf {username} \u03b4\u03b5\u03bd \u03b5\u03af\u03bd\u03b1\u03b9 \u03ad\u03b3\u03ba\u03c5\u03c1\u03bf\u03c2.", + "title": "\u0395\u03c0\u03b1\u03bd\u03b1\u03bb\u03b7\u03c0\u03c4\u03b9\u03ba\u03cc\u03c2 \u03ad\u03bb\u03b5\u03b3\u03c7\u03bf\u03c2 \u03c4\u03b1\u03c5\u03c4\u03cc\u03c4\u03b7\u03c4\u03b1\u03c2 \u03b5\u03bd\u03c3\u03c9\u03bc\u03ac\u03c4\u03c9\u03c3\u03b7\u03c2" + }, "user": { "data": { "host": "\u039a\u03b5\u03bd\u03c4\u03c1\u03b9\u03ba\u03cc\u03c2 \u03c5\u03c0\u03bf\u03bb\u03bf\u03b3\u03b9\u03c3\u03c4\u03ae\u03c2", From aa314a090146b44d32ab2a2754321ae03bf248de Mon Sep 17 00:00:00 2001 From: akloeckner Date: Mon, 27 Jun 2022 08:59:29 +0200 Subject: [PATCH 696/947] Add this variable to trigger-based templates (#72437) add this variables to trigger-based templates follow-up for https://github.com/home-assistant/core/issues/70359 --- .../components/template/trigger_entity.py | 13 ++++++-- tests/components/template/test_sensor.py | 32 +++++++++++++------ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index 6780d12c507..b5696003c94 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -147,25 +147,32 @@ class TriggerEntity(CoordinatorEntity[TriggerUpdateCoordinator]): @callback def _process_data(self) -> None: """Process new data.""" + + this = None + if state := self.hass.states.get(self.entity_id): + this = state.as_dict() + run_variables = self.coordinator.data["run_variables"] + variables = {"this": this, **(run_variables or {})} + try: rendered = dict(self._static_rendered) for key in self._to_render_simple: rendered[key] = self._config[key].async_render( - self.coordinator.data["run_variables"], + variables, parse_result=key in self._parse_result, ) for key in self._to_render_complex: rendered[key] = template.render_complex( self._config[key], - self.coordinator.data["run_variables"], + variables, ) if CONF_ATTRIBUTES in self._config: rendered[CONF_ATTRIBUTES] = template.render_complex( self._config[CONF_ATTRIBUTES], - self.coordinator.data["run_variables"], + variables, ) self._rendered = rendered diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index ddf13c2015b..44fa96cfc6a 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -624,20 +624,32 @@ async def test_sun_renders_once_per_sensor(hass, start_ha): } -@pytest.mark.parametrize("count,domain", [(1, sensor.DOMAIN)]) +@pytest.mark.parametrize("count,domain", [(1, "template")]) @pytest.mark.parametrize( "config", [ { - "sensor": { - "platform": "template", - "sensors": { - "test_template_sensor": { - "value_template": "{{ this.attributes.test }}: {{ this.entity_id }}", - "attribute_templates": { - "test": "It {{ states.sensor.test_state.state }}" - }, - } + "template": { + "sensor": { + "name": "test_template_sensor", + "state": "{{ this.attributes.test }}: {{ this.entity_id }}", + "attributes": {"test": "It {{ states.sensor.test_state.state }}"}, + }, + }, + }, + { + "template": { + "trigger": { + "platform": "state", + "entity_id": [ + "sensor.test_state", + "sensor.test_template_sensor", + ], + }, + "sensor": { + "name": "test_template_sensor", + "state": "{{ this.attributes.test }}: {{ this.entity_id }}", + "attributes": {"test": "It {{ states.sensor.test_state.state }}"}, }, }, }, From a94579107c189bb1814b5a8456091f79fc6b1a70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Mon, 27 Jun 2022 11:38:40 +0200 Subject: [PATCH 697/947] Bump awesomeversion from 22.5.2 to 22.6.0 (#74030) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 55cc067829b..6fb51cf0d09 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,7 +8,7 @@ async-upnp-client==0.31.2 async_timeout==4.0.2 atomicwrites==1.4.0 attrs==21.2.0 -awesomeversion==22.5.2 +awesomeversion==22.6.0 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 diff --git a/pyproject.toml b/pyproject.toml index 165e781983e..fc07110dbad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "async_timeout==4.0.2", "attrs==21.2.0", "atomicwrites==1.4.0", - "awesomeversion==22.5.2", + "awesomeversion==22.6.0", "bcrypt==3.1.7", "certifi>=2021.5.30", "ciso8601==2.2.0", diff --git a/requirements.txt b/requirements.txt index 0b3791d6eed..21e11def1b5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ astral==2.2 async_timeout==4.0.2 attrs==21.2.0 atomicwrites==1.4.0 -awesomeversion==22.5.2 +awesomeversion==22.6.0 bcrypt==3.1.7 certifi>=2021.5.30 ciso8601==2.2.0 From e32c7dbf9219bc762085e7731a4a6cc68c7ca60c Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Mon, 27 Jun 2022 05:39:02 -0400 Subject: [PATCH 698/947] Use built in unit handling for nws weather (#73981) use built in unit handling for nws --- homeassistant/components/nws/weather.py | 114 ++++++++++-------------- tests/components/nws/const.py | 6 +- 2 files changed, 49 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 44e1ed2d2c4..5bb1bb0bf6c 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -3,22 +3,18 @@ from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, - LENGTH_KILOMETERS, LENGTH_METERS, - LENGTH_MILES, - PRESSURE_HPA, - PRESSURE_INHG, PRESSURE_PA, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, @@ -28,9 +24,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.distance import convert as convert_distance from homeassistant.util.dt import utcnow -from homeassistant.util.pressure import convert as convert_pressure from homeassistant.util.speed import convert as convert_speed from homeassistant.util.temperature import convert as convert_temperature @@ -155,68 +149,54 @@ class NWSWeather(WeatherEntity): return f"{self.station} {self.mode.title()}" @property - def temperature(self): + def native_temperature(self): """Return the current temperature.""" - temp_c = None if self.observation: - temp_c = self.observation.get("temperature") - if temp_c is not None: - return convert_temperature(temp_c, TEMP_CELSIUS, TEMP_FAHRENHEIT) + return self.observation.get("temperature") return None @property - def pressure(self): + def native_temperature_unit(self): + """Return the current temperature unit.""" + return TEMP_CELSIUS + + @property + def native_pressure(self): """Return the current pressure.""" - pressure_pa = None if self.observation: - pressure_pa = self.observation.get("seaLevelPressure") - if pressure_pa is None: - return None - if self.is_metric: - pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_HPA) - pressure = round(pressure) - else: - pressure = convert_pressure(pressure_pa, PRESSURE_PA, PRESSURE_INHG) - pressure = round(pressure, 2) - return pressure + return self.observation.get("seaLevelPressure") + return None + + @property + def native_pressure_unit(self): + """Return the current pressure unit.""" + return PRESSURE_PA @property def humidity(self): """Return the name of the sensor.""" - humidity = None if self.observation: - humidity = self.observation.get("relativeHumidity") - return humidity + return self.observation.get("relativeHumidity") + return None @property - def wind_speed(self): + def native_wind_speed(self): """Return the current windspeed.""" - wind_km_hr = None if self.observation: - wind_km_hr = self.observation.get("windSpeed") - if wind_km_hr is None: - return None + return self.observation.get("windSpeed") + return None - if self.is_metric: - wind = wind_km_hr - else: - wind = convert_speed( - wind_km_hr, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR - ) - return round(wind) + @property + def native_wind_speed_unit(self): + """Return the current windspeed.""" + return SPEED_KILOMETERS_PER_HOUR @property def wind_bearing(self): """Return the current wind bearing (degrees).""" - wind_bearing = None if self.observation: - wind_bearing = self.observation.get("windDirection") - return wind_bearing - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_FAHRENHEIT + return self.observation.get("windDirection") + return None @property def condition(self): @@ -232,19 +212,16 @@ class NWSWeather(WeatherEntity): return None @property - def visibility(self): + def native_visibility(self): """Return visibility.""" - vis_m = None if self.observation: - vis_m = self.observation.get("visibility") - if vis_m is None: - return None + return self.observation.get("visibility") + return None - if self.is_metric: - vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_KILOMETERS) - else: - vis = convert_distance(vis_m, LENGTH_METERS, LENGTH_MILES) - return round(vis, 0) + @property + def native_visibility_unit(self): + """Return visibility unit.""" + return LENGTH_METERS @property def forecast(self): @@ -257,10 +234,16 @@ class NWSWeather(WeatherEntity): ATTR_FORECAST_DETAILED_DESCRIPTION: forecast_entry.get( "detailedForecast" ), - ATTR_FORECAST_TEMP: forecast_entry.get("temperature"), ATTR_FORECAST_TIME: forecast_entry.get("startTime"), } + if (temp := forecast_entry.get("temperature")) is not None: + data[ATTR_FORECAST_NATIVE_TEMP] = convert_temperature( + temp, TEMP_FAHRENHEIT, TEMP_CELSIUS + ) + else: + data[ATTR_FORECAST_NATIVE_TEMP] = None + if self.mode == DAYNIGHT: data[ATTR_FORECAST_DAYTIME] = forecast_entry.get("isDaytime") time = forecast_entry.get("iconTime") @@ -275,16 +258,11 @@ class NWSWeather(WeatherEntity): data[ATTR_FORECAST_WIND_BEARING] = forecast_entry.get("windBearing") wind_speed = forecast_entry.get("windSpeedAvg") if wind_speed is not None: - if self.is_metric: - data[ATTR_FORECAST_WIND_SPEED] = round( - convert_speed( - wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ) - ) - else: - data[ATTR_FORECAST_WIND_SPEED] = round(wind_speed) + data[ATTR_FORECAST_NATIVE_WIND_SPEED] = convert_speed( + wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR + ) else: - data[ATTR_FORECAST_WIND_SPEED] = None + data[ATTR_FORECAST_NATIVE_WIND_SPEED] = None forecast.append(data) return forecast diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py index dcf591b83ae..850d330c9ae 100644 --- a/tests/components/nws/const.py +++ b/tests/components/nws/const.py @@ -105,13 +105,13 @@ WEATHER_EXPECTED_OBSERVATION_IMPERIAL = { ), ATTR_WEATHER_WIND_BEARING: 180, ATTR_WEATHER_WIND_SPEED: round( - convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR) + convert_speed(10, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR), 2 ), ATTR_WEATHER_PRESSURE: round( convert_pressure(100000, PRESSURE_PA, PRESSURE_INHG), 2 ), ATTR_WEATHER_VISIBILITY: round( - convert_distance(10000, LENGTH_METERS, LENGTH_MILES) + convert_distance(10000, LENGTH_METERS, LENGTH_MILES), 2 ), ATTR_WEATHER_HUMIDITY: 10, } @@ -161,7 +161,7 @@ EXPECTED_FORECAST_METRIC = { convert_temperature(10, TEMP_FAHRENHEIT, TEMP_CELSIUS), 1 ), ATTR_FORECAST_WIND_SPEED: round( - convert_speed(10, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR) + convert_speed(10, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR), 2 ), ATTR_FORECAST_WIND_BEARING: 180, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90, From b185de0ac00bb60d123191df0a231d058dfe6dcf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jun 2022 12:10:31 +0200 Subject: [PATCH 699/947] Add base Entity to pylint checks (#73902) * Add base entity properties * Add special case of Mapping[xxx, Any] * Add Mapping tests * Add entity functions * Adjust docstring * Add update/async_update --- pylint/plugins/hass_enforce_type_hints.py | 147 ++++++++++++++++++++-- tests/pylint/test_enforce_type_hints.py | 102 +++++++++++++++ 2 files changed, 238 insertions(+), 11 deletions(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 9ec2fa83806..6555510ff58 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -435,6 +435,117 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { } # Overriding properties and functions are normally checked by mypy, and will only # be checked by pylint when --ignore-missing-annotations is False +_ENTITY_MATCH: list[TypeHintMatch] = [ + TypeHintMatch( + function_name="should_poll", + return_type="bool", + ), + TypeHintMatch( + function_name="unique_id", + return_type=["str", None], + ), + TypeHintMatch( + function_name="name", + return_type=["str", None], + ), + TypeHintMatch( + function_name="state", + return_type=["StateType", None, "str", "int", "float"], + ), + TypeHintMatch( + function_name="capability_attributes", + return_type=["Mapping[str, Any]", None], + ), + TypeHintMatch( + function_name="state_attributes", + return_type=["dict[str, Any]", None], + ), + TypeHintMatch( + function_name="device_state_attributes", + return_type=["Mapping[str, Any]", None], + ), + TypeHintMatch( + function_name="extra_state_attributes", + return_type=["Mapping[str, Any]", None], + ), + TypeHintMatch( + function_name="device_info", + return_type=["DeviceInfo", None], + ), + TypeHintMatch( + function_name="device_class", + return_type=["str", None], + ), + TypeHintMatch( + function_name="unit_of_measurement", + return_type=["str", None], + ), + TypeHintMatch( + function_name="icon", + return_type=["str", None], + ), + TypeHintMatch( + function_name="entity_picture", + return_type=["str", None], + ), + TypeHintMatch( + function_name="available", + return_type="bool", + ), + TypeHintMatch( + function_name="assumed_state", + return_type="bool", + ), + TypeHintMatch( + function_name="force_update", + return_type="bool", + ), + TypeHintMatch( + function_name="supported_features", + return_type=["int", None], + ), + TypeHintMatch( + function_name="context_recent_time", + return_type="timedelta", + ), + TypeHintMatch( + function_name="entity_registry_enabled_default", + return_type="bool", + ), + TypeHintMatch( + function_name="entity_registry_visible_default", + return_type="bool", + ), + TypeHintMatch( + function_name="attribution", + return_type=["str", None], + ), + TypeHintMatch( + function_name="entity_category", + return_type=["EntityCategory", None], + ), + TypeHintMatch( + function_name="async_removed_from_registry", + return_type=None, + ), + TypeHintMatch( + function_name="async_added_to_hass", + return_type=None, + ), + TypeHintMatch( + function_name="async_will_remove_from_hass", + return_type=None, + ), + TypeHintMatch( + function_name="async_registry_entry_updated", + return_type=None, + ), + TypeHintMatch( + function_name="update", + return_type=None, + has_async_counterpart=True, + ), +] _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ TypeHintMatch( function_name="is_on", @@ -461,6 +572,10 @@ _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ ] _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { "fan": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="ToggleEntity", matches=_TOGGLE_ENTITY_MATCH, @@ -488,14 +603,6 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { function_name="oscillating", return_type=["bool", None], ), - TypeHintMatch( - function_name="capability_attributes", - return_type="dict[str]", - ), - TypeHintMatch( - function_name="supported_features", - return_type="int", - ), TypeHintMatch( function_name="preset_mode", return_type=["str", None], @@ -542,6 +649,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), ], "lock": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), ClassTypeHintMatch( base_class="LockEntity", matches=[ @@ -594,7 +705,9 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { def _is_valid_type( - expected_type: list[str] | str | None | object, node: nodes.NodeNG + expected_type: list[str] | str | None | object, + node: nodes.NodeNG, + in_return: bool = False, ) -> bool: """Check the argument node against the expected type.""" if expected_type is UNDEFINED: @@ -602,7 +715,7 @@ def _is_valid_type( if isinstance(expected_type, list): for expected_type_item in expected_type: - if _is_valid_type(expected_type_item, node): + if _is_valid_type(expected_type_item, node, in_return): return True return False @@ -638,6 +751,18 @@ def _is_valid_type( # Special case for xxx[yyy, zzz]` if match := _TYPE_HINT_MATCHERS["x_of_y_comma_z"].match(expected_type): + # Handle special case of Mapping[xxx, Any] + if in_return and match.group(1) == "Mapping" and match.group(3) == "Any": + return ( + isinstance(node, nodes.Subscript) + and isinstance(node.value, nodes.Name) + # We accept dict when Mapping is needed + and node.value.name in ("Mapping", "dict") + and isinstance(node.slice, nodes.Tuple) + and _is_valid_type(match.group(2), node.slice.elts[0]) + # Ignore second item + # and _is_valid_type(match.group(3), node.slice.elts[1]) + ) return ( isinstance(node, nodes.Subscript) and _is_valid_type(match.group(1), node.value) @@ -663,7 +788,7 @@ def _is_valid_type( def _is_valid_return_type(match: TypeHintMatch, node: nodes.NodeNG) -> bool: - if _is_valid_type(match.return_type, node): + if _is_valid_type(match.return_type, node, True): return True if isinstance(node, nodes.BinOp): diff --git a/tests/pylint/test_enforce_type_hints.py b/tests/pylint/test_enforce_type_hints.py index 54c7cf6ec4c..8b4b8d4d058 100644 --- a/tests/pylint/test_enforce_type_hints.py +++ b/tests/pylint/test_enforce_type_hints.py @@ -635,3 +635,105 @@ def test_named_arguments( ), ): type_hint_checker.visit_classdef(class_node) + + +@pytest.mark.parametrize( + "return_hint", + [ + "", + "-> Mapping[int, int]", + "-> dict[int, Any]", + ], +) +def test_invalid_mapping_return_type( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + return_hint: str, +) -> None: + """Check that Mapping[xxx, Any] doesn't accept invalid Mapping or dict.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node, property_node = astroid.extract_node( + f""" + class Entity(): + pass + + class ToggleEntity(Entity): + pass + + class FanEntity(ToggleEntity): + pass + + class MyFanA( #@ + FanEntity + ): + @property + def capability_attributes( #@ + self + ){return_hint}: + pass + """, + "homeassistant.components.pylint_test.fan", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_adds_messages( + linter, + pylint.testutils.MessageTest( + msg_id="hass-return-type", + node=property_node, + args=["Mapping[str, Any]", None], + line=15, + col_offset=4, + end_line=15, + end_col_offset=29, + ), + ): + type_hint_checker.visit_classdef(class_node) + + +@pytest.mark.parametrize( + "return_hint", + [ + "-> Mapping[str, Any]", + "-> Mapping[str, bool | int]", + "-> dict[str, Any]", + "-> dict[str, str]", + ], +) +def test_valid_mapping_return_type( + linter: UnittestLinter, + type_hint_checker: BaseChecker, + return_hint: str, +) -> None: + """Check that Mapping[xxx, Any] accepts both Mapping and dict.""" + # Set bypass option + type_hint_checker.config.ignore_missing_annotations = False + + class_node = astroid.extract_node( + f""" + class Entity(): + pass + + class ToggleEntity(Entity): + pass + + class FanEntity(ToggleEntity): + pass + + class MyFanA( #@ + FanEntity + ): + @property + def capability_attributes( + self + ){return_hint}: + pass + """, + "homeassistant.components.pylint_test.fan", + ) + type_hint_checker.visit_module(class_node.parent) + + with assert_no_messages(linter): + type_hint_checker.visit_classdef(class_node) From 10ea88e0ea839829551a6a6018cc1b3b81e23413 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Mon, 27 Jun 2022 13:56:51 +0200 Subject: [PATCH 700/947] Switchbot bump Dependency 0.14.0 (#74001) * Bump requirement. * Switchbot depenacy update, full async. * Update tests, remove redundant config entry check. * Update requirements_test_all.txt * Update requirements_all.txt * Remove asyncio lock. Not required anymore with bleak. * Update requirements_all.txt * Update requirements_test_all.txt * pyswitchbot no longer uses bluepy --- .../components/switchbot/__init__.py | 15 +------- .../components/switchbot/binary_sensor.py | 5 +-- .../components/switchbot/config_flow.py | 15 +++----- homeassistant/components/switchbot/const.py | 1 - .../components/switchbot/coordinator.py | 18 +++------- homeassistant/components/switchbot/cover.py | 36 +++++++------------ .../components/switchbot/manifest.json | 2 +- homeassistant/components/switchbot/sensor.py | 5 +-- homeassistant/components/switchbot/switch.py | 28 +++++++-------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 1 - tests/components/switchbot/conftest.py | 17 +++++++-- 13 files changed, 59 insertions(+), 88 deletions(-) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 0059d655767..68fbd4bd584 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -1,17 +1,14 @@ """Support for Switchbot devices.""" -from asyncio import Lock -import switchbot # pylint: disable=import-error +import switchbot from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_SENSOR_TYPE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from .const import ( ATTR_BOT, ATTR_CURTAIN, - BTLE_LOCK, COMMON_OPTIONS, CONF_RETRY_COUNT, CONF_RETRY_TIMEOUT, @@ -50,12 +47,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Uses BTLE advertisement data, all Switchbot devices in range is stored here. if DATA_COORDINATOR not in hass.data[DOMAIN]: - # Check if asyncio.lock is stored in hass data. - # BTLE has issues with multiple connections, - # so we use a lock to ensure that only one API request is reaching it at a time: - if BTLE_LOCK not in hass.data[DOMAIN]: - hass.data[DOMAIN][BTLE_LOCK] = Lock() - if COMMON_OPTIONS not in hass.data[DOMAIN]: hass.data[DOMAIN][COMMON_OPTIONS] = {**entry.options} @@ -72,7 +63,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api=switchbot, retry_count=hass.data[DOMAIN][COMMON_OPTIONS][CONF_RETRY_COUNT], scan_timeout=hass.data[DOMAIN][COMMON_OPTIONS][CONF_SCAN_TIMEOUT], - api_lock=hass.data[DOMAIN][BTLE_LOCK], ) hass.data[DOMAIN][DATA_COORDINATOR] = coordinator @@ -82,9 +72,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() - if not coordinator.last_update_success: - raise ConfigEntryNotReady - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator} diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index c3f88e924ea..e2a5a951d1d 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -8,6 +8,7 @@ from homeassistant.components.binary_sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -33,8 +34,8 @@ async def async_setup_entry( DATA_COORDINATOR ] - if not coordinator.data[entry.unique_id].get("data"): - return + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady async_add_entities( [ diff --git a/homeassistant/components/switchbot/config_flow.py b/homeassistant/components/switchbot/config_flow.py index 70e032414a7..362f3b01ae7 100644 --- a/homeassistant/components/switchbot/config_flow.py +++ b/homeassistant/components/switchbot/config_flow.py @@ -1,11 +1,10 @@ """Config flow for Switchbot.""" from __future__ import annotations -from asyncio import Lock import logging from typing import Any -from switchbot import GetSwitchbotDevices # pylint: disable=import-error +from switchbot import GetSwitchbotDevices import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow @@ -14,7 +13,6 @@ from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from .const import ( - BTLE_LOCK, CONF_RETRY_COUNT, CONF_RETRY_TIMEOUT, CONF_SCAN_TIMEOUT, @@ -30,10 +28,10 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def _btle_connect() -> dict: +async def _btle_connect() -> dict: """Scan for BTLE advertisement data.""" - switchbot_devices = GetSwitchbotDevices().discover() + switchbot_devices = await GetSwitchbotDevices().discover() if not switchbot_devices: raise NotConnectedError("Failed to discover switchbot") @@ -52,14 +50,9 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN): # store asyncio.lock in hass data if not present. if DOMAIN not in self.hass.data: self.hass.data.setdefault(DOMAIN, {}) - if BTLE_LOCK not in self.hass.data[DOMAIN]: - self.hass.data[DOMAIN][BTLE_LOCK] = Lock() - - connect_lock = self.hass.data[DOMAIN][BTLE_LOCK] # Discover switchbots nearby. - async with connect_lock: - _btle_adv_data = await self.hass.async_add_executor_job(_btle_connect) + _btle_adv_data = await _btle_connect() return _btle_adv_data diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 8ca7fadf41c..b1587e97c10 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -22,5 +22,4 @@ CONF_SCAN_TIMEOUT = "scan_timeout" # Data DATA_COORDINATOR = "coordinator" -BTLE_LOCK = "btle_lock" COMMON_OPTIONS = "common_options" diff --git a/homeassistant/components/switchbot/coordinator.py b/homeassistant/components/switchbot/coordinator.py index e901cc539ea..e8e2e240dc6 100644 --- a/homeassistant/components/switchbot/coordinator.py +++ b/homeassistant/components/switchbot/coordinator.py @@ -1,11 +1,10 @@ """Provides the switchbot DataUpdateCoordinator.""" from __future__ import annotations -from asyncio import Lock from datetime import timedelta import logging -import switchbot # pylint: disable=import-error +import switchbot from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -26,7 +25,6 @@ class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): api: switchbot, retry_count: int, scan_timeout: int, - api_lock: Lock, ) -> None: """Initialize global switchbot data updater.""" self.switchbot_api = api @@ -39,20 +37,12 @@ class SwitchbotDataUpdateCoordinator(DataUpdateCoordinator): hass, _LOGGER, name=DOMAIN, update_interval=self.update_interval ) - self.api_lock = api_lock - - def _update_data(self) -> dict | None: - """Fetch device states from switchbot api.""" - - return self.switchbot_data.discover( - retry=self.retry_count, scan_timeout=self.scan_timeout - ) - async def _async_update_data(self) -> dict | None: """Fetch data from switchbot.""" - async with self.api_lock: - switchbot_data = await self.hass.async_add_executor_job(self._update_data) + switchbot_data = await self.switchbot_data.discover( + retry=self.retry_count, scan_timeout=self.scan_timeout + ) if not switchbot_data: raise UpdateFailed("Unable to fetch switchbot services data") diff --git a/homeassistant/components/switchbot/cover.py b/homeassistant/components/switchbot/cover.py index 9f265d696ad..9223217c173 100644 --- a/homeassistant/components/switchbot/cover.py +++ b/homeassistant/components/switchbot/cover.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from switchbot import SwitchbotCurtain # pylint: disable=import-error +from switchbot import SwitchbotCurtain from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, @@ -16,6 +16,7 @@ from homeassistant.components.cover import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -36,6 +37,9 @@ async def async_setup_entry( DATA_COORDINATOR ] + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady + async_add_entities( [ SwitchBotCurtainEntity( @@ -94,44 +98,30 @@ class SwitchBotCurtainEntity(SwitchbotEntity, CoverEntity, RestoreEntity): """Open the curtain.""" _LOGGER.debug("Switchbot to open curtain %s", self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.open) - ) + self._last_run_success = bool(await self._device.open()) + self.async_write_ha_state() async def async_close_cover(self, **kwargs: Any) -> None: """Close the curtain.""" _LOGGER.debug("Switchbot to close the curtain %s", self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.close) - ) + self._last_run_success = bool(await self._device.close()) + self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the moving of this device.""" _LOGGER.debug("Switchbot to stop %s", self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.stop) - ) + self._last_run_success = bool(await self._device.stop()) + self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" position = kwargs.get(ATTR_POSITION) _LOGGER.debug("Switchbot to move at %d %s", position, self._mac) - - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job( - self._device.set_position, position - ) - ) + self._last_run_success = bool(await self._device.set_position(position)) + self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 7a16225dcbb..cb485ffd8a5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -2,7 +2,7 @@ "domain": "switchbot", "name": "SwitchBot", "documentation": "https://www.home-assistant.io/integrations/switchbot", - "requirements": ["PySwitchbot==0.13.3"], + "requirements": ["PySwitchbot==0.14.0"], "config_flow": true, "codeowners": ["@danielhiversen", "@RenierM26"], "iot_class": "local_polling", diff --git a/homeassistant/components/switchbot/sensor.py b/homeassistant/components/switchbot/sensor.py index 1ee0276b7ee..759a504d19a 100644 --- a/homeassistant/components/switchbot/sensor.py +++ b/homeassistant/components/switchbot/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( SIGNAL_STRENGTH_DECIBELS_MILLIWATT, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -53,8 +54,8 @@ async def async_setup_entry( DATA_COORDINATOR ] - if not coordinator.data[entry.unique_id].get("data"): - return + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady async_add_entities( [ diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py index b5507594521..404a92eda82 100644 --- a/homeassistant/components/switchbot/switch.py +++ b/homeassistant/components/switchbot/switch.py @@ -4,12 +4,13 @@ from __future__ import annotations import logging from typing import Any -from switchbot import Switchbot # pylint: disable=import-error +from switchbot import Switchbot from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_MAC, CONF_NAME, CONF_PASSWORD, STATE_ON from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import entity_platform from homeassistant.helpers.restore_state import RestoreEntity @@ -32,6 +33,9 @@ async def async_setup_entry( DATA_COORDINATOR ] + if not coordinator.data.get(entry.unique_id): + raise PlatformNotReady + async_add_entities( [ SwitchBotBotEntity( @@ -80,25 +84,19 @@ class SwitchBotBotEntity(SwitchbotEntity, SwitchEntity, RestoreEntity): """Turn device on.""" _LOGGER.info("Turn Switchbot bot on %s", self._mac) - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.turn_on) - ) - if self._last_run_success: - self._attr_is_on = True - self.async_write_ha_state() + self._last_run_success = bool(await self._device.turn_on()) + if self._last_run_success: + self._attr_is_on = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn device off.""" _LOGGER.info("Turn Switchbot bot off %s", self._mac) - async with self.coordinator.api_lock: - self._last_run_success = bool( - await self.hass.async_add_executor_job(self._device.turn_off) - ) - if self._last_run_success: - self._attr_is_on = False - self.async_write_ha_state() + self._last_run_success = bool(await self._device.turn_off()) + if self._last_run_success: + self._attr_is_on = False + self.async_write_ha_state() @property def assumed_state(self) -> bool: diff --git a/requirements_all.txt b/requirements_all.txt index 95cbdd5b0bf..3173a74468b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -34,7 +34,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.13.3 +PySwitchbot==0.14.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9f095161ea..ffab3df0314 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -30,7 +30,7 @@ PyRMVtransport==0.3.3 PySocks==1.7.1 # homeassistant.components.switchbot -# PySwitchbot==0.13.3 +PySwitchbot==0.14.0 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 5db46ffbeba..a2a0eab897a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -30,7 +30,6 @@ COMMENT_REQUIREMENTS = ( "opencv-python-headless", "pybluez", "pycups", - "PySwitchbot", "pySwitchmate", "python-eq3bt", "python-gammu", diff --git a/tests/components/switchbot/conftest.py b/tests/components/switchbot/conftest.py index 2fdea69de16..550aeb08082 100644 --- a/tests/components/switchbot/conftest.py +++ b/tests/components/switchbot/conftest.py @@ -45,6 +45,19 @@ class MocGetSwitchbotDevices: "model": "m", "rawAdvData": "000d6d00", }, + "c0ceb0d426be": { + "mac_address": "c0:ce:b0:d4:26:be", + "isEncrypted": False, + "data": { + "temp": {"c": 21.6, "f": 70.88}, + "fahrenheit": False, + "humidity": 73, + "battery": 100, + "rssi": -58, + }, + "model": "T", + "modelName": "WoSensorTH", + }, } self._curtain_all_services_data = { "mac_address": "e7:89:43:90:90:90", @@ -72,11 +85,11 @@ class MocGetSwitchbotDevices: "modelName": "WoOther", } - def discover(self, retry=0, scan_timeout=0): + async def discover(self, retry=0, scan_timeout=0): """Mock discover.""" return self._all_services_data - def get_device_data(self, mac=None): + async def get_device_data(self, mac=None): """Return data for specific device.""" if mac == "e7:89:43:99:99:99": return self._all_services_data From f9c83dd99193f79fbf34ed45ae4d3148519d2f77 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 27 Jun 2022 14:58:23 +0200 Subject: [PATCH 701/947] Add CoverEntity to pylint checks (#74036) * Add CoverEntity to pylint checks * Avoid false positivies on device_class * Adjust device_class handling * Adjust device_class again using a singleton * Adjust device_class (again) * Simplify DEVICE_CLASS check * Keep device_class in base class --- pylint/plugins/hass_enforce_type_hints.py | 115 +++++++++++++++++++++- 1 file changed, 114 insertions(+), 1 deletion(-) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 6555510ff58..711f40b26ef 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -10,6 +10,7 @@ from pylint.lint import PyLinter from homeassistant.const import Platform +DEVICE_CLASS = object() UNDEFINED = object() _PLATFORMS: set[str] = {platform.value for platform in Platform} @@ -474,7 +475,7 @@ _ENTITY_MATCH: list[TypeHintMatch] = [ ), TypeHintMatch( function_name="device_class", - return_type=["str", None], + return_type=[DEVICE_CLASS, "str", None], ), TypeHintMatch( function_name="unit_of_measurement", @@ -571,6 +572,101 @@ _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ ), ] _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { + "cover": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="CoverEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["CoverDeviceClass", "str", None], + ), + TypeHintMatch( + function_name="current_cover_position", + return_type=["int", None], + ), + TypeHintMatch( + function_name="current_cover_tilt_position", + return_type=["int", None], + ), + TypeHintMatch( + function_name="is_opening", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_closing", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="is_closed", + return_type=["bool", None], + ), + TypeHintMatch( + function_name="open_cover", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="close_cover", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="toggle", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="set_cover_position", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="stop_cover", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="open_cover_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="close_cover_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="set_cover_tilt_position", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="stop_cover_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="toggle_tilt", + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], "fan": [ ClassTypeHintMatch( base_class="Entity", @@ -583,6 +679,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ClassTypeHintMatch( base_class="FanEntity", matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["str", None], + ), TypeHintMatch( function_name="percentage", return_type=["int", None], @@ -656,6 +756,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ClassTypeHintMatch( base_class="LockEntity", matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["str", None], + ), TypeHintMatch( function_name="changed_by", return_type=["str", None], @@ -713,6 +817,15 @@ def _is_valid_type( if expected_type is UNDEFINED: return True + # Special case for device_class + if expected_type == DEVICE_CLASS and in_return: + return ( + isinstance(node, nodes.Name) + and node.name.endswith("DeviceClass") + or isinstance(node, nodes.Attribute) + and node.attrname.endswith("DeviceClass") + ) + if isinstance(expected_type, list): for expected_type_item in expected_type: if _is_valid_type(expected_type_item, node, in_return): From 320fa25a99c08b65334d156e1853699993b7c902 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 27 Jun 2022 19:50:56 +0200 Subject: [PATCH 702/947] Fix re-login logic when UniFi integration receives a 401 (#74013) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index d481f0d0fc4..510659c56d3 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Network", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==32"], + "requirements": ["aiounifi==33"], "codeowners": ["@Kane610"], "quality_scale": "platinum", "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 3173a74468b..95e4f539a27 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -259,7 +259,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==32 +aiounifi==33 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffab3df0314..4bd6c87f04f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==32 +aiounifi==33 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 992ceb1a09af00e2a5473729e6d02b287cffbba2 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Mon, 27 Jun 2022 20:24:15 +0200 Subject: [PATCH 703/947] Google Assistant diagnostics and synchronization (#73574) * Add config flow import for local google assistant * Add diagnostic with sync response * Add button for device sync --- .../components/google_assistant/__init__.py | 49 +++++++- .../components/google_assistant/button.py | 53 +++++++++ .../google_assistant/config_flow.py | 19 +++ .../components/google_assistant/const.py | 2 + .../google_assistant/diagnostics.py | 41 +++++++ .../components/google_assistant/helpers.py | 4 +- .../components/google_assistant/smart_home.py | 32 ++--- .../google_assistant/test_button.py | 55 +++++++++ .../google_assistant/test_diagnostics.py | 109 ++++++++++++++++++ .../components/google_assistant/test_init.py | 38 +++++- 10 files changed, 384 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/google_assistant/button.py create mode 100644 homeassistant/components/google_assistant/config_flow.py create mode 100644 homeassistant/components/google_assistant/diagnostics.py create mode 100644 tests/components/google_assistant/test_button.py create mode 100644 tests/components/google_assistant/test_diagnostics.py diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index dca436d8e2a..638ccfd9133 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -5,9 +5,10 @@ import logging import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_NAME, Platform from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.typing import ConfigType from .const import ( @@ -23,6 +24,7 @@ from .const import ( CONF_ROOM_HINT, CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, + DATA_CONFIG, DEFAULT_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSED_DOMAINS, DOMAIN, @@ -37,6 +39,8 @@ _LOGGER = logging.getLogger(__name__) CONF_ALLOW_UNLOCK = "allow_unlock" +PLATFORMS = [Platform.BUTTON] + ENTITY_SCHEMA = vol.Schema( { vol.Optional(CONF_NAME): cv.string, @@ -95,11 +99,48 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: if DOMAIN not in yaml_config: return True - config = yaml_config[DOMAIN] + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CONFIG] = yaml_config[DOMAIN] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_PROJECT_ID: yaml_config[DOMAIN][CONF_PROJECT_ID]}, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + config: ConfigType = {**hass.data[DOMAIN][DATA_CONFIG]} + + if entry.source == SOURCE_IMPORT: + # if project was changed, remove entry a new will be setup + if config[CONF_PROJECT_ID] != entry.data[CONF_PROJECT_ID]: + hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) + return False + + config.update(entry.data) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, config[CONF_PROJECT_ID])}, + manufacturer="Google", + model="Google Assistant", + name=config[CONF_PROJECT_ID], + entry_type=dr.DeviceEntryType.SERVICE, + ) google_config = GoogleConfig(hass, config) await google_config.async_initialize() + hass.data[DOMAIN][entry.entry_id] = google_config + hass.http.register_view(GoogleAssistantView(google_config)) if google_config.should_report_state: @@ -123,4 +164,6 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler ) + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True diff --git a/homeassistant/components/google_assistant/button.py b/homeassistant/components/google_assistant/button.py new file mode 100644 index 00000000000..322a021053a --- /dev/null +++ b/homeassistant/components/google_assistant/button.py @@ -0,0 +1,53 @@ +"""Support for buttons.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.components.button import ButtonEntity +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo, EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_PROJECT_ID, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN +from .http import GoogleConfig + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the platform.""" + yaml_config: ConfigType = hass.data[DOMAIN][DATA_CONFIG] + google_config: GoogleConfig = hass.data[DOMAIN][config_entry.entry_id] + + entities = [] + + if CONF_SERVICE_ACCOUNT in yaml_config: + entities.append(SyncButton(config_entry.data[CONF_PROJECT_ID], google_config)) + + async_add_entities(entities) + + +class SyncButton(ButtonEntity): + """Representation of a synchronization button.""" + + def __init__(self, project_id: str, google_config: GoogleConfig) -> None: + """Initialize button.""" + super().__init__() + self._google_config = google_config + self._attr_entity_category = EntityCategory.DIAGNOSTIC + self._attr_unique_id = f"{project_id}_sync" + self._attr_name = "Synchronize Devices" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, project_id)}) + + async def async_press(self) -> None: + """Press the button.""" + assert self._context + agent_user_id = self._google_config.get_agent_user_id(self._context) + result = await self._google_config.async_sync_entities(agent_user_id) + if result != 200: + raise HomeAssistantError( + f"Unable to sync devices with result code: {result}, check log for more info." + ) diff --git a/homeassistant/components/google_assistant/config_flow.py b/homeassistant/components/google_assistant/config_flow.py new file mode 100644 index 00000000000..e8e0d9962f9 --- /dev/null +++ b/homeassistant/components/google_assistant/config_flow.py @@ -0,0 +1,19 @@ +"""Config flow for google assistant component.""" + +from homeassistant import config_entries + +from .const import CONF_PROJECT_ID, DOMAIN + + +class GoogleAssistantHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + async def async_step_import(self, user_input): + """Import a config entry.""" + await self.async_set_unique_id(unique_id=user_input[CONF_PROJECT_ID]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_PROJECT_ID], data=user_input + ) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index 339cddae883..dbcf60ac098 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -40,6 +40,8 @@ CONF_ROOM_HINT = "room" CONF_SECURE_DEVICES_PIN = "secure_devices_pin" CONF_SERVICE_ACCOUNT = "service_account" +DATA_CONFIG = "config" + DEFAULT_EXPOSE_BY_DEFAULT = True DEFAULT_EXPOSED_DOMAINS = [ "alarm_control_panel", diff --git a/homeassistant/components/google_assistant/diagnostics.py b/homeassistant/components/google_assistant/diagnostics.py new file mode 100644 index 00000000000..01e17e0bcf8 --- /dev/null +++ b/homeassistant/components/google_assistant/diagnostics.py @@ -0,0 +1,41 @@ +"""Diagnostics support for Hue.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.diagnostics.const import REDACTED +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_SECURE_DEVICES_PIN, CONF_SERVICE_ACCOUNT, DATA_CONFIG, DOMAIN +from .http import GoogleConfig +from .smart_home import async_devices_sync_response, create_sync_response + +TO_REDACT = [ + "uuid", + "baseUrl", + "webhookId", + CONF_SERVICE_ACCOUNT, + CONF_SECURE_DEVICES_PIN, + CONF_API_KEY, +] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostic information.""" + data = hass.data[DOMAIN] + config: GoogleConfig = data[entry.entry_id] + yaml_config: ConfigType = data[DATA_CONFIG] + devices = await async_devices_sync_response(hass, config, REDACTED) + sync = create_sync_response(REDACTED, devices) + + return { + "config_entry": async_redact_data(entry.as_dict(), TO_REDACT), + "yaml_config": async_redact_data(yaml_config, TO_REDACT), + "sync": async_redact_data(sync, TO_REDACT), + } diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 932611390eb..6f81ddebdb4 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -160,7 +160,9 @@ class AbstractConfig(ABC): def get_local_webhook_id(self, agent_user_id): """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" - return self._store.agent_user_ids[agent_user_id][STORE_GOOGLE_LOCAL_WEBHOOK_ID] + if data := self._store.agent_user_ids.get(agent_user_id): + return data[STORE_GOOGLE_LOCAL_WEBHOOK_ID] + return None @abstractmethod def get_agent_user_id(self, context): diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 227b033bcaa..75a3fd76b9b 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -71,6 +71,24 @@ async def _process(hass, data, message): return {"requestId": data.request_id, "payload": result} +async def async_devices_sync_response(hass, config, agent_user_id): + """Generate the device serialization.""" + entities = async_get_entities(hass, config) + instance_uuid = await instance_id.async_get(hass) + devices = [] + + for entity in entities: + if not entity.should_expose(): + continue + + try: + devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Error serializing %s", entity.entity_id) + + return devices + + @HANDLERS.register("action.devices.SYNC") async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. @@ -86,19 +104,7 @@ async def async_devices_sync(hass, data, payload): agent_user_id = data.config.get_agent_user_id(data.context) await data.config.async_connect_agent_user(agent_user_id) - entities = async_get_entities(hass, data.config) - instance_uuid = await instance_id.async_get(hass) - devices = [] - - for entity in entities: - if not entity.should_expose(): - continue - - try: - devices.append(entity.sync_serialize(agent_user_id, instance_uuid)) - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error serializing %s", entity.entity_id) - + devices = await async_devices_sync_response(hass, data.config, agent_user_id) response = create_sync_response(agent_user_id, devices) _LOGGER.debug("Syncing entities response: %s", response) diff --git a/tests/components/google_assistant/test_button.py b/tests/components/google_assistant/test_button.py new file mode 100644 index 00000000000..0783b70dff3 --- /dev/null +++ b/tests/components/google_assistant/test_button.py @@ -0,0 +1,55 @@ +"""Test buttons.""" + +from unittest.mock import patch + +from pytest import raises + +from homeassistant.components import google_assistant as ga +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component + +from .test_http import DUMMY_CONFIG + +from tests.common import MockUser + + +async def test_sync_button(hass: HomeAssistant, hass_owner_user: MockUser): + """Test sync button.""" + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + await hass.async_block_till_done() + + state = hass.states.get("button.synchronize_devices") + assert state + + config_entry = hass.config_entries.async_entries("google_assistant")[0] + google_config: ga.GoogleConfig = hass.data[ga.DOMAIN][config_entry.entry_id] + + with patch.object(google_config, "async_sync_entities") as mock_sync_entities: + mock_sync_entities.return_value = 200 + context = Context(user_id=hass_owner_user.id) + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.synchronize_devices"}, + blocking=True, + context=context, + ) + mock_sync_entities.assert_called_once_with(hass_owner_user.id) + + with raises(HomeAssistantError): + mock_sync_entities.return_value = 400 + + await hass.services.async_call( + "button", + "press", + {"entity_id": "button.synchronize_devices"}, + blocking=True, + context=context, + ) diff --git a/tests/components/google_assistant/test_diagnostics.py b/tests/components/google_assistant/test_diagnostics.py new file mode 100644 index 00000000000..13721c17f88 --- /dev/null +++ b/tests/components/google_assistant/test_diagnostics.py @@ -0,0 +1,109 @@ +"""Test diagnostics.""" + +from typing import Any +from unittest.mock import ANY + +from homeassistant import core, setup +from homeassistant.components import google_assistant as ga, switch +from homeassistant.setup import async_setup_component + +from .test_http import DUMMY_CONFIG + +from tests.components.diagnostics import get_diagnostics_for_config_entry + + +async def test_diagnostics(hass: core.HomeAssistant, hass_client: Any): + """Test diagnostics v1.""" + + await setup.async_setup_component( + hass, switch.DOMAIN, {"switch": [{"platform": "demo"}]} + ) + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + config_entry = hass.config_entries.async_entries("google_assistant")[0] + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + assert result == { + "config_entry": { + "data": {"project_id": "1234"}, + "disabled_by": None, + "domain": "google_assistant", + "entry_id": ANY, + "options": {}, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "source": "import", + "title": "1234", + "unique_id": "1234", + "version": 1, + }, + "sync": { + "agentUserId": "**REDACTED**", + "devices": [ + { + "attributes": {"commandOnlyOnOff": True}, + "id": "switch.decorative_lights", + "otherDeviceIds": [{"deviceId": "switch.decorative_lights"}], + "name": {"name": "Decorative Lights"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.SWITCH", + "willReportState": False, + "customData": { + "baseUrl": "**REDACTED**", + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": "**REDACTED**", + "uuid": "**REDACTED**", + "webhookId": None, + }, + }, + { + "attributes": {}, + "id": "switch.ac", + "otherDeviceIds": [{"deviceId": "switch.ac"}], + "name": {"name": "AC"}, + "traits": ["action.devices.traits.OnOff"], + "type": "action.devices.types.OUTLET", + "willReportState": False, + "customData": { + "baseUrl": "**REDACTED**", + "httpPort": 8123, + "httpSSL": False, + "proxyDeviceId": "**REDACTED**", + "uuid": "**REDACTED**", + "webhookId": None, + }, + }, + ], + }, + "yaml_config": { + "expose_by_default": True, + "exposed_domains": [ + "alarm_control_panel", + "binary_sensor", + "climate", + "cover", + "fan", + "group", + "humidifier", + "input_boolean", + "input_select", + "light", + "lock", + "media_player", + "scene", + "script", + "select", + "sensor", + "switch", + "vacuum", + ], + "project_id": "1234", + "report_state": False, + "service_account": "**REDACTED**", + }, + } diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py index 69198b99aaa..bdd6932c91d 100644 --- a/tests/components/google_assistant/test_init.py +++ b/tests/components/google_assistant/test_init.py @@ -2,11 +2,47 @@ from http import HTTPStatus from homeassistant.components import google_assistant as ga -from homeassistant.core import Context +from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from .test_http import DUMMY_CONFIG +from tests.common import MockConfigEntry + + +async def test_import(hass: HomeAssistant): + """Test import.""" + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + + entries = hass.config_entries.async_entries("google_assistant") + assert len(entries) == 1 + assert entries[0].data[ga.const.CONF_PROJECT_ID] == "1234" + + +async def test_import_changed(hass: HomeAssistant): + """Test import with changed project id.""" + + old_entry = MockConfigEntry( + domain=ga.DOMAIN, data={ga.const.CONF_PROJECT_ID: "4321"}, source="import" + ) + old_entry.add_to_hass(hass) + + await async_setup_component( + hass, + ga.DOMAIN, + {"google_assistant": DUMMY_CONFIG}, + ) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries("google_assistant") + assert len(entries) == 1 + assert entries[0].data[ga.const.CONF_PROJECT_ID] == "1234" + async def test_request_sync_service(aioclient_mock, hass): """Test that it posts to the request_sync url.""" From 5f06404db5cbff7abf8b0436d10b6b7b03e6faf2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Jun 2022 20:25:36 +0200 Subject: [PATCH 704/947] Migrate tomorrowio to native_* (#74050) --- .../components/tomorrowio/weather.py | 34 +++++++++---------- tests/components/tomorrowio/test_weather.py | 10 ++++++ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/tomorrowio/weather.py b/homeassistant/components/tomorrowio/weather.py index 346f673362e..07ea079b1ce 100644 --- a/homeassistant/components/tomorrowio/weather.py +++ b/homeassistant/components/tomorrowio/weather.py @@ -8,13 +8,13 @@ from pytomorrowio.const import DAILY, FORECASTS, HOURLY, NOWCAST, WeatherCode from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry @@ -74,11 +74,11 @@ async def async_setup_entry( class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): """Entity that talks to Tomorrow.io v4 API to retrieve weather data.""" - _attr_temperature_unit = TEMP_CELSIUS - _attr_pressure_unit = PRESSURE_HPA - _attr_wind_speed_unit = SPEED_METERS_PER_SECOND - _attr_visibility_unit = LENGTH_KILOMETERS - _attr_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__( self, @@ -119,12 +119,12 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): data = { ATTR_FORECAST_TIME: forecast_dt.isoformat(), ATTR_FORECAST_CONDITION: translated_condition, - ATTR_FORECAST_PRECIPITATION: precipitation, + ATTR_FORECAST_NATIVE_PRECIPITATION: precipitation, ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, - ATTR_FORECAST_TEMP: temp, - ATTR_FORECAST_TEMP_LOW: temp_low, + ATTR_FORECAST_NATIVE_TEMP: temp, + ATTR_FORECAST_NATIVE_TEMP_LOW: temp_low, ATTR_FORECAST_WIND_BEARING: wind_direction, - ATTR_FORECAST_WIND_SPEED: wind_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: wind_speed, } return {k: v for k, v in data.items() if v is not None} @@ -145,12 +145,12 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): return CONDITIONS[condition] @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" return self._get_current_property(TMRW_ATTR_TEMPERATURE) @property - def pressure(self): + def native_pressure(self): """Return the raw pressure.""" return self._get_current_property(TMRW_ATTR_PRESSURE) @@ -160,7 +160,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): return self._get_current_property(TMRW_ATTR_HUMIDITY) @property - def wind_speed(self): + def native_wind_speed(self): """Return the raw wind speed.""" return self._get_current_property(TMRW_ATTR_WIND_SPEED) @@ -183,7 +183,7 @@ class TomorrowioWeatherEntity(TomorrowioEntity, WeatherEntity): ) @property - def visibility(self): + def native_visibility(self): """Return the raw visibility.""" return self._get_current_property(TMRW_ATTR_VISIBILITY) diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index 52c29161452..3d2b0669f5b 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -29,11 +29,16 @@ from homeassistant.components.weather import ( ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE, + ATTR_WEATHER_PRECIPITATION_UNIT, ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_PRESSURE_UNIT, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_TEMPERATURE_UNIT, ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_VISIBILITY_UNIT, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, DOMAIN as WEATHER_DOMAIN, ) from homeassistant.config_entries import SOURCE_USER @@ -104,8 +109,13 @@ async def test_v4_weather(hass: HomeAssistant) -> None: assert weather_state.attributes[ATTR_FRIENDLY_NAME] == "Tomorrow.io - Daily" assert weather_state.attributes[ATTR_WEATHER_HUMIDITY] == 23 assert weather_state.attributes[ATTR_WEATHER_OZONE] == 46.53 + assert weather_state.attributes[ATTR_WEATHER_PRECIPITATION_UNIT] == "mm" assert weather_state.attributes[ATTR_WEATHER_PRESSURE] == 30.35 + assert weather_state.attributes[ATTR_WEATHER_PRESSURE_UNIT] == "hPa" assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE] == 44.1 + assert weather_state.attributes[ATTR_WEATHER_TEMPERATURE_UNIT] == "°C" assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 8.15 + assert weather_state.attributes[ATTR_WEATHER_VISIBILITY_UNIT] == "km" assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 33.59 # 9.33 m/s ->km/h + assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED_UNIT] == "km/h" From 84ea8a3c438a4e2e95bb96c16b99e5ed6a86d7d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 27 Jun 2022 20:26:04 +0200 Subject: [PATCH 705/947] Fix misleading comments in tomorrowio (#74049) * Fix misleading comments in tomorrowio * Add test --- homeassistant/components/tomorrowio/sensor.py | 26 +++++++-------- tests/components/tomorrowio/test_sensor.py | 32 +++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index ed4ae915c1c..ea114af0544 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -28,7 +28,6 @@ from homeassistant.const import ( IRRADIATION_BTUS_PER_HOUR_SQUARE_FOOT, IRRADIATION_WATTS_PER_SQUARE_METER, LENGTH_KILOMETERS, - LENGTH_METERS, LENGTH_MILES, PERCENTAGE, PRESSURE_HPA, @@ -40,6 +39,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from homeassistant.util.distance import convert as distance_convert +from homeassistant.util.speed import convert as speed_convert from . import TomorrowioDataUpdateCoordinator, TomorrowioEntity from .const import ( @@ -113,14 +113,14 @@ SENSOR_TYPES = ( native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), - # Data comes in as inHg + # Data comes in as hPa TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, ), - # Data comes in as BTUs/(hr * ft^2) + # Data comes in as W/m^2, convert to BTUs/(hr * ft^2) for imperial # https://www.theunitconverter.com/watt-square-meter-to-btu-hour-square-foot-conversion/ TomorrowioSensorEntityDescription( key=TMRW_ATTR_SOLAR_GHI, @@ -129,7 +129,7 @@ SENSOR_TYPES = ( unit_metric=IRRADIATION_WATTS_PER_SQUARE_METER, imperial_conversion=(1 / 3.15459), ), - # Data comes in as miles + # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", @@ -139,7 +139,7 @@ SENSOR_TYPES = ( val, LENGTH_KILOMETERS, LENGTH_MILES ), ), - # Data comes in as miles + # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( key=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", @@ -154,16 +154,15 @@ SENSOR_TYPES = ( name="Cloud Cover", native_unit_of_measurement=PERCENTAGE, ), - # Data comes in as MPH + # Data comes in as m/s, convert to mi/h for imperial TomorrowioSensorEntityDescription( key=TMRW_ATTR_WIND_GUST, name="Wind Gust", unit_imperial=SPEED_MILES_PER_HOUR, unit_metric=SPEED_METERS_PER_SECOND, - imperial_conversion=lambda val: distance_convert( - val, LENGTH_METERS, LENGTH_MILES - ) - * 3600, + imperial_conversion=lambda val: speed_convert( + val, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR + ), ), TomorrowioSensorEntityDescription( key=TMRW_ATTR_PRECIPITATION_TYPE, @@ -172,7 +171,7 @@ SENSOR_TYPES = ( device_class="tomorrowio__precipitation_type", icon="mdi:weather-snowy-rainy", ), - # Data comes in as ppb + # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Ozone is 48 TomorrowioSensorEntityDescription( key=TMRW_ATTR_OZONE, @@ -193,7 +192,7 @@ SENSOR_TYPES = ( native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, device_class=SensorDeviceClass.PM10, ), - # Data comes in as ppb + # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 TomorrowioSensorEntityDescription( key=TMRW_ATTR_NITROGEN_DIOXIDE, @@ -202,7 +201,7 @@ SENSOR_TYPES = ( multiplication_factor=convert_ppb_to_ugm3(46.01), device_class=SensorDeviceClass.NITROGEN_DIOXIDE, ), - # Data comes in as ppb + # Data comes in as ppb, convert to ppm TomorrowioSensorEntityDescription( key=TMRW_ATTR_CARBON_MONOXIDE, name="Carbon Monoxide", @@ -210,6 +209,7 @@ SENSOR_TYPES = ( multiplication_factor=1 / 1000, device_class=SensorDeviceClass.CO, ), + # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 TomorrowioSensorEntityDescription( key=TMRW_ATTR_SULPHUR_DIOXIDE, diff --git a/tests/components/tomorrowio/test_sensor.py b/tests/components/tomorrowio/test_sensor.py index ef025204ea6..51b3db00c6e 100644 --- a/tests/components/tomorrowio/test_sensor.py +++ b/tests/components/tomorrowio/test_sensor.py @@ -25,6 +25,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers.entity_registry import async_get from homeassistant.util import dt as dt_util +from homeassistant.util.unit_system import IMPERIAL_SYSTEM from .const import API_V4_ENTRY_DATA @@ -169,6 +170,37 @@ async def test_v4_sensor(hass: HomeAssistant) -> None: check_sensor_state(hass, PRECIPITATION_TYPE, "rain") +async def test_v4_sensor_imperial(hass: HomeAssistant) -> None: + """Test v4 sensor data.""" + hass.config.units = IMPERIAL_SYSTEM + await _setup(hass, V4_FIELDS, API_V4_ENTRY_DATA) + check_sensor_state(hass, O3, "91.35") + check_sensor_state(hass, CO, "0.0") + check_sensor_state(hass, NO2, "20.08") + check_sensor_state(hass, SO2, "4.32") + check_sensor_state(hass, PM25, "0.15") + check_sensor_state(hass, PM10, "0.57") + check_sensor_state(hass, MEP_AQI, "23") + check_sensor_state(hass, MEP_HEALTH_CONCERN, "good") + check_sensor_state(hass, MEP_PRIMARY_POLLUTANT, "pm10") + check_sensor_state(hass, EPA_AQI, "24") + check_sensor_state(hass, EPA_HEALTH_CONCERN, "good") + check_sensor_state(hass, EPA_PRIMARY_POLLUTANT, "pm25") + check_sensor_state(hass, FIRE_INDEX, "10") + check_sensor_state(hass, GRASS_POLLEN, "none") + check_sensor_state(hass, WEED_POLLEN, "none") + check_sensor_state(hass, TREE_POLLEN, "none") + check_sensor_state(hass, FEELS_LIKE, "214.3") + check_sensor_state(hass, DEW_POINT, "163.08") + check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "29.47") + check_sensor_state(hass, GHI, "0.0") + check_sensor_state(hass, CLOUD_BASE, "0.46") + check_sensor_state(hass, CLOUD_COVER, "100") + check_sensor_state(hass, CLOUD_CEILING, "0.46") + check_sensor_state(hass, WIND_GUST, "28.27") + check_sensor_state(hass, PRECIPITATION_TYPE, "rain") + + async def test_entity_description() -> None: """Test improper entity description raises.""" with pytest.raises(ValueError): From 33f5b225fb21dfda80183470cba1fb716404a3f0 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 27 Jun 2022 21:29:19 +0200 Subject: [PATCH 706/947] Use aiounifi v34 to utilise orjson for better performance (#74065) Bump aiounifi to v34 --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 510659c56d3..36186b6fed8 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Network", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", - "requirements": ["aiounifi==33"], + "requirements": ["aiounifi==34"], "codeowners": ["@Kane610"], "quality_scale": "platinum", "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 95e4f539a27..30af6682715 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -259,7 +259,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==33 +aiounifi==34 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4bd6c87f04f..c9f7d815469 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.4 # homeassistant.components.unifi -aiounifi==33 +aiounifi==34 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From b9c636ba4e240a03fbb65df6dab4d28cdfcaf78a Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Mon, 27 Jun 2022 17:03:25 -0400 Subject: [PATCH 707/947] Automatically add newly added devices for UniFi Protect (#73879) --- .../components/unifiprotect/binary_sensor.py | 31 +++++++- .../components/unifiprotect/button.py | 18 ++++- .../components/unifiprotect/camera.py | 65 ++++++++++++----- .../components/unifiprotect/const.py | 3 + homeassistant/components/unifiprotect/data.py | 73 +++++++++++++++---- .../components/unifiprotect/entity.py | 42 ++++++++--- .../components/unifiprotect/light.py | 23 +++++- homeassistant/components/unifiprotect/lock.py | 20 ++++- .../components/unifiprotect/media_player.py | 21 ++++-- .../components/unifiprotect/number.py | 28 ++++++- .../components/unifiprotect/select.py | 22 +++++- .../components/unifiprotect/sensor.py | 37 ++++++++-- .../components/unifiprotect/switch.py | 20 ++++- .../components/unifiprotect/utils.py | 10 ++- .../unifiprotect/test_binary_sensor.py | 44 +++++++++++ tests/components/unifiprotect/test_button.py | 22 +++++- tests/components/unifiprotect/test_camera.py | 29 +++++++- tests/components/unifiprotect/test_light.py | 19 ++++- tests/components/unifiprotect/test_lock.py | 21 +++++- .../unifiprotect/test_media_player.py | 21 +++++- tests/components/unifiprotect/test_number.py | 41 +++++++++++ tests/components/unifiprotect/test_select.py | 44 +++++++++++ tests/components/unifiprotect/test_sensor.py | 30 ++++++++ tests/components/unifiprotect/test_switch.py | 30 ++++++++ tests/components/unifiprotect/utils.py | 58 ++++++++++++++- 25 files changed, 696 insertions(+), 76 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index eb4b2024233..598e0632fbb 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -11,6 +11,7 @@ from pyunifiprotect.data import ( Event, Light, MountType, + ProtectAdoptableDeviceModel, ProtectModelWithId, Sensor, ) @@ -23,10 +24,11 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ( EventThumbnailMixin, @@ -35,6 +37,7 @@ from .entity import ( async_all_device_entities, ) from .models import PermRequired, ProtectRequiredKeysMixin +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) _KEY_DOOR = "door" @@ -364,6 +367,24 @@ async def async_setup_entry( ) -> None: """Set up binary sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities: list[ProtectDeviceEntity] = async_all_device_entities( + data, + ProtectDeviceBinarySensor, + camera_descs=CAMERA_SENSORS, + light_descs=LIGHT_SENSORS, + sense_descs=SENSE_SENSORS, + lock_descs=DOORLOCK_SENSORS, + viewer_descs=VIEWER_SENSORS, + ufp_device=device, + ) + if device.is_adopted and isinstance(device, Camera): + entities += _async_motion_entities(data, ufp_device=device) + async_add_entities(entities) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectDeviceBinarySensor, @@ -382,10 +403,14 @@ async def async_setup_entry( @callback def _async_motion_entities( data: ProtectData, + ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] - for device in data.api.bootstrap.cameras.values(): - if not device.is_adopted_by_us: + devices = ( + data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device] + ) + for device in devices: + if not device.is_adopted: continue for description in MOTION_SENSORS: diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index d647cdac64a..901139109d3 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -13,12 +13,14 @@ from homeassistant.components.button import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_dispatch_id as _ufpd @dataclass @@ -79,6 +81,19 @@ async def async_setup_entry( """Discover devices on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectButton, + all_descs=ALL_DEVICE_BUTTONS, + chime_descs=CHIME_BUTTONS, + sense_descs=SENSOR_BUTTONS, + ufp_device=device, + ) + async_add_entities(entities) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectButton, @@ -86,7 +101,6 @@ async def async_setup_entry( chime_descs=CHIME_BUTTONS, sense_descs=SENSOR_BUTTONS, ) - async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index a84346a8384..336a5ae9187 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -4,10 +4,10 @@ from __future__ import annotations from collections.abc import Generator import logging -from pyunifiprotect.api import ProtectApiClient from pyunifiprotect.data import ( Camera as UFPCamera, CameraChannel, + ProtectAdoptableDeviceModel, ProtectModelWithId, StateType, ) @@ -15,6 +15,7 @@ from pyunifiprotect.data import ( from homeassistant.components.camera import Camera, CameraEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -23,28 +24,39 @@ from .const import ( ATTR_FPS, ATTR_HEIGHT, ATTR_WIDTH, + DISPATCH_ADOPT, + DISPATCH_CHANNELS, DOMAIN, ) from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) def get_camera_channels( - protect: ProtectApiClient, + data: ProtectData, + ufp_device: UFPCamera | None = None, ) -> Generator[tuple[UFPCamera, CameraChannel, bool], None, None]: """Get all the camera channels.""" - for camera in protect.bootstrap.cameras.values(): + + devices = ( + data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device] + ) + for camera in devices: if not camera.is_adopted_by_us: continue if not camera.channels: - _LOGGER.warning( - "Camera does not have any channels: %s (id: %s)", - camera.display_name, - camera.id, - ) + if ufp_device is None: + # only warn on startup + _LOGGER.warning( + "Camera does not have any channels: %s (id: %s)", + camera.display_name, + camera.id, + ) + data.async_add_pending_camera_id(camera.id) continue is_default = True @@ -60,17 +72,12 @@ def get_camera_channels( yield camera, camera.channels[0], True -async def async_setup_entry( - hass: HomeAssistant, - entry: ConfigEntry, - async_add_entities: AddEntitiesCallback, -) -> None: - """Discover cameras on a UniFi Protect NVR.""" - data: ProtectData = hass.data[DOMAIN][entry.entry_id] +def _async_camera_entities( + data: ProtectData, ufp_device: UFPCamera | None = None +) -> list[ProtectDeviceEntity]: disable_stream = data.disable_stream - - entities = [] - for camera, channel, is_default in get_camera_channels(data.api): + entities: list[ProtectDeviceEntity] = [] + for camera, channel, is_default in get_camera_channels(data, ufp_device): # do not enable streaming for package camera # 2 FPS causes a lot of buferring entities.append( @@ -95,6 +102,28 @@ async def async_setup_entry( disable_stream, ) ) + return entities + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Discover cameras on a UniFi Protect NVR.""" + data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not isinstance(device, UFPCamera): + return + + entities = _async_camera_entities(data, ufp_device=device) + async_add_entities(entities) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_CHANNELS), _add_new_device) + + entities = _async_camera_entities(data) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/const.py b/homeassistant/components/unifiprotect/const.py index 3ba22e6b85b..3c29d0c9972 100644 --- a/homeassistant/components/unifiprotect/const.py +++ b/homeassistant/components/unifiprotect/const.py @@ -60,3 +60,6 @@ PLATFORMS = [ Platform.SENSOR, Platform.SWITCH, ] + +DISPATCH_ADOPT = "adopt_device" +DISPATCH_CHANNELS = "new_camera_channels" diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 4a20e816ce2..30887f04235 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -10,6 +10,7 @@ from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( Bootstrap, Event, + EventType, Liveview, ModelType, ProtectAdoptableDeviceModel, @@ -20,10 +21,22 @@ from pyunifiprotect.exceptions import ClientError, NotAuthorized from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, DEVICES_WITH_ENTITIES, DOMAIN -from .utils import async_get_devices, async_get_devices_by_type +from .const import ( + CONF_DISABLE_RTSP, + DEVICES_THAT_ADOPT, + DEVICES_WITH_ENTITIES, + DISPATCH_ADOPT, + DISPATCH_CHANNELS, + DOMAIN, +) +from .utils import ( + async_dispatch_id as _ufpd, + async_get_devices, + async_get_devices_by_type, +) _LOGGER = logging.getLogger(__name__) @@ -56,6 +69,7 @@ class ProtectData: self._hass = hass self._update_interval = update_interval self._subscriptions: dict[str, list[Callable[[ProtectModelWithId], None]]] = {} + self._pending_camera_ids: set[str] = set() self._unsub_interval: CALLBACK_TYPE | None = None self._unsub_websocket: CALLBACK_TYPE | None = None @@ -117,6 +131,18 @@ class ProtectData: self.last_update_success = True self._async_process_updates(updates) + @callback + def async_add_pending_camera_id(self, camera_id: str) -> None: + """ + Add pending camera. + + A "pending camera" is one that has been adopted by not had its camera channels + initialized yet. Will cause Websocket code to check for channels to be + initialized for the camera and issue a dispatch once they do. + """ + + self._pending_camera_ids.add(camera_id) + @callback def _async_process_ws_message(self, message: WSSubscriptionMessage) -> None: # removed packets are not processed yet @@ -125,8 +151,19 @@ class ProtectData: ): return - if message.new_obj.model in DEVICES_WITH_ENTITIES: - self._async_signal_device_update(message.new_obj) + obj = message.new_obj + if obj.model in DEVICES_WITH_ENTITIES: + self._async_signal_device_update(obj) + if ( + obj.model == ModelType.CAMERA + and obj.id in self._pending_camera_ids + and "channels" in message.changed_data + ): + self._pending_camera_ids.remove(obj.id) + async_dispatcher_send( + self._hass, _ufpd(self._entry, DISPATCH_CHANNELS), obj + ) + # trigger update for all Cameras with LCD screens when NVR Doorbell settings updates if "doorbell_settings" in message.changed_data: _LOGGER.debug( @@ -137,17 +174,25 @@ class ProtectData: if camera.feature_flags.has_lcd_screen: self._async_signal_device_update(camera) # trigger updates for camera that the event references - elif isinstance(message.new_obj, Event): - if message.new_obj.camera is not None: - self._async_signal_device_update(message.new_obj.camera) - elif message.new_obj.light is not None: - self._async_signal_device_update(message.new_obj.light) - elif message.new_obj.sensor is not None: - self._async_signal_device_update(message.new_obj.sensor) + elif isinstance(obj, Event): + if obj.type == EventType.DEVICE_ADOPTED: + if obj.metadata is not None and obj.metadata.device_id is not None: + device = self.api.bootstrap.get_device_from_id( + obj.metadata.device_id + ) + if device is not None: + _LOGGER.debug("New device detected: %s", device.id) + async_dispatcher_send( + self._hass, _ufpd(self._entry, DISPATCH_ADOPT), device + ) + elif obj.camera is not None: + self._async_signal_device_update(obj.camera) + elif obj.light is not None: + self._async_signal_device_update(obj.light) + elif obj.sensor is not None: + self._async_signal_device_update(obj.sensor) # alert user viewport needs restart so voice clients can get new options - elif len(self.api.bootstrap.viewers) > 0 and isinstance( - message.new_obj, Liveview - ): + elif len(self.api.bootstrap.viewers) > 0 and isinstance(obj, Liveview): _LOGGER.warning( "Liveviews updated. Restart Home Assistant to update Viewport select options" ) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 65734569de2..b7419d0a41e 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -38,12 +38,16 @@ def _async_device_entities( klass: type[ProtectDeviceEntity], model_type: ModelType, descs: Sequence[ProtectRequiredKeysMixin], + ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: if len(descs) == 0: return [] entities: list[ProtectDeviceEntity] = [] - for device in data.get_by_types({model_type}): + devices = ( + [ufp_device] if ufp_device is not None else data.get_by_types({model_type}) + ) + for device in devices: if not device.is_adopted_by_us: continue @@ -89,6 +93,7 @@ def async_all_device_entities( lock_descs: Sequence[ProtectRequiredKeysMixin] | None = None, chime_descs: Sequence[ProtectRequiredKeysMixin] | None = None, all_descs: Sequence[ProtectRequiredKeysMixin] | None = None, + ufp_device: ProtectAdoptableDeviceModel | None = None, ) -> list[ProtectDeviceEntity]: """Generate a list of all the device entities.""" all_descs = list(all_descs or []) @@ -99,14 +104,33 @@ def async_all_device_entities( lock_descs = list(lock_descs or []) + all_descs chime_descs = list(chime_descs or []) + all_descs - return ( - _async_device_entities(data, klass, ModelType.CAMERA, camera_descs) - + _async_device_entities(data, klass, ModelType.LIGHT, light_descs) - + _async_device_entities(data, klass, ModelType.SENSOR, sense_descs) - + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) - + _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs) - + _async_device_entities(data, klass, ModelType.CHIME, chime_descs) - ) + if ufp_device is None: + return ( + _async_device_entities(data, klass, ModelType.CAMERA, camera_descs) + + _async_device_entities(data, klass, ModelType.LIGHT, light_descs) + + _async_device_entities(data, klass, ModelType.SENSOR, sense_descs) + + _async_device_entities(data, klass, ModelType.VIEWPORT, viewer_descs) + + _async_device_entities(data, klass, ModelType.DOORLOCK, lock_descs) + + _async_device_entities(data, klass, ModelType.CHIME, chime_descs) + ) + + descs = [] + if ufp_device.model == ModelType.CAMERA: + descs = camera_descs + elif ufp_device.model == ModelType.LIGHT: + descs = light_descs + elif ufp_device.model == ModelType.SENSOR: + descs = sense_descs + elif ufp_device.model == ModelType.VIEWPORT: + descs = viewer_descs + elif ufp_device.model == ModelType.DOORLOCK: + descs = lock_descs + elif ufp_device.model == ModelType.CHIME: + descs = chime_descs + + if len(descs) == 0 or ufp_device.model is None: + return [] + return _async_device_entities(data, klass, ufp_device.model, descs, ufp_device) class ProtectDeviceEntity(Entity): diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index bd64905a289..fdfe41bca3c 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -4,16 +4,23 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Light, ProtectModelWithId +from pyunifiprotect.data import ( + Light, + ModelType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, +) from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -25,6 +32,18 @@ async def async_setup_entry( ) -> None: """Set up lights for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not device.is_adopted_by_us: + return + + if device.model == ModelType.LIGHT and device.can_write( + data.api.bootstrap.auth_user + ): + async_add_entities([ProtectLight(data, device)]) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities = [] for device in data.api.bootstrap.lights.values(): if not device.is_adopted_by_us: diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 7258dc5f952..400d463050e 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -4,16 +4,23 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Doorlock, LockStatusType, ProtectModelWithId +from pyunifiprotect.data import ( + Doorlock, + LockStatusType, + ProtectAdoptableDeviceModel, + ProtectModelWithId, +) from homeassistant.components.lock import LockEntity, LockEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -26,6 +33,15 @@ async def async_setup_entry( """Set up locks on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not device.is_adopted_by_us: + return + + if isinstance(device, Doorlock): + async_add_entities([ProtectLock(data, device)]) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities = [] for device in data.api.bootstrap.doorlocks.values(): if not device.is_adopted_by_us: diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index b0391c9d860..41109c053f6 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from typing import Any -from pyunifiprotect.data import Camera, ProtectModelWithId +from pyunifiprotect.data import Camera, ProtectAdoptableDeviceModel, ProtectModelWithId from pyunifiprotect.exceptions import StreamError from homeassistant.components import media_source @@ -23,11 +23,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_IDLE, STATE_PLAYING from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -40,12 +42,21 @@ async def async_setup_entry( """Discover cameras with speakers on a UniFi Protect NVR.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + if not device.is_adopted_by_us: + return + + if isinstance(device, Camera) and device.feature_flags.has_speaker: + async_add_entities([ProtectMediaPlayer(data, device)]) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities = [] for device in data.api.bootstrap.cameras.values(): - if not device.is_adopted_by_us or not device.feature_flags.has_speaker: + if not device.is_adopted_by_us: continue - - entities.append(ProtectMediaPlayer(data, device)) + if device.feature_flags.has_speaker: + entities.append(ProtectMediaPlayer(data, device)) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 7bd6ce5b3d8..a017d2330b6 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -4,19 +4,27 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta -from pyunifiprotect.data import Camera, Doorlock, Light, ProtectModelWithId +from pyunifiprotect.data import ( + Camera, + Doorlock, + Light, + ProtectAdoptableDeviceModel, + ProtectModelWithId, +) from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, TIME_SECONDS from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_dispatch_id as _ufpd @dataclass @@ -184,6 +192,22 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectNumbers, + camera_descs=CAMERA_NUMBERS, + light_descs=LIGHT_NUMBERS, + sense_descs=SENSE_NUMBERS, + lock_descs=DOORLOCK_NUMBERS, + chime_descs=CHIME_NUMBERS, + ufp_device=device, + ) + async_add_entities(entities) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectNumbers, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 17bdfa390a6..5ea956ca603 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -19,6 +19,7 @@ from pyunifiprotect.data import ( LightModeEnableType, LightModeType, MountType, + ProtectAdoptableDeviceModel, ProtectModelWithId, RecordingMode, Sensor, @@ -32,14 +33,15 @@ from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.util.dt import utcnow -from .const import ATTR_DURATION, ATTR_MESSAGE, DOMAIN, TYPE_EMPTY_VALUE +from .const import ATTR_DURATION, ATTR_MESSAGE, DISPATCH_ADOPT, DOMAIN, TYPE_EMPTY_VALUE from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T -from .utils import async_get_light_motion_current +from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current _LOGGER = logging.getLogger(__name__) _KEY_LIGHT_MOTION = "light_motion" @@ -320,6 +322,22 @@ async def async_setup_entry( ) -> None: """Set up number entities for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectSelects, + camera_descs=CAMERA_SELECTS, + light_descs=LIGHT_SELECTS, + sense_descs=SENSE_SELECTS, + viewer_descs=VIEWER_SELECTS, + lock_descs=DOORLOCK_SELECTS, + ufp_device=device, + ) + async_add_entities(entities) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectSelects, diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 012d52ae215..a46e4c790b7 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -36,10 +36,11 @@ from homeassistant.const import ( TIME_SECONDS, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ( EventThumbnailMixin, @@ -48,7 +49,7 @@ from .entity import ( async_all_device_entities, ) from .models import PermRequired, ProtectRequiredKeysMixin, T -from .utils import async_get_light_motion_current +from .utils import async_dispatch_id as _ufpd, async_get_light_motion_current _LOGGER = logging.getLogger(__name__) OBJECT_TYPE_NONE = "none" @@ -594,6 +595,26 @@ async def async_setup_entry( ) -> None: """Set up sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectDeviceSensor, + all_descs=ALL_DEVICES_SENSORS, + camera_descs=CAMERA_SENSORS + CAMERA_DISABLED_SENSORS, + sense_descs=SENSE_SENSORS, + light_descs=LIGHT_SENSORS, + lock_descs=DOORLOCK_SENSORS, + chime_descs=CHIME_SENSORS, + viewer_descs=VIEWER_SENSORS, + ufp_device=device, + ) + if device.is_adopted_by_us and isinstance(device, Camera): + entities += _async_motion_entities(data, ufp_device=device) + async_add_entities(entities) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectDeviceSensor, @@ -614,13 +635,17 @@ async def async_setup_entry( @callback def _async_motion_entities( data: ProtectData, + ufp_device: Camera | None = None, ) -> list[ProtectDeviceEntity]: entities: list[ProtectDeviceEntity] = [] - for device in data.api.bootstrap.cameras.values(): - for description in MOTION_TRIP_SENSORS: - if not device.is_adopted_by_us: - continue + devices = ( + data.api.bootstrap.cameras.values() if ufp_device is None else [ufp_device] + ) + for device in devices: + if not device.is_adopted_by_us: + continue + for description in MOTION_TRIP_SENSORS: entities.append(ProtectDeviceSensor(data, device, description)) _LOGGER.debug( "Adding trip sensor entity %s for %s", diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 8b3661ce324..71812459b95 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -15,13 +15,15 @@ from pyunifiprotect.data import ( from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN +from .const import DISPATCH_ADOPT, DOMAIN from .data import ProtectData from .entity import ProtectDeviceEntity, async_all_device_entities from .models import PermRequired, ProtectSetableKeysMixin, T +from .utils import async_dispatch_id as _ufpd _LOGGER = logging.getLogger(__name__) @@ -302,6 +304,22 @@ async def async_setup_entry( ) -> None: """Set up sensors for UniFi Protect integration.""" data: ProtectData = hass.data[DOMAIN][entry.entry_id] + + async def _add_new_device(device: ProtectAdoptableDeviceModel) -> None: + entities = async_all_device_entities( + data, + ProtectSwitch, + camera_descs=CAMERA_SWITCHES, + light_descs=LIGHT_SWITCHES, + sense_descs=SENSE_SWITCHES, + lock_descs=DOORLOCK_SWITCHES, + viewer_descs=VIEWER_SWITCHES, + ufp_device=device, + ) + async_add_entities(entities) + + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entities: list[ProtectDeviceEntity] = async_all_device_entities( data, ProtectSwitch, diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 72baab334f3..808117aac9e 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -15,9 +15,10 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from .const import ModelType +from .const import DOMAIN, ModelType def get_nested_attr(obj: Any, attr: str) -> Any: @@ -98,3 +99,10 @@ def async_get_light_motion_current(obj: Light) -> str: ): return f"{LightModeType.MOTION.value}Dark" return obj.light_mode_settings.mode.value + + +@callback +def async_dispatch_id(entry: ConfigEntry, dispatch: str) -> str: + """Generate entry specific dispatch ID.""" + + return f"{DOMAIN}.{entry.entry_id}.{dispatch}" diff --git a/tests/components/unifiprotect/test_binary_sensor.py b/tests/components/unifiprotect/test_binary_sensor.py index 856c034905f..640bf81ec49 100644 --- a/tests/components/unifiprotect/test_binary_sensor.py +++ b/tests/components/unifiprotect/test_binary_sensor.py @@ -32,15 +32,59 @@ from homeassistant.helpers import entity_registry as er from .utils import ( MockUFPFixture, + adopt_devices, assert_entity_counts, ids_from_device_description, init_entry, + remove_entities, ) LIGHT_SENSOR_WRITE = LIGHT_SENSORS[:2] SENSE_SENSORS_WRITE = SENSE_SENSORS[:4] +async def test_binary_sensor_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera +): + """Test removing and re-adding a camera device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 3, 3) + + +async def test_binary_sensor_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test removing and re-adding a light device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) + await adopt_devices(hass, ufp, [light]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 2, 2) + + +async def test_binary_sensor_sensor_remove( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +): + """Test removing and re-adding a light device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + await remove_entities(hass, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 0, 0) + await adopt_devices(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.BINARY_SENSOR, 4, 4) + + async def test_binary_sensor_setup_light( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): diff --git a/tests/components/unifiprotect/test_button.py b/tests/components/unifiprotect/test_button.py index a846214a7aa..a46d74e0b8e 100644 --- a/tests/components/unifiprotect/test_button.py +++ b/tests/components/unifiprotect/test_button.py @@ -11,7 +11,27 @@ from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .utils import MockUFPFixture, assert_entity_counts, enable_entity, init_entry +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + enable_entity, + init_entry, + remove_entities, +) + + +async def test_button_chime_remove( + hass: HomeAssistant, ufp: MockUFPFixture, chime: Chime +): + """Test removing and re-adding a light device.""" + + await init_entry(hass, ufp, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 3, 2) + await remove_entities(hass, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 0, 0) + await adopt_devices(hass, ufp, [chime]) + assert_entity_counts(hass, Platform.BUTTON, 3, 2) async def test_reboot_button( diff --git a/tests/components/unifiprotect/test_camera.py b/tests/components/unifiprotect/test_camera.py index 6fad7cb899e..2b103e8d714 100644 --- a/tests/components/unifiprotect/test_camera.py +++ b/tests/components/unifiprotect/test_camera.py @@ -33,9 +33,11 @@ from homeassistant.setup import async_setup_component from .utils import ( MockUFPFixture, + adopt_devices, assert_entity_counts, enable_entity, init_entry, + remove_entities, time_changed, ) @@ -268,18 +270,37 @@ async def test_basic_setup( await validate_no_stream_camera_state(hass, doorbell, 3, entity_id, features=0) -async def test_missing_channels( - hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera -): +async def test_adopt(hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera): """Test setting up camera with no camera channels.""" camera1 = camera.copy() camera1.channels = [] await init_entry(hass, ufp, [camera1]) - assert_entity_counts(hass, Platform.CAMERA, 0, 0) + await remove_entities(hass, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + camera1.channels = [] + await adopt_devices(hass, ufp, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + + camera1.channels = camera.channels + for channel in camera1.channels: + channel._api = ufp.api + + mock_msg = Mock() + mock_msg.changed_data = {"channels": camera.channels} + mock_msg.new_obj = camera1 + ufp.ws_msg(mock_msg) + await hass.async_block_till_done() + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + + await remove_entities(hass, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 0, 0) + await adopt_devices(hass, ufp, [camera1]) + assert_entity_counts(hass, Platform.CAMERA, 2, 1) + async def test_camera_image( hass: HomeAssistant, ufp: MockUFPFixture, camera: ProtectCamera diff --git a/tests/components/unifiprotect/test_light.py b/tests/components/unifiprotect/test_light.py index 3c575de8d00..40f2191828e 100644 --- a/tests/components/unifiprotect/test_light.py +++ b/tests/components/unifiprotect/test_light.py @@ -19,7 +19,24 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .utils import MockUFPFixture, assert_entity_counts, init_entry +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + init_entry, + remove_entities, +) + + +async def test_light_remove(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): + """Test removing and re-adding a light device.""" + + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.LIGHT, 0, 0) + await adopt_devices(hass, ufp, [light]) + assert_entity_counts(hass, Platform.LIGHT, 1, 1) async def test_light_setup( diff --git a/tests/components/unifiprotect/test_lock.py b/tests/components/unifiprotect/test_lock.py index 21b3c77deb5..d6534e93845 100644 --- a/tests/components/unifiprotect/test_lock.py +++ b/tests/components/unifiprotect/test_lock.py @@ -21,7 +21,26 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .utils import MockUFPFixture, assert_entity_counts, init_entry +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + init_entry, + remove_entities, +) + + +async def test_lock_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock +): + """Test removing and re-adding a lock device.""" + + await init_entry(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) + await remove_entities(hass, [doorlock]) + assert_entity_counts(hass, Platform.LOCK, 0, 0) + await adopt_devices(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.LOCK, 1, 1) async def test_lock_setup( diff --git a/tests/components/unifiprotect/test_media_player.py b/tests/components/unifiprotect/test_media_player.py index 678fa0c9be4..ade84e2d51c 100644 --- a/tests/components/unifiprotect/test_media_player.py +++ b/tests/components/unifiprotect/test_media_player.py @@ -25,7 +25,26 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er -from .utils import MockUFPFixture, assert_entity_counts, init_entry +from .utils import ( + MockUFPFixture, + adopt_devices, + assert_entity_counts, + init_entry, + remove_entities, +) + + +async def test_media_player_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera +): + """Test removing and re-adding a light device.""" + + await init_entry(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) + await remove_entities(hass, [doorbell]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 0, 0) + await adopt_devices(hass, ufp, [doorbell]) + assert_entity_counts(hass, Platform.MEDIA_PLAYER, 1, 1) async def test_media_player_setup( diff --git a/tests/components/unifiprotect/test_number.py b/tests/components/unifiprotect/test_number.py index 656f7d08ba5..51e9dfc85a2 100644 --- a/tests/components/unifiprotect/test_number.py +++ b/tests/components/unifiprotect/test_number.py @@ -21,12 +21,53 @@ from homeassistant.helpers import entity_registry as er from .utils import ( MockUFPFixture, + adopt_devices, assert_entity_counts, ids_from_device_description, init_entry, + remove_entities, ) +async def test_number_sensor_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, camera: Camera, unadopted_camera: Camera +): + """Test removing and re-adding a camera device.""" + + await init_entry(hass, ufp, [camera, unadopted_camera]) + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + await remove_entities(hass, [camera, unadopted_camera]) + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + await adopt_devices(hass, ufp, [camera, unadopted_camera]) + assert_entity_counts(hass, Platform.NUMBER, 3, 3) + + +async def test_number_sensor_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test removing and re-adding a light device.""" + + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + await adopt_devices(hass, ufp, [light]) + assert_entity_counts(hass, Platform.NUMBER, 2, 2) + + +async def test_number_lock_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorlock: Doorlock +): + """Test removing and re-adding a light device.""" + + await init_entry(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.NUMBER, 1, 1) + await remove_entities(hass, [doorlock]) + assert_entity_counts(hass, Platform.NUMBER, 0, 0) + await adopt_devices(hass, ufp, [doorlock]) + assert_entity_counts(hass, Platform.NUMBER, 1, 1) + + async def test_number_setup_light( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): diff --git a/tests/components/unifiprotect/test_select.py b/tests/components/unifiprotect/test_select.py index 637a0d4ad5d..46bc70f61f6 100644 --- a/tests/components/unifiprotect/test_select.py +++ b/tests/components/unifiprotect/test_select.py @@ -41,12 +41,56 @@ from homeassistant.helpers import entity_registry as er from .utils import ( MockUFPFixture, + adopt_devices, assert_entity_counts, ids_from_device_description, init_entry, + remove_entities, ) +async def test_select_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera +): + """Test removing and re-adding a camera device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SELECT, 0, 0) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SELECT, 4, 4) + + +async def test_select_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test removing and re-adding a light device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.SELECT, 0, 0) + await adopt_devices(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SELECT, 2, 2) + + +async def test_select_viewer_remove( + hass: HomeAssistant, ufp: MockUFPFixture, viewer: Viewer +): + """Test removing and re-adding a light device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + await remove_entities(hass, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 0, 0) + await adopt_devices(hass, ufp, [viewer]) + assert_entity_counts(hass, Platform.SELECT, 1, 1) + + async def test_select_setup_light( hass: HomeAssistant, ufp: MockUFPFixture, light: Light ): diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index e204b09b1b0..fcad6ce2725 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -41,10 +41,12 @@ from homeassistant.helpers import entity_registry as er from .utils import ( MockUFPFixture, + adopt_devices, assert_entity_counts, enable_entity, ids_from_device_description, init_entry, + remove_entities, reset_objects, time_changed, ) @@ -53,6 +55,34 @@ CAMERA_SENSORS_WRITE = CAMERA_SENSORS[:5] SENSE_SENSORS_WRITE = SENSE_SENSORS[:8] +async def test_sensor_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera +): + """Test removing and re-adding a camera device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SENSOR, 12, 9) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SENSOR, 25, 13) + + +async def test_sensor_sensor_remove( + hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor +): + """Test removing and re-adding a light device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 22, 14) + await remove_entities(hass, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 12, 9) + await adopt_devices(hass, ufp, [sensor_all]) + assert_entity_counts(hass, Platform.SENSOR, 22, 14) + + async def test_sensor_setup_sensor( hass: HomeAssistant, ufp: MockUFPFixture, sensor_all: Sensor ): diff --git a/tests/components/unifiprotect/test_switch.py b/tests/components/unifiprotect/test_switch.py index 0c45ec28b7b..684e3b8e441 100644 --- a/tests/components/unifiprotect/test_switch.py +++ b/tests/components/unifiprotect/test_switch.py @@ -19,10 +19,12 @@ from homeassistant.helpers import entity_registry as er from .utils import ( MockUFPFixture, + adopt_devices, assert_entity_counts, enable_entity, ids_from_device_description, init_entry, + remove_entities, ) CAMERA_SWITCHES_BASIC = [ @@ -37,6 +39,34 @@ CAMERA_SWITCHES_NO_EXTRA = [ ] +async def test_switch_camera_remove( + hass: HomeAssistant, ufp: MockUFPFixture, doorbell: Camera, unadopted_camera: Camera +): + """Test removing and re-adding a camera device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + await remove_entities(hass, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + await adopt_devices(hass, ufp, [doorbell, unadopted_camera]) + assert_entity_counts(hass, Platform.SWITCH, 13, 12) + + +async def test_switch_light_remove( + hass: HomeAssistant, ufp: MockUFPFixture, light: Light +): + """Test removing and re-adding a light device.""" + + ufp.api.bootstrap.nvr.system_info.ustorage = None + await init_entry(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + await remove_entities(hass, [light]) + assert_entity_counts(hass, Platform.SWITCH, 0, 0) + await adopt_devices(hass, ufp, [light]) + assert_entity_counts(hass, Platform.SWITCH, 2, 1) + + async def test_switch_setup_no_perm( hass: HomeAssistant, ufp: MockUFPFixture, diff --git a/tests/components/unifiprotect/utils.py b/tests/components/unifiprotect/utils.py index 517da9e73c6..260c6996128 100644 --- a/tests/components/unifiprotect/utils.py +++ b/tests/components/unifiprotect/utils.py @@ -5,11 +5,15 @@ from __future__ import annotations from dataclasses import dataclass from datetime import timedelta from typing import Any, Callable, Sequence +from unittest.mock import Mock from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( Bootstrap, Camera, + Event, + EventType, + ModelType, ProtectAdoptableDeviceModel, WSSubscriptionMessage, ) @@ -18,7 +22,7 @@ from pyunifiprotect.test_util.anonymize import random_hex from homeassistant.const import Platform from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity import EntityDescription import homeassistant.util.dt as dt_util @@ -166,3 +170,55 @@ async def init_entry( await hass.config_entries.async_setup(ufp.entry.entry_id) await hass.async_block_till_done() + + +async def remove_entities( + hass: HomeAssistant, + ufp_devices: list[ProtectAdoptableDeviceModel], +) -> None: + """Remove all entities for given Protect devices.""" + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + for ufp_device in ufp_devices: + if not ufp_device.is_adopted_by_us: + continue + + name = ufp_device.display_name.replace(" ", "_").lower() + entity = entity_registry.async_get(f"{Platform.SENSOR}.{name}_uptime") + assert entity is not None + + device_id = entity.device_id + for reg in list(entity_registry.entities.values()): + if reg.device_id == device_id: + entity_registry.async_remove(reg.entity_id) + device_registry.async_remove_device(device_id) + + await hass.async_block_till_done() + + +async def adopt_devices( + hass: HomeAssistant, + ufp: MockUFPFixture, + ufp_devices: list[ProtectAdoptableDeviceModel], +): + """Emit WS to re-adopt give Protect devices.""" + + for ufp_device in ufp_devices: + mock_msg = Mock() + mock_msg.changed_data = {} + mock_msg.new_obj = Event( + api=ufp_device.api, + id=random_hex(24), + smart_detect_types=[], + smart_detect_event_ids=[], + type=EventType.DEVICE_ADOPTED, + start=dt_util.utcnow(), + score=100, + metadata={"device_id": ufp_device.id}, + model=ModelType.EVENT, + ) + ufp.ws_msg(mock_msg) + + await hass.async_block_till_done() From 08c5c6ca1c2cf4ece207697ba1e8ca10b843cddd Mon Sep 17 00:00:00 2001 From: shbatm Date: Mon, 27 Jun 2022 16:24:25 -0500 Subject: [PATCH 708/947] ISY994: Bump pyisy to 3.0.7 (#74071) --- homeassistant/components/isy994/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json index d131a150fb3..f3620ec9663 100644 --- a/homeassistant/components/isy994/manifest.json +++ b/homeassistant/components/isy994/manifest.json @@ -2,7 +2,7 @@ "domain": "isy994", "name": "Universal Devices ISY994", "documentation": "https://www.home-assistant.io/integrations/isy994", - "requirements": ["pyisy==3.0.6"], + "requirements": ["pyisy==3.0.7"], "codeowners": ["@bdraco", "@shbatm"], "config_flow": true, "ssdp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 30af6682715..105f8f08f20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1580,7 +1580,7 @@ pyirishrail==0.0.2 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.6 +pyisy==3.0.7 # homeassistant.components.itach pyitachip2ir==0.0.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9f7d815469..43fb970d99a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1065,7 +1065,7 @@ pyiqvia==2022.04.0 pyiss==1.0.1 # homeassistant.components.isy994 -pyisy==3.0.6 +pyisy==3.0.7 # homeassistant.components.kaleidescape pykaleidescape==1.0.1 From 21b842cf9cf49fdb9449fb24b46a55974a108186 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Jun 2022 00:48:18 +0200 Subject: [PATCH 709/947] Partially revert "Switch loader to use json helper (#73872)" (#74077) --- homeassistant/loader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index ab681d7c42d..589f316532b 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -11,6 +11,7 @@ from collections.abc import Callable from contextlib import suppress import functools as ft import importlib +import json import logging import pathlib import sys @@ -29,7 +30,6 @@ from .generated.mqtt import MQTT from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF -from .helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from .util.async_ import gather_with_concurrency # Typing imports that create a circular dependency @@ -366,8 +366,8 @@ class Integration: continue try: - manifest = json_loads(manifest_path.read_text()) - except JSON_DECODE_EXCEPTIONS as err: + manifest = json.loads(manifest_path.read_text()) + except ValueError as err: _LOGGER.error( "Error parsing manifest.json file at %s: %s", manifest_path, err ) From e8917af823c5fcc919169fc9f1b575b17335f3b0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 00:48:56 +0200 Subject: [PATCH 710/947] Cleanup update/async_update typing in Entities (#74035) --- homeassistant/components/garadget/cover.py | 2 +- homeassistant/components/lutron/cover.py | 2 +- homeassistant/components/soma/cover.py | 4 ++-- homeassistant/components/zha/cover.py | 2 +- homeassistant/components/zha/fan.py | 2 +- homeassistant/components/zha/lock.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 96ebe698605..826f21e9f88 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -233,7 +233,7 @@ class GaradgetCover(CoverEntity): self._start_watcher("stop") return ret["return_value"] == 1 - def update(self): + def update(self) -> None: """Get updated status from API.""" try: status = self._get_variable("doorStatus") diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py index fa62ef3745a..65a1c737d55 100644 --- a/homeassistant/components/lutron/cover.py +++ b/homeassistant/components/lutron/cover.py @@ -66,7 +66,7 @@ class LutronCover(LutronDevice, CoverEntity): position = kwargs[ATTR_POSITION] self._lutron_device.level = position - def update(self): + def update(self) -> None: """Call when forcing a refresh of the device.""" # Reading the property (rather than last_level()) fetches value level = self._lutron_device.level diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 0130b0ca7b1..116f88aa20e 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -102,7 +102,7 @@ class SomaTilt(SomaEntity, CoverEntity): ) self.set_position(kwargs[ATTR_TILT_POSITION]) - async def async_update(self): + async def async_update(self) -> None: """Update the entity with the latest data.""" response = await self.get_shade_state_from_api() @@ -172,7 +172,7 @@ class SomaShade(SomaEntity, CoverEntity): f'Error while setting the cover position ({self.name}): {response["msg"]}' ) - async def async_update(self): + async def async_update(self) -> None: """Update the cover with the latest data.""" response = await self.get_shade_state_from_api() diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 6ade62343b1..f6c67e6981d 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -162,7 +162,7 @@ class ZhaCover(ZhaEntity, CoverEntity): self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED self.async_write_ha_state() - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve the open/close state of the cover.""" await super().async_update() await self.async_get_state() diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 3b9793c5137..8e24427b679 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -202,7 +202,7 @@ class FanGroup(BaseFan, ZhaGroupEntity): self.error("Could not set fan mode: %s", ex) self.async_set_state(0, "fan_mode", fan_mode) - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve on off state from the fan.""" all_states = [self.hass.states.get(x) for x in self._entity_ids] states: list[State] = list(filter(None, all_states)) diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 6615141f4d1..a2ec5e068cb 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -137,7 +137,7 @@ class ZhaDoorLock(ZhaEntity, LockEntity): return self.async_write_ha_state() - async def async_update(self): + async def async_update(self) -> None: """Attempt to retrieve state from the lock.""" await super().async_update() await self.async_get_state() From 09dca3cd9478d0fbc12ee143b8e858e31e69e559 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Jun 2022 01:46:58 +0200 Subject: [PATCH 711/947] Remove invalid unit of measurement from Glances (#73983) --- homeassistant/components/glances/const.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 3fd26165283..d28c7395a43 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -142,7 +142,6 @@ SENSOR_TYPES: tuple[GlancesSensorEntityDescription, ...] = ( key="process_sleeping", type="processcount", name_suffix="Sleeping", - native_unit_of_measurement="", icon=CPU_ICON, state_class=SensorStateClass.MEASUREMENT, ), From c62bfcaa4cc70571acca65875ffc8da8a54649f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iv=C3=A1n=20Cea=20Fontenla?= Date: Tue, 28 Jun 2022 01:47:55 +0200 Subject: [PATCH 712/947] Nuki opener event on ring (#72793) * feat(nuki): add ring action timestamp attribute * feat(nuki): add ring action state attribute * Emit event on Nuki Opener ring * Removed event attributes * Use entity registry to get entity id * Move event firing to the async update method * Move events code outside try-except * Black autoformat * Added missing period to doc * Import order Co-authored-by: Franck Nijhof --- homeassistant/components/nuki/__init__.py | 41 +++++++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index e9cef7aa6cd..a59b0a62f70 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1,4 +1,5 @@ """The nuki component.""" +from collections import defaultdict from datetime import timedelta import logging @@ -12,6 +13,7 @@ from homeassistant import exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -39,17 +41,36 @@ def _get_bridge_devices(bridge: NukiBridge) -> tuple[list[NukiLock], list[NukiOp return bridge.locks, bridge.openers -def _update_devices(devices: list[NukiDevice]) -> None: +def _update_devices(devices: list[NukiDevice]) -> dict[str, set[str]]: + """ + Update the Nuki devices. + + Returns: + A dict with the events to be fired. The event type is the key and the device ids are the value + """ + + events: dict[str, set[str]] = defaultdict(set) + for device in devices: for level in (False, True): try: - device.update(level) + if isinstance(device, NukiOpener): + last_ring_action_state = device.ring_action_state + + device.update(level) + + if not last_ring_action_state and device.ring_action_state: + events["ring"].add(device.nuki_id) + else: + device.update(level) except RequestException: continue if device.state not in ERROR_STATES: break + return events + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Nuki entry.""" @@ -86,12 +107,26 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Note: asyncio.TimeoutError and aiohttp.ClientError are already # handled by the data update coordinator. async with async_timeout.timeout(10): - await hass.async_add_executor_job(_update_devices, locks + openers) + events = await hass.async_add_executor_job( + _update_devices, locks + openers + ) except InvalidCredentialsException as err: raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err except RequestException as err: raise UpdateFailed(f"Error communicating with Bridge: {err}") from err + ent_reg = er.async_get(hass) + for event, device_ids in events.items(): + for device_id in device_ids: + entity_id = ent_reg.async_get_entity_id( + Platform.LOCK, DOMAIN, device_id + ) + event_data = { + "entity_id": entity_id, + "type": event, + } + hass.bus.async_fire("nuki_event", event_data) + coordinator = DataUpdateCoordinator( hass, _LOGGER, From b2c84a4c4ae70eeb7ca39a92b8e77d28683b4f44 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 01:49:10 +0200 Subject: [PATCH 713/947] Adjust reauth in awair config flow (#72386) * Adjust config-flow type hints in awair * Improve typing of dict arguments * Use mapping for async_step_reauth * Add async_step_reauth_confirm * Don't try old token * Adjust translations * Adjust tests * Update tests/components/awair/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/awair/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/awair/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/awair/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/awair/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/awair/test_config_flow.py Co-authored-by: Martin Hjelmare * Update tests/components/awair/test_config_flow.py Co-authored-by: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/awair/config_flow.py | 18 +++- homeassistant/components/awair/strings.json | 2 +- .../components/awair/translations/en.json | 2 +- tests/components/awair/test_config_flow.py | 82 ++++++++++++------- 4 files changed, 69 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index 1eff98dd78d..fc7fd1e79a4 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -1,12 +1,16 @@ """Config flow for Awair.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from python_awair import Awair from python_awair.exceptions import AuthError, AwairError import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN, LOGGER @@ -17,7 +21,9 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_user(self, user_input: dict | None = None): + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: """Handle a flow initialized by the user.""" errors = {} @@ -42,8 +48,14 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input: dict | None = None): + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Handle re-auth if token invalid.""" + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" errors = {} if user_input is not None: @@ -62,7 +74,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): errors = {CONF_ACCESS_TOKEN: error} return self.async_show_form( - step_id="reauth", + step_id="reauth_confirm", data_schema=vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}), errors=errors, ) diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json index f9b1f40e047..5ed7c0e715e 100644 --- a/homeassistant/components/awair/strings.json +++ b/homeassistant/components/awair/strings.json @@ -8,7 +8,7 @@ "email": "[%key:common::config_flow::data::email%]" } }, - "reauth": { + "reauth_confirm": { "description": "Please re-enter your Awair developer access token.", "data": { "access_token": "[%key:common::config_flow::data::access_token%]", diff --git a/homeassistant/components/awair/translations/en.json b/homeassistant/components/awair/translations/en.json index 0e5a1e62bb5..52ffd13ec79 100644 --- a/homeassistant/components/awair/translations/en.json +++ b/homeassistant/components/awair/translations/en.json @@ -10,7 +10,7 @@ "unknown": "Unexpected error" }, "step": { - "reauth": { + "reauth_confirm": { "data": { "access_token": "Access Token", "email": "Email" diff --git a/tests/components/awair/test_config_flow.py b/tests/components/awair/test_config_flow.py index 8afe9a1c701..47e58fea421 100644 --- a/tests/components/awair/test_config_flow.py +++ b/tests/components/awair/test_config_flow.py @@ -8,6 +8,7 @@ from homeassistant import data_entry_flow from homeassistant.components.awair.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant from .const import CONFIG, DEVICES_FIXTURE, NO_DEVICES_FIXTURE, UNIQUE_ID, USER_FIXTURE @@ -82,46 +83,67 @@ async def test_no_devices_error(hass): assert result["reason"] == "no_devices_found" -async def test_reauth(hass): +async def test_reauth(hass: HomeAssistant) -> None: """Test reauth flow.""" - with patch( - "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] - ), patch( - "homeassistant.components.awair.sensor.async_setup_entry", - return_value=True, - ): - mock_config = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG) - mock_config.add_to_hass(hass) - hass.config_entries.async_update_entry( - mock_config, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"} - ) + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=UNIQUE_ID, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"} + ) + mock_config.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, - data=CONFIG, - ) - - assert result["type"] == "abort" - assert result["reason"] == "reauth_successful" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, + data={**CONFIG, CONF_ACCESS_TOKEN: "blah"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} with patch("python_awair.AwairClient.query", side_effect=AuthError()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, - data=CONFIG, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" assert result["errors"] == {CONF_ACCESS_TOKEN: "invalid_access_token"} - with patch("python_awair.AwairClient.query", side_effect=AwairError()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, - data=CONFIG, + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] + ), patch("homeassistant.components.awair.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + +async def test_reauth_error(hass: HomeAssistant) -> None: + """Test reauth flow.""" + mock_config = MockConfigEntry( + domain=DOMAIN, unique_id=UNIQUE_ID, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"} + ) + mock_config.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_REAUTH, "unique_id": UNIQUE_ID}, + data={**CONFIG, CONF_ACCESS_TOKEN: "blah"}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {} + + with patch("python_awair.AwairClient.query", side_effect=AwairError()): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONFIG, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "unknown" From 2f0fe0df821ae0805a8b18b5770c455647dbe15a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 28 Jun 2022 01:50:06 +0200 Subject: [PATCH 714/947] Fix wind speed SMHI (#72999) --- homeassistant/components/smhi/weather.py | 67 ++++++++++++++++-------- tests/components/smhi/test_weather.py | 47 +++++++++++++++-- 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index b63d32dc538..cbe2ad37fe9 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -2,9 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from datetime import datetime, timedelta import logging -from typing import Final +from typing import Any, Final import aiohttp import async_timeout @@ -27,10 +28,14 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ROUNDING_PRECISION, Forecast, WeatherEntity, ) @@ -41,6 +46,8 @@ from homeassistant.const import ( CONF_NAME, LENGTH_KILOMETERS, LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -49,7 +56,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.util import Throttle, slugify +from homeassistant.util import Throttle, slugify, speed as speed_util from .const import ( ATTR_SMHI_CLOUDINESS, @@ -112,9 +119,11 @@ class SmhiWeather(WeatherEntity): """Representation of a weather entity.""" _attr_attribution = "Swedish weather institute (SMHI)" - _attr_temperature_unit = TEMP_CELSIUS - _attr_visibility_unit = LENGTH_KILOMETERS - _attr_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + _attr_native_pressure_unit = PRESSURE_HPA def __init__( self, @@ -139,7 +148,23 @@ class SmhiWeather(WeatherEntity): configuration_url="http://opendata.smhi.se/apidocs/metfcst/parameters.html", ) self._attr_condition = None - self._attr_temperature = None + self._attr_native_temperature = None + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return additional attributes.""" + if self._forecasts: + wind_gust = speed_util.convert( + self._forecasts[0].wind_gust, + SPEED_METERS_PER_SECOND, + self._wind_speed_unit, + ) + return { + ATTR_SMHI_CLOUDINESS: self._forecasts[0].cloudiness, + ATTR_SMHI_WIND_GUST_SPEED: round(wind_gust, ROUNDING_PRECISION), + ATTR_SMHI_THUNDER_PROBABILITY: self._forecasts[0].thunder, + } + return None @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self) -> None: @@ -156,13 +181,12 @@ class SmhiWeather(WeatherEntity): return if self._forecasts: - self._attr_temperature = self._forecasts[0].temperature + self._attr_native_temperature = self._forecasts[0].temperature self._attr_humidity = self._forecasts[0].humidity - # Convert from m/s to km/h - self._attr_wind_speed = round(self._forecasts[0].wind_speed * 18 / 5) + self._attr_native_wind_speed = self._forecasts[0].wind_speed self._attr_wind_bearing = self._forecasts[0].wind_direction - self._attr_visibility = self._forecasts[0].horizontal_visibility - self._attr_pressure = self._forecasts[0].pressure + self._attr_native_visibility = self._forecasts[0].horizontal_visibility + self._attr_native_pressure = self._forecasts[0].pressure self._attr_condition = next( ( k @@ -171,12 +195,6 @@ class SmhiWeather(WeatherEntity): ), None, ) - self._attr_extra_state_attributes = { - ATTR_SMHI_CLOUDINESS: self._forecasts[0].cloudiness, - # Convert from m/s to km/h - ATTR_SMHI_WIND_GUST_SPEED: round(self._forecasts[0].wind_gust * 18 / 5), - ATTR_SMHI_THUNDER_PROBABILITY: self._forecasts[0].thunder, - } async def retry_update(self, _: datetime) -> None: """Retry refresh weather forecast.""" @@ -200,10 +218,13 @@ class SmhiWeather(WeatherEntity): data.append( { ATTR_FORECAST_TIME: forecast.valid_time.isoformat(), - ATTR_FORECAST_TEMP: forecast.temperature_max, - ATTR_FORECAST_TEMP_LOW: forecast.temperature_min, - ATTR_FORECAST_PRECIPITATION: round(forecast.total_precipitation, 1), + ATTR_FORECAST_NATIVE_TEMP: forecast.temperature_max, + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast.temperature_min, + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast.total_precipitation, ATTR_FORECAST_CONDITION: condition, + ATTR_FORECAST_NATIVE_PRESSURE: forecast.pressure, + ATTR_FORECAST_WIND_BEARING: forecast.wind_direction, + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast.wind_speed, } ) diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index c890ad62216..0097a7a5c5a 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -16,18 +16,24 @@ from homeassistant.components.weather import ( ATTR_FORECAST, ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_PRESSURE, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, + ATTR_WEATHER_WIND_SPEED_UNIT, + DOMAIN as WEATHER_DOMAIN, ) -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN +from homeassistant.const import ATTR_ATTRIBUTION, SPEED_METERS_PER_SECOND, STATE_UNKNOWN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow from . import ENTITY_ID, TEST_CONFIG @@ -58,13 +64,13 @@ async def test_setup_hass( assert state.state == "sunny" assert state.attributes[ATTR_SMHI_CLOUDINESS] == 50 assert state.attributes[ATTR_SMHI_THUNDER_PROBABILITY] == 33 - assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 17 + assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 16.92 assert state.attributes[ATTR_ATTRIBUTION].find("SMHI") >= 0 assert state.attributes[ATTR_WEATHER_HUMIDITY] == 55 assert state.attributes[ATTR_WEATHER_PRESSURE] == 1024 assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 17 assert state.attributes[ATTR_WEATHER_VISIBILITY] == 50 - assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 7 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 6.84 assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 134 assert len(state.attributes["forecast"]) == 4 @@ -74,6 +80,9 @@ async def test_setup_hass( assert forecast[ATTR_FORECAST_TEMP_LOW] == 6 assert forecast[ATTR_FORECAST_PRECIPITATION] == 0 assert forecast[ATTR_FORECAST_CONDITION] == "partlycloudy" + assert forecast[ATTR_FORECAST_PRESSURE] == 1026 + assert forecast[ATTR_FORECAST_WIND_BEARING] == 203 + assert forecast[ATTR_FORECAST_WIND_SPEED] == 6.12 async def test_properties_no_data(hass: HomeAssistant) -> None: @@ -305,3 +314,35 @@ def test_condition_class(): assert get_condition(23) == "snowy-rainy" # 24. Heavy sleet assert get_condition(24) == "snowy-rainy" + + +async def test_custom_speed_unit( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: + """Test Wind Gust speed with custom unit.""" + uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + aioclient_mock.get(uri, text=api_response) + + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + + assert state + assert state.name == "test" + assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 16.92 + + entity_reg = er.async_get(hass) + entity_reg.async_update_entity_options( + state.entity_id, + WEATHER_DOMAIN, + {ATTR_WEATHER_WIND_SPEED_UNIT: SPEED_METERS_PER_SECOND}, + ) + + await hass.async_block_till_done() + + state = hass.states.get(ENTITY_ID) + assert state.attributes[ATTR_SMHI_WIND_GUST_SPEED] == 4.7 From e706c6a15f639184b854e27962e4e33f02d2bc99 Mon Sep 17 00:00:00 2001 From: leroyloren <57643470+leroyloren@users.noreply.github.com> Date: Tue, 28 Jun 2022 01:53:57 +0200 Subject: [PATCH 715/947] Visiblity fix unit km to m (#74008) --- homeassistant/components/openweathermap/const.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 027c08fd84b..f180f2a9bbf 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -31,7 +31,7 @@ from homeassistant.components.weather import ( ) from homeassistant.const import ( DEGREE, - LENGTH_KILOMETERS, + LENGTH_METERS, LENGTH_MILLIMETERS, PERCENTAGE, PRESSURE_HPA, @@ -248,7 +248,7 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_VISIBILITY_DISTANCE, name="Visibility", - native_unit_of_measurement=LENGTH_KILOMETERS, + native_unit_of_measurement=LENGTH_METERS, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( From fef21c02eedc757eb41114dd655c08c0c8952761 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Tue, 28 Jun 2022 07:56:10 +0800 Subject: [PATCH 716/947] Clean up disabling audio in stream (#74038) --- homeassistant/components/stream/worker.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 4cfe8864de0..1d29bd17c33 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -473,10 +473,6 @@ def stream_worker( audio_stream = None if audio_stream and audio_stream.name not in AUDIO_CODECS: audio_stream = None - # These formats need aac_adtstoasc bitstream filter, but auto_bsf not - # compatible with empty_moov and manual bitstream filters not in PyAV - if container.format.name in {"hls", "mpegts"}: - audio_stream = None # Some audio streams do not have a profile and throw errors when remuxing if audio_stream and audio_stream.profile is None: audio_stream = None From 192986ba8a2e8f0146776381def52d22eaeabb2e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 01:57:46 +0200 Subject: [PATCH 717/947] Migrate buienradar to native_* (#74059) --- .../components/buienradar/weather.py | 53 +++++++++++-------- 1 file changed, 31 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index aa336d3929c..6fdf5c166ee 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -28,16 +28,25 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + LENGTH_METERS, + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -111,7 +120,11 @@ async def async_setup_entry( class BrWeather(WeatherEntity): """Representation of a weather condition.""" - _attr_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_METERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__(self, data, config, coordinates): """Initialize the platform with a data instance and station name.""" @@ -142,12 +155,12 @@ class BrWeather(WeatherEntity): return conditions.get(ccode) @property - def temperature(self): + def native_temperature(self): """Return the current temperature.""" return self._data.temperature @property - def pressure(self): + def native_pressure(self): """Return the current pressure.""" return self._data.pressure @@ -157,18 +170,14 @@ class BrWeather(WeatherEntity): return self._data.humidity @property - def visibility(self): - """Return the current visibility in km.""" - if self._data.visibility is None: - return None - return round(self._data.visibility / 1000, 1) + def native_visibility(self): + """Return the current visibility in m.""" + return self._data.visibility @property - def wind_speed(self): - """Return the current windspeed in km/h.""" - if self._data.wind_speed is None: - return None - return round(self._data.wind_speed * 3.6, 1) + def native_wind_speed(self): + """Return the current windspeed in m/s.""" + return self._data.wind_speed @property def wind_bearing(self): @@ -191,11 +200,11 @@ class BrWeather(WeatherEntity): data_out = { ATTR_FORECAST_TIME: data_in.get(DATETIME).isoformat(), ATTR_FORECAST_CONDITION: cond[condcode], - ATTR_FORECAST_TEMP_LOW: data_in.get(MIN_TEMP), - ATTR_FORECAST_TEMP: data_in.get(MAX_TEMP), - ATTR_FORECAST_PRECIPITATION: data_in.get(RAIN), + ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.get(MIN_TEMP), + ATTR_FORECAST_NATIVE_TEMP: data_in.get(MAX_TEMP), + ATTR_FORECAST_NATIVE_PRECIPITATION: data_in.get(RAIN), ATTR_FORECAST_WIND_BEARING: data_in.get(WINDAZIMUTH), - ATTR_FORECAST_WIND_SPEED: round(data_in.get(WINDSPEED) * 3.6, 1), + ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.get(WINDSPEED), } fcdata_out.append(data_out) From 7e341aaef218dbeb4c48102ed1aa752769bc5f82 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 28 Jun 2022 00:26:39 +0000 Subject: [PATCH 718/947] [ci skip] Translation update --- homeassistant/components/awair/translations/en.json | 7 +++++++ homeassistant/components/hive/translations/et.json | 9 ++++++++- homeassistant/components/hive/translations/no.json | 9 ++++++++- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/awair/translations/en.json b/homeassistant/components/awair/translations/en.json index 52ffd13ec79..caec592c527 100644 --- a/homeassistant/components/awair/translations/en.json +++ b/homeassistant/components/awair/translations/en.json @@ -10,6 +10,13 @@ "unknown": "Unexpected error" }, "step": { + "reauth": { + "data": { + "access_token": "Access Token", + "email": "Email" + }, + "description": "Please re-enter your Awair developer access token." + }, "reauth_confirm": { "data": { "access_token": "Access Token", diff --git a/homeassistant/components/hive/translations/et.json b/homeassistant/components/hive/translations/et.json index 5cffcb036d3..d5916257589 100644 --- a/homeassistant/components/hive/translations/et.json +++ b/homeassistant/components/hive/translations/et.json @@ -20,6 +20,13 @@ "description": "Sisesta oma Hive autentimiskood. \n\n Uue koodi taotlemiseks sisesta kood 0000.", "title": "Hive kaheastmeline autentimine." }, + "configuration": { + "data": { + "device_name": "Seadme nimi" + }, + "description": "Sisesta oma Hive andmed", + "title": "Hive s\u00e4tted" + }, "reauth": { "data": { "password": "Salas\u00f5na", @@ -34,7 +41,7 @@ "scan_interval": "P\u00e4ringute intervall (sekundites)", "username": "Kasutajanimi" }, - "description": "Sisesta oma Hive sisselogimisteave ja s\u00e4tted.", + "description": "Sisesta oma Hive sisselogimisteave.", "title": "Hive sisselogimine" } } diff --git a/homeassistant/components/hive/translations/no.json b/homeassistant/components/hive/translations/no.json index c5213aafeee..17241b940c4 100644 --- a/homeassistant/components/hive/translations/no.json +++ b/homeassistant/components/hive/translations/no.json @@ -20,6 +20,13 @@ "description": "Skriv inn din Hive-godkjenningskode. \n\n Vennligst skriv inn kode 0000 for \u00e5 be om en annen kode.", "title": "Hive Totrinnsbekreftelse autentisering." }, + "configuration": { + "data": { + "device_name": "Enhetsnavn" + }, + "description": "Skriv inn Hive-konfigurasjonen din", + "title": "Hive-konfigurasjon." + }, "reauth": { "data": { "password": "Passord", @@ -34,7 +41,7 @@ "scan_interval": "Skanneintervall (sekunder)", "username": "Brukernavn" }, - "description": "Skriv inn inn innloggingsinformasjonen og konfigurasjonen for Hive.", + "description": "Skriv inn Hive-p\u00e5loggingsinformasjonen din.", "title": "Hive-p\u00e5logging" } } From cdaa6c0d429bc70078537291bb35d1bba2a9f24d Mon Sep 17 00:00:00 2001 From: maikukun <107741047+maikukun@users.noreply.github.com> Date: Mon, 27 Jun 2022 19:31:30 -0700 Subject: [PATCH 719/947] Update tesla_powerwall to 0.3.18 (#74026) --- homeassistant/components/powerwall/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/powerwall/manifest.json b/homeassistant/components/powerwall/manifest.json index be5d4678e27..3d1eb07f3fa 100644 --- a/homeassistant/components/powerwall/manifest.json +++ b/homeassistant/components/powerwall/manifest.json @@ -3,7 +3,7 @@ "name": "Tesla Powerwall", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/powerwall", - "requirements": ["tesla-powerwall==0.3.17"], + "requirements": ["tesla-powerwall==0.3.18"], "codeowners": ["@bdraco", "@jrester"], "dhcp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 105f8f08f20..5f5fc9fe4d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2298,7 +2298,7 @@ temperusb==1.5.3 # tensorflow==2.5.0 # homeassistant.components.powerwall -tesla-powerwall==0.3.17 +tesla-powerwall==0.3.18 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 43fb970d99a..24903c43b30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1519,7 +1519,7 @@ tailscale==0.2.0 tellduslive==0.10.11 # homeassistant.components.powerwall -tesla-powerwall==0.3.17 +tesla-powerwall==0.3.18 # homeassistant.components.tesla_wall_connector tesla-wall-connector==1.0.1 From 1e0788aeea2c331b30a6f56ec57600bc4f045b14 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 28 Jun 2022 04:51:02 +0200 Subject: [PATCH 720/947] Allow partial tests to take 20 minutes, use all cores (#74079) --- .github/workflows/ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fcd879d7512..bf690740c6d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -803,7 +803,7 @@ jobs: tests - name: Run pytest (partially) if: needs.changes.outputs.test_full_suite == 'false' - timeout-minutes: 10 + timeout-minutes: 20 shell: bash run: | . venv/bin/activate @@ -818,7 +818,7 @@ jobs: -qq \ --timeout=9 \ --durations=10 \ - -n 0 \ + -n auto \ --cov="homeassistant.components.${{ matrix.group }}" \ --cov-report=xml \ --cov-report=term-missing \ From df357962b3f25004eb73cbda38e372ad9bd10a8d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jun 2022 21:59:08 -0700 Subject: [PATCH 721/947] Bump orjson to 3.7.5 (#74083) --- homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6fb51cf0d09..dfbf056936f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -20,7 +20,7 @@ httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 lru-dict==1.1.7 -orjson==3.7.2 +orjson==3.7.5 paho-mqtt==1.6.1 pillow==9.1.1 pip>=21.0,<22.2 diff --git a/pyproject.toml b/pyproject.toml index fc07110dbad..59df3967b6e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ dependencies = [ "PyJWT==2.4.0", # PyJWT has loose dependency. We want the latest one. "cryptography==36.0.2", - "orjson==3.7.2", + "orjson==3.7.5", "pip>=21.0,<22.2", "python-slugify==4.0.1", "pyyaml==6.0", diff --git a/requirements.txt b/requirements.txt index 21e11def1b5..7506201eae1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ ifaddr==0.1.7 jinja2==3.1.2 PyJWT==2.4.0 cryptography==36.0.2 -orjson==3.7.2 +orjson==3.7.5 pip>=21.0,<22.2 python-slugify==4.0.1 pyyaml==6.0 From 91a119917d93527c73778dcfe78feb17537c2ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5Bp=CA=B2=C9=B5s=5D?= Date: Tue, 28 Jun 2022 07:00:44 +0200 Subject: [PATCH 722/947] List more private and link-local IP networks (#74064) List more private and link-local IP networks The IPv6 link-local network is especially important as without it local accounts don't work on IPv6-capable networks with no IPv6 DHCP server. --- homeassistant/util/network.py | 21 ++++++++++++++------- tests/util/test_network.py | 8 ++++++++ 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py index 87077a0eb0a..7d0d6e99639 100644 --- a/homeassistant/util/network.py +++ b/homeassistant/util/network.py @@ -14,14 +14,21 @@ LOOPBACK_NETWORKS = ( # RFC6890 - Address allocation for Private Internets PRIVATE_NETWORKS = ( - ip_network("fd00::/8"), ip_network("10.0.0.0/8"), ip_network("172.16.0.0/12"), ip_network("192.168.0.0/16"), + ip_network("fd00::/8"), + ip_network("::ffff:10.0.0.0/104"), + ip_network("::ffff:172.16.0.0/108"), + ip_network("::ffff:192.168.0.0/112"), ) # RFC6890 - Link local ranges -LINK_LOCAL_NETWORK = ip_network("169.254.0.0/16") +LINK_LOCAL_NETWORKS = ( + ip_network("169.254.0.0/16"), + ip_network("fe80::/10"), + ip_network("::ffff:169.254.0.0/112"), +) def is_loopback(address: IPv4Address | IPv6Address) -> bool: @@ -30,18 +37,18 @@ def is_loopback(address: IPv4Address | IPv6Address) -> bool: def is_private(address: IPv4Address | IPv6Address) -> bool: - """Check if an address is a private address.""" + """Check if an address is a unique local non-loopback address.""" return any(address in network for network in PRIVATE_NETWORKS) def is_link_local(address: IPv4Address | IPv6Address) -> bool: - """Check if an address is link local.""" - return address in LINK_LOCAL_NETWORK + """Check if an address is link-local (local but not necessarily unique).""" + return any(address in network for network in LINK_LOCAL_NETWORKS) def is_local(address: IPv4Address | IPv6Address) -> bool: - """Check if an address is loopback or private.""" - return is_loopback(address) or is_private(address) + """Check if an address is on a local network.""" + return is_loopback(address) or is_private(address) or is_link_local(address) def is_invalid(address: IPv4Address | IPv6Address) -> bool: diff --git a/tests/util/test_network.py b/tests/util/test_network.py index 4f372e5e1a7..7339b6dc51d 100644 --- a/tests/util/test_network.py +++ b/tests/util/test_network.py @@ -30,7 +30,9 @@ def test_is_private(): def test_is_link_local(): """Test link local addresses.""" assert network_util.is_link_local(ip_address("169.254.12.3")) + assert network_util.is_link_local(ip_address("fe80::1234:5678:abcd")) assert not network_util.is_link_local(ip_address("127.0.0.1")) + assert not network_util.is_link_local(ip_address("::1")) def test_is_invalid(): @@ -43,7 +45,13 @@ def test_is_local(): """Test local addresses.""" assert network_util.is_local(ip_address("192.168.0.1")) assert network_util.is_local(ip_address("127.0.0.1")) + assert network_util.is_local(ip_address("fd12:3456:789a:1::1")) + assert network_util.is_local(ip_address("fe80::1234:5678:abcd")) + assert network_util.is_local(ip_address("::ffff:192.168.0.1")) assert not network_util.is_local(ip_address("208.5.4.2")) + assert not network_util.is_local(ip_address("198.51.100.1")) + assert not network_util.is_local(ip_address("2001:DB8:FA1::1")) + assert not network_util.is_local(ip_address("::ffff:208.5.4.2")) def test_is_ip_address(): From 1804f70a5b4f8bd3a178ea7969134f9d064cda3f Mon Sep 17 00:00:00 2001 From: Matrix Date: Tue, 28 Jun 2022 14:39:12 +0800 Subject: [PATCH 723/947] Fix missing leak sensor battery expose (#74084) --- homeassistant/components/yolink/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 7c578fbaa73..4679c3e670b 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -22,6 +22,7 @@ from .const import ( ATTR_COORDINATORS, ATTR_DEVICE_CO_SMOKE_SENSOR, ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_LOCK, ATTR_DEVICE_MANIPULATOR, ATTR_DEVICE_MOTION_SENSOR, @@ -61,6 +62,7 @@ SENSOR_DEVICE_TYPE = [ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, + ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, From 530e1f908020631932c6d2fb5b80626f19325e22 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 08:40:46 +0200 Subject: [PATCH 724/947] Fix reauth step in geocaching (#74089) --- homeassistant/components/geocaching/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/geocaching/config_flow.py b/homeassistant/components/geocaching/config_flow.py index 83c9ed17586..9ce3cb76775 100644 --- a/homeassistant/components/geocaching/config_flow.py +++ b/homeassistant/components/geocaching/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Geocaching.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -24,9 +25,9 @@ class GeocachingFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Return logger.""" return logging.getLogger(__name__) - async def async_step_reauth(self, user_input: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - return await self.async_step_reauth_confirm(user_input=user_input) + return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None From b6676df1cbc6430339c5b8f9b40baa4009d95067 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 08:43:06 +0200 Subject: [PATCH 725/947] Adjust config-flow reauth type hints in components (#74088) --- homeassistant/components/google/config_flow.py | 5 ++--- homeassistant/components/neato/config_flow.py | 4 ++-- homeassistant/components/netatmo/config_flow.py | 4 +++- homeassistant/components/spotify/config_flow.py | 3 ++- homeassistant/components/yolink/config_flow.py | 3 ++- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index 046840075ff..a5951edec22 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Google integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -155,9 +156,7 @@ class OAuth2FlowHandler( }, ) - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 15544371b2e..1c180fe1dbd 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -1,8 +1,8 @@ """Config flow for Neato Botvac.""" from __future__ import annotations +from collections.abc import Mapping import logging -from types import MappingProxyType from typing import Any from homeassistant.config_entries import SOURCE_REAUTH @@ -35,7 +35,7 @@ class OAuth2FlowHandler( return await super().async_step_user(user_input=user_input) - async def async_step_reauth(self, data: MappingProxyType[str, Any]) -> FlowResult: + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index bbd28e8398f..125e6ee38e5 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -1,7 +1,9 @@ """Config flow for Netatmo.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any import uuid import voluptuous as vol @@ -66,7 +68,7 @@ class NetatmoFlowHandler( return await super().async_step_user(user_input) - async def async_step_reauth(self, user_input: dict | None = None) -> FlowResult: + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index e1780ff9d40..30ad74f8e26 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Spotify.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -56,7 +57,7 @@ class SpotifyFlowHandler( return self.async_create_entry(title=name, data=data) - async def async_step_reauth(self, entry: dict[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/yolink/config_flow.py b/homeassistant/components/yolink/config_flow.py index 35a4c4ebea8..68eabdfa183 100644 --- a/homeassistant/components/yolink/config_flow.py +++ b/homeassistant/components/yolink/config_flow.py @@ -1,6 +1,7 @@ """Config flow for yolink.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -30,7 +31,7 @@ class OAuth2FlowHandler( scopes = ["create"] return {"scope": " ".join(scopes)} - async def async_step_reauth(self, user_input=None) -> FlowResult: + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] From 720768560d9145857e3bcbcfc9b6c4f0467d140d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jun 2022 23:44:06 -0700 Subject: [PATCH 726/947] Fix devices missing in logbook when all requested entities are filtered (#74073) --- .../components/logbook/websocket_api.py | 2 +- .../components/logbook/test_websocket_api.py | 93 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index 04afa82e75b..3af87b26caa 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -301,7 +301,7 @@ async def ws_event_stream( entity_ids = msg.get("entity_ids") if entity_ids: entity_ids = async_filter_entities(hass, entity_ids) - if not entity_ids: + if not entity_ids and not device_ids: _async_send_empty_response(connection, msg_id, start_time, end_time) return diff --git a/tests/components/logbook/test_websocket_api.py b/tests/components/logbook/test_websocket_api.py index 66fbc9b0bca..6c4908a2ad5 100644 --- a/tests/components/logbook/test_websocket_api.py +++ b/tests/components/logbook/test_websocket_api.py @@ -2564,3 +2564,96 @@ async def test_logbook_stream_ignores_forced_updates( # Check our listener got unsubscribed assert sum(hass.bus.async_listeners().values()) == init_count + + +@patch("homeassistant.components.logbook.websocket_api.EVENT_COALESCE_TIME", 0) +async def test_subscribe_all_entities_are_continuous_with_device( + hass, recorder_mock, hass_ws_client +): + """Test subscribe/unsubscribe logbook stream with entities that are always filtered and a device.""" + now = dt_util.utcnow() + await asyncio.gather( + *[ + async_setup_component(hass, comp, {}) + for comp in ("homeassistant", "logbook", "automation", "script") + ] + ) + await async_wait_recording_done(hass) + devices = await _async_mock_devices_with_logbook_platform(hass) + device = devices[0] + device2 = devices[1] + + entity_ids = ("sensor.uom", "sensor.uom_two") + + def _create_events(): + for entity_id in entity_ids: + for state in ("1", "2", "3"): + hass.states.async_set( + entity_id, state, {ATTR_UNIT_OF_MEASUREMENT: "any"} + ) + hass.states.async_set("counter.any", state) + hass.states.async_set("proximity.any", state) + hass.bus.async_fire("mock_event", {"device_id": device.id}) + hass.bus.async_fire("mock_event", {"device_id": device2.id}) + + init_count = sum(hass.bus.async_listeners().values()) + _create_events() + + await async_wait_recording_done(hass) + websocket_client = await hass_ws_client() + await websocket_client.send_json( + { + "id": 7, + "type": "logbook/event_stream", + "start_time": now.isoformat(), + "entity_ids": ["sensor.uom", "counter.any", "proximity.any"], + "device_ids": [device.id, device2.id], + } + ) + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + {"domain": "test", "message": "is on fire", "name": "device name", "when": ANY}, + {"domain": "test", "message": "is on fire", "name": "device name", "when": ANY}, + ] + assert msg["event"]["partial"] is True + + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [] + assert "partial" not in msg["event"] + + for _ in range(2): + _create_events() + msg = await asyncio.wait_for(websocket_client.receive_json(), 2) + assert msg["id"] == 7 + assert msg["type"] == "event" + assert msg["event"]["events"] == [ + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + { + "domain": "test", + "message": "is on fire", + "name": "device name", + "when": ANY, + }, + ] + assert "partial" not in msg["event"] + + await websocket_client.close() + await hass.async_block_till_done() + + # Check our listener got unsubscribed + assert sum(hass.bus.async_listeners().values()) == init_count From fb108533580d5f4c326ca970d8e6fd4998cc5593 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 08:50:16 +0200 Subject: [PATCH 727/947] Fix mypy issues in zha core modules (#74028) * Fix mypy issues in zha gateway, group and helpers * Cleanup device * Apply suggestion * Raise ValueError * Use hass.config.path --- homeassistant/components/zha/core/device.py | 4 ++-- homeassistant/components/zha/core/gateway.py | 6 +++--- homeassistant/components/zha/core/group.py | 6 ++---- homeassistant/components/zha/core/helpers.py | 2 ++ mypy.ini | 12 ------------ script/hassfest/mypy_config.py | 4 ---- 6 files changed, 9 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 588fcac7ca6..e83c0afbceb 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -276,7 +276,7 @@ class ZHADevice(LogMixin): @property def skip_configuration(self) -> bool: """Return true if the device should not issue configuration related commands.""" - return self._zigpy_device.skip_configuration or self.is_coordinator + return self._zigpy_device.skip_configuration or bool(self.is_coordinator) @property def gateway(self): @@ -819,7 +819,7 @@ class ZHADevice(LogMixin): fmt = f"{log_msg[1]} completed: %s" zdo.debug(fmt, *(log_msg[2] + (outcome,))) - def log(self, level: int, msg: str, *args: Any, **kwargs: dict) -> None: + def log(self, level: int, msg: str, *args: Any, **kwargs: Any) -> None: """Log a message.""" msg = f"[%s](%s): {msg}" args = (self.nwk, self.model) + args diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 02b2b21c835..b9465d5e2aa 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -8,7 +8,6 @@ from datetime import timedelta from enum import Enum import itertools import logging -import os import time import traceback from typing import TYPE_CHECKING, Any, NamedTuple, Union @@ -163,7 +162,7 @@ class ZHAGateway: app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( CONF_DATABASE, - os.path.join(self._hass.config.config_dir, DEFAULT_DATABASE_NAME), + self._hass.config.path(DEFAULT_DATABASE_NAME), ) app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] @@ -333,7 +332,7 @@ class ZHAGateway: def group_removed(self, zigpy_group: zigpy.group.Group) -> None: """Handle zigpy group removed event.""" self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED) - zha_group = self._groups.pop(zigpy_group.group_id, None) + zha_group = self._groups.pop(zigpy_group.group_id) zha_group.info("group_removed") self._cleanup_group_entity_registry_entries(zigpy_group) @@ -428,6 +427,7 @@ class ZHAGateway: ] # then we get all group entity entries tied to the coordinator + assert self.coordinator_zha_device all_group_entity_entries = er.async_entries_for_device( self.ha_entity_registry, self.coordinator_zha_device.device_id, diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 1392041c4d4..7f1c9f09998 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -78,7 +78,7 @@ class ZHAGroupMember(LogMixin): return member_info @property - def associated_entities(self) -> list[GroupEntityReference]: + def associated_entities(self) -> list[dict[str, Any]]: """Return the list of entities that were derived from this endpoint.""" ha_entity_registry = self.device.gateway.ha_entity_registry zha_device_registry = self.device.gateway.device_registry @@ -150,9 +150,7 @@ class ZHAGroup(LogMixin): def members(self) -> list[ZHAGroupMember]: """Return the ZHA devices that are members of this group.""" return [ - ZHAGroupMember( - self, self._zha_gateway.devices.get(member_ieee), endpoint_id - ) + ZHAGroupMember(self, self._zha_gateway.devices[member_ieee], endpoint_id) for (member_ieee, endpoint_id) in self._zigpy_group.members.keys() if member_ieee in self._zha_gateway.devices ] diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index f387cc99bfe..390ef290dc2 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -169,6 +169,8 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: """Get a ZHA device for the given device registry id.""" device_registry = dr.async_get(hass) registry_device = device_registry.async_get(device_id) + if not registry_device: + raise ValueError(f"Device id `{device_id}` not found in registry.") zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ieee_address = list(list(registry_device.identifiers)[0])[1] ieee = zigpy.types.EUI64.convert(ieee_address) diff --git a/mypy.ini b/mypy.ini index e2e91ef921c..b5c56d71ff9 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2996,21 +2996,9 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.switch] ignore_errors = true -[mypy-homeassistant.components.zha.core.device] -ignore_errors = true - [mypy-homeassistant.components.zha.core.discovery] ignore_errors = true -[mypy-homeassistant.components.zha.core.gateway] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.group] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.helpers] -ignore_errors = true - [mypy-homeassistant.components.zha.core.registries] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 49cf3b7b65a..453eade7b2d 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -144,11 +144,7 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.light", "homeassistant.components.xiaomi_miio.sensor", "homeassistant.components.xiaomi_miio.switch", - "homeassistant.components.zha.core.device", "homeassistant.components.zha.core.discovery", - "homeassistant.components.zha.core.gateway", - "homeassistant.components.zha.core.group", - "homeassistant.components.zha.core.helpers", "homeassistant.components.zha.core.registries", "homeassistant.components.zha.core.store", ] From cc8170fcfec8ed9070ebdc903c19246b228d8e60 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 08:50:31 +0200 Subject: [PATCH 728/947] Align code between group platforms (#74057) --- .../components/group/binary_sensor.py | 21 +++++++++---------- homeassistant/components/group/light.py | 18 +++++++--------- homeassistant/components/group/switch.py | 1 + 3 files changed, 19 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index ff0e58badfb..473a5a5e885 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -88,6 +88,8 @@ async def async_setup_entry( class BinarySensorGroup(GroupEntity, BinarySensorEntity): """Representation of a BinarySensorGroup.""" + _attr_available: bool = False + def __init__( self, unique_id: str | None, @@ -127,27 +129,24 @@ class BinarySensorGroup(GroupEntity, BinarySensorEntity): @callback def async_update_group_state(self) -> None: """Query all members and determine the binary sensor group state.""" - all_states = [self.hass.states.get(x) for x in self._entity_ids] - - # filtered_states are members currently in the state machine - filtered_states: list[str] = [x.state for x in all_states if x is not None] + states = [ + state.state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] # Set group as unavailable if all members are unavailable or missing - self._attr_available = any( - state != STATE_UNAVAILABLE for state in filtered_states - ) + self._attr_available = any(state != STATE_UNAVAILABLE for state in states) valid_state = self.mode( - state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) if not valid_state: # Set as unknown if any / all member is not unknown or unavailable self._attr_is_on = None else: # Set as ON if any / all member is ON - states = list(map(lambda x: x == STATE_ON, filtered_states)) - state = self.mode(states) - self._attr_is_on = state + self._attr_is_on = self.mode(state == STATE_ON for state in states) @property def device_class(self) -> str | None: diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index b9741085c2d..e0645da6141 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -46,7 +46,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import Event, HomeAssistant, State, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event @@ -214,15 +214,15 @@ class LightGroup(GroupEntity, LightEntity): @callback def async_update_group_state(self) -> None: """Query all members and determine the light group state.""" - all_states = [self.hass.states.get(x) for x in self._entity_ids] - states: list[State] = list(filter(None, all_states)) + states = [ + state + for entity_id in self._entity_ids + if (state := self.hass.states.get(entity_id)) is not None + ] on_states = [state for state in states if state.state == STATE_ON] - # filtered_states are members currently in the state machine - filtered_states: list[str] = [x.state for x in all_states if x is not None] - valid_state = self.mode( - state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in filtered_states + state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) if not valid_state: @@ -230,9 +230,7 @@ class LightGroup(GroupEntity, LightEntity): self._attr_is_on = None else: # Set as ON if any / all member is ON - self._attr_is_on = self.mode( - list(map(lambda x: x == STATE_ON, filtered_states)) - ) + self._attr_is_on = self.mode(state.state == STATE_ON for state in states) self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) self._attr_brightness = reduce_attribute(on_states, ATTR_BRIGHTNESS) diff --git a/homeassistant/components/group/switch.py b/homeassistant/components/group/switch.py index 6b879e55cea..8b60e1f1402 100644 --- a/homeassistant/components/group/switch.py +++ b/homeassistant/components/group/switch.py @@ -170,4 +170,5 @@ class SwitchGroup(GroupEntity, SwitchEntity): # Set as ON if any / all member is ON self._attr_is_on = self.mode(state == STATE_ON for state in states) + # Set group as unavailable if all members are unavailable or missing self._attr_available = any(state != STATE_UNAVAILABLE for state in states) From 87b46a699a21fc06672bc780193f3c264dcf8424 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 08:52:20 +0200 Subject: [PATCH 729/947] Fix mypy issues in zha store (#74032) --- homeassistant/components/zha/core/store.py | 13 +++++++------ mypy.ini | 3 --- script/hassfest/mypy_config.py | 1 - 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py index c82f05303a5..e58dcd46dba 100644 --- a/homeassistant/components/zha/core/store.py +++ b/homeassistant/components/zha/core/store.py @@ -5,7 +5,7 @@ from collections import OrderedDict from collections.abc import MutableMapping import datetime import time -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast import attr @@ -45,10 +45,11 @@ class ZhaStorage: @callback def async_create_device(self, device: ZHADevice) -> ZhaDeviceEntry: """Create a new ZhaDeviceEntry.""" + ieee_str: str = str(device.ieee) device_entry: ZhaDeviceEntry = ZhaDeviceEntry( - name=device.name, ieee=str(device.ieee), last_seen=device.last_seen + name=device.name, ieee=ieee_str, last_seen=device.last_seen ) - self.devices[device_entry.ieee] = device_entry + self.devices[ieee_str] = device_entry self.async_schedule_save() return device_entry @@ -81,8 +82,8 @@ class ZhaStorage: ieee_str: str = str(device.ieee) old = self.devices[ieee_str] - if old is not None and device.last_seen is None: - return + if device.last_seen is None: + return old changes = {} changes["last_seen"] = device.last_seen @@ -93,7 +94,7 @@ class ZhaStorage: async def async_load(self) -> None: """Load the registry of zha device entries.""" - data = await self._store.async_load() + data = cast(dict[str, Any], await self._store.async_load()) devices: OrderedDict[str, ZhaDeviceEntry] = OrderedDict() diff --git a/mypy.ini b/mypy.ini index b5c56d71ff9..0f8c8fc0b61 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3001,6 +3001,3 @@ ignore_errors = true [mypy-homeassistant.components.zha.core.registries] ignore_errors = true - -[mypy-homeassistant.components.zha.core.store] -ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 453eade7b2d..0fd62d49ae2 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -146,7 +146,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.switch", "homeassistant.components.zha.core.discovery", "homeassistant.components.zha.core.registries", - "homeassistant.components.zha.core.store", ] # Component modules which should set no_implicit_reexport = true. From 567df9cc4db44d42431b25300f1781713bf44581 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 27 Jun 2022 23:53:17 -0700 Subject: [PATCH 730/947] Add async_remove_config_entry_device to enphase_envoy (#74012) --- .../components/enphase_envoy/__init__.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 696baa31775..61bf9b64bcf 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -96,3 +97,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +async def async_remove_config_entry_device( + hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry +) -> bool: + """Remove a enphase_envoy config entry from a device.""" + dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN} + data: dict = hass.data[DOMAIN][config_entry.entry_id] + coordinator: DataUpdateCoordinator = data[COORDINATOR] + envoy_data: dict = coordinator.data + envoy_serial_num = config_entry.unique_id + if envoy_serial_num in dev_ids: + return False + for inverter in envoy_data.get("inverters_production", []): + if str(inverter) in dev_ids: + return False + return True From cb46bb5bfad279ce21e99f37b4a75f9cb2661c58 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jun 2022 00:34:56 -0700 Subject: [PATCH 731/947] Revert "Partially revert "Switch loader to use json helper (#73872)" (#74077)" (#74087) --- homeassistant/loader.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 589f316532b..ab681d7c42d 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -11,7 +11,6 @@ from collections.abc import Callable from contextlib import suppress import functools as ft import importlib -import json import logging import pathlib import sys @@ -30,6 +29,7 @@ from .generated.mqtt import MQTT from .generated.ssdp import SSDP from .generated.usb import USB from .generated.zeroconf import HOMEKIT, ZEROCONF +from .helpers.json import JSON_DECODE_EXCEPTIONS, json_loads from .util.async_ import gather_with_concurrency # Typing imports that create a circular dependency @@ -366,8 +366,8 @@ class Integration: continue try: - manifest = json.loads(manifest_path.read_text()) - except ValueError as err: + manifest = json_loads(manifest_path.read_text()) + except JSON_DECODE_EXCEPTIONS as err: _LOGGER.error( "Error parsing manifest.json file at %s: %s", manifest_path, err ) From 800bae68a8c45433b132cb50bcb700f2c758940c Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 28 Jun 2022 02:51:33 -0500 Subject: [PATCH 732/947] Fix clearing of Sonos library cache during regrouping (#74085) Fix clearing of ZoneGroupState attribute cache --- homeassistant/components/sonos/speaker.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index d37e3bac2a3..729d1a1457f 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -1084,8 +1084,8 @@ class SonosSpeaker: except asyncio.TimeoutError: _LOGGER.warning("Timeout waiting for target groups %s", groups) - for speaker in hass.data[DATA_SONOS].discovered.values(): - speaker.soco._zgs_cache.clear() # pylint: disable=protected-access + any_speaker = next(iter(hass.data[DATA_SONOS].discovered.values())) + any_speaker.soco.zone_group_state.clear_cache() # # Media and playback state handlers From 8e1ec07f3d3ab5e95a4bb7c44dfff048dd9a4b74 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:00:23 +0200 Subject: [PATCH 733/947] Adjust type hints in component alarm methods (#74092) * Adjust type hints in component alarm methods * Undo related change * Undo related change --- .../components/agent_dvr/alarm_control_panel.py | 12 +++++++----- .../alarmdecoder/alarm_control_panel.py | 12 +++++++----- .../components/blink/alarm_control_panel.py | 8 +++++--- .../components/concord232/alarm_control_panel.py | 8 ++++---- .../components/egardia/alarm_control_panel.py | 10 +++++----- .../components/envisalink/alarm_control_panel.py | 12 ++++++------ .../components/hive/alarm_control_panel.py | 10 ++++++---- .../homematicip_cloud/alarm_control_panel.py | 6 +++--- .../components/ialarm/alarm_control_panel.py | 8 +++++--- .../components/ifttt/alarm_control_panel.py | 8 ++++---- .../components/lupusec/alarm_control_panel.py | 6 +++--- .../components/manual/alarm_control_panel.py | 16 ++++++++-------- .../manual_mqtt/alarm_control_panel.py | 12 ++++++------ .../components/mqtt/alarm_control_panel.py | 14 +++++++------- .../components/ness_alarm/alarm_control_panel.py | 10 +++++----- .../components/nx584/alarm_control_panel.py | 8 ++++---- .../components/point/alarm_control_panel.py | 10 ++++++---- .../components/risco/alarm_control_panel.py | 12 +++++++----- .../satel_integra/alarm_control_panel.py | 8 ++++---- .../components/spc/alarm_control_panel.py | 10 +++++----- .../components/template/alarm_control_panel.py | 10 +++++----- .../totalconnect/alarm_control_panel.py | 14 ++++++++------ .../xiaomi_miio/alarm_control_panel.py | 8 +++++--- 23 files changed, 125 insertions(+), 107 deletions(-) diff --git a/homeassistant/components/agent_dvr/alarm_control_panel.py b/homeassistant/components/agent_dvr/alarm_control_panel.py index 8978be97c1d..632b2e29d57 100644 --- a/homeassistant/components/agent_dvr/alarm_control_panel.py +++ b/homeassistant/components/agent_dvr/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Agent DVR Alarm Control Panels.""" +from __future__ import annotations + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -58,7 +60,7 @@ class AgentBaseStation(AlarmControlPanelEntity): sw_version=client.version, ) - async def async_update(self): + async def async_update(self) -> None: """Update the state of the device.""" await self._client.update() self._attr_available = self._client.is_available @@ -76,24 +78,24 @@ class AgentBaseStation(AlarmControlPanelEntity): else: self._attr_state = STATE_ALARM_DISARMED - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._client.disarm() self._attr_state = STATE_ALARM_DISARMED - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_AWAY_MODE_NAME) self._attr_state = STATE_ALARM_ARMED_AWAY - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_HOME_MODE_NAME) self._attr_state = STATE_ALARM_ARMED_HOME - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command. Uses custom mode.""" await self._client.arm() await self._client.set_active_profile(CONF_NIGHT_MODE_NAME) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 991d588eccf..ca11b9d6894 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for AlarmDecoder-based alarm control panels (Honeywell/DSC).""" +from __future__ import annotations + import voluptuous as vol from homeassistant.components.alarm_control_panel import ( @@ -91,7 +93,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): self._attr_code_arm_required = code_arm_required self._alt_night_mode = alt_night_mode - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -126,12 +128,12 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): } self.schedule_update_ha_state() - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code: self._client.send(f"{code!s}1") - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._client.arm_away( code=code, @@ -139,7 +141,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): auto_bypass=self._auto_bypass, ) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._client.arm_home( code=code, @@ -147,7 +149,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanelEntity): auto_bypass=self._auto_bypass, ) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" self._client.arm_night( code=code, diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py index dbea1371af2..22a142ff44c 100644 --- a/homeassistant/components/blink/alarm_control_panel.py +++ b/homeassistant/components/blink/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Blink Alarm Control Panel.""" +from __future__ import annotations + import logging from homeassistant.components.alarm_control_panel import ( @@ -51,7 +53,7 @@ class BlinkSyncModule(AlarmControlPanelEntity): identifiers={(DOMAIN, sync.serial)}, name=name, manufacturer=DEFAULT_BRAND ) - def update(self): + def update(self) -> None: """Update the state of the device.""" _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name) self.data.refresh() @@ -63,12 +65,12 @@ class BlinkSyncModule(AlarmControlPanelEntity): self.sync.attributes[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION self._attr_extra_state_attributes = self.sync.attributes - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self.sync.arm = False self.sync.refresh() - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm command.""" self.sync.arm = True self.sync.refresh() diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index e8f21c46278..4b46d1bf98c 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -103,7 +103,7 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): """Return the state of the device.""" return self._state - def update(self): + def update(self) -> None: """Update values from API.""" try: part = self._alarm.list_partitions()[0] @@ -124,13 +124,13 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): else: self._state = STATE_ALARM_ARMED_AWAY - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): return self._alarm.disarm(code) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if not self._validate_code(code, STATE_ALARM_ARMED_HOME): return @@ -139,7 +139,7 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): else: self._alarm.arm("stay") - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if not self._validate_code(code, STATE_ALARM_ARMED_AWAY): return diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index 2e2abe1fc87..f35d248c968 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -79,7 +79,7 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): self._rs_codes = rs_codes self._rs_port = rs_port - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Add Egardiaserver callback if enabled.""" if self._rs_enabled: _LOGGER.debug("Registering callback to Egardiaserver") @@ -134,12 +134,12 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): else: _LOGGER.error("Ignoring status") - def update(self): + def update(self) -> None: """Update the alarm status.""" status = self._egardiasystem.getstate() self.parsestatus(status) - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" try: self._egardiasystem.alarm_disarm() @@ -149,7 +149,7 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): err, ) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" try: self._egardiasystem.alarm_arm_home() @@ -160,7 +160,7 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): err, ) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" try: self._egardiasystem.alarm_arm_away() diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 3dfa1e0762d..35cfe8558a8 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -121,7 +121,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): _LOGGER.debug("Setting up alarm: %s", alarm_name) super().__init__(alarm_name, info, controller) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -168,7 +168,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): state = STATE_ALARM_DISARMED return state - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if code: self.hass.data[DATA_EVL].disarm_partition(str(code), self._partition_number) @@ -177,7 +177,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): str(self._code), self._partition_number ) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if code: self.hass.data[DATA_EVL].arm_stay_partition( @@ -188,7 +188,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): str(self._code), self._partition_number ) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if code: self.hass.data[DATA_EVL].arm_away_partition( @@ -199,11 +199,11 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): str(self._code), self._partition_number ) - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Alarm trigger command. Will be used to trigger a panic alarm.""" self.hass.data[DATA_EVL].panic_alarm(self._panic_type) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" self.hass.data[DATA_EVL].arm_night_partition( str(code) if code else str(self._code), self._partition_number diff --git a/homeassistant/components/hive/alarm_control_panel.py b/homeassistant/components/hive/alarm_control_panel.py index f8f35e20ffa..48b59e351be 100644 --- a/homeassistant/components/hive/alarm_control_panel.py +++ b/homeassistant/components/hive/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for the Hive alarm.""" +from __future__ import annotations + from datetime import timedelta from homeassistant.components.alarm_control_panel import ( @@ -49,19 +51,19 @@ class HiveAlarmControlPanelEntity(HiveEntity, AlarmControlPanelEntity): | AlarmControlPanelEntityFeature.ARM_AWAY ) - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self.hive.alarm.setMode(self.device, "home") - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self.hive.alarm.setMode(self.device, "asleep") - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self.hive.alarm.setMode(self.device, "away") - async def async_update(self): + async def async_update(self) -> None: """Update all Node data from Hive.""" await self.hive.session.updateData(self.device) self.device = await self.hive.alarm.getAlarm(self.device) diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py index 86e187410b3..3b6ae684d07 100644 --- a/homeassistant/components/homematicip_cloud/alarm_control_panel.py +++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py @@ -83,15 +83,15 @@ class HomematicipAlarmControlPanelEntity(AlarmControlPanelEntity): def _security_and_alarm(self) -> SecurityAndAlarmHome: return self._home.get_functionalHome(SecurityAndAlarmHome) - async def async_alarm_disarm(self, code=None) -> None: + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._home.set_security_zones_activation(False, False) - async def async_alarm_arm_home(self, code=None) -> None: + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._home.set_security_zones_activation(False, True) - async def async_alarm_arm_away(self, code=None) -> None: + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._home.set_security_zones_activation(True, True) diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index be53eb99525..74310d940e7 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -1,4 +1,6 @@ """Interfaces with iAlarm control panels.""" +from __future__ import annotations + from homeassistant.components.alarm_control_panel import ( AlarmControlPanelEntity, AlarmControlPanelEntityFeature, @@ -52,14 +54,14 @@ class IAlarmPanel(CoordinatorEntity, AlarmControlPanelEntity): """Return the state of the device.""" return self.coordinator.state - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self.coordinator.ialarm.disarm() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self.coordinator.ialarm.arm_stay() - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self.coordinator.ialarm.arm_away() diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index ebab2592403..840dd2fed62 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -179,25 +179,25 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): return CodeFormat.NUMBER return CodeFormat.TEXT - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._check_code(code): return self.set_alarm_state(self._event_disarm, STATE_ALARM_DISARMED) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if self._code_arm_required and not self._check_code(code): return self.set_alarm_state(self._event_away, STATE_ALARM_ARMED_AWAY) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if self._code_arm_required and not self._check_code(code): return self.set_alarm_state(self._event_home, STATE_ALARM_ARMED_HOME) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" if self._code_arm_required and not self._check_code(code): return diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 812225ea407..425b813d18a 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -69,14 +69,14 @@ class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): state = None return state - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._device.set_away() - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._device.set_standby() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._device.set_home() diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index dd347336d9e..cfa81a816c3 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -302,7 +302,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """Whether the code is required for arm actions.""" return self._code_arm_required - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): return @@ -311,7 +311,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_HOME @@ -320,7 +320,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._update_state(STATE_ALARM_ARMED_HOME) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_AWAY @@ -329,7 +329,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._update_state(STATE_ALARM_ARMED_AWAY) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_NIGHT @@ -338,7 +338,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._update_state(STATE_ALARM_ARMED_NIGHT) - def alarm_arm_vacation(self, code=None): + def alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_VACATION @@ -347,7 +347,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._update_state(STATE_ALARM_ARMED_VACATION) - def alarm_arm_custom_bypass(self, code=None): + def alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_CUSTOM_BYPASS @@ -356,7 +356,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS) - def alarm_trigger(self, code=None): + def alarm_trigger(self, code: str | None = None) -> None: """ Send alarm trigger command. @@ -428,7 +428,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """Update state at a scheduled point in time.""" self.async_write_ha_state() - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() if state := await self.async_get_last_state(): diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 67675a44e22..719e85bf16c 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -327,7 +327,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): """Whether the code is required for arm actions.""" return self._code_arm_required - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): return @@ -336,7 +336,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._state_ts = dt_util.utcnow() self.schedule_update_ha_state() - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_HOME @@ -345,7 +345,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._update_state(STATE_ALARM_ARMED_HOME) - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_AWAY @@ -354,7 +354,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._update_state(STATE_ALARM_ARMED_AWAY) - def alarm_arm_night(self, code=None): + def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" if self._code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_NIGHT @@ -363,7 +363,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._update_state(STATE_ALARM_ARMED_NIGHT) - def alarm_trigger(self, code=None): + def alarm_trigger(self, code: str | None = None) -> None: """ Send alarm trigger command. @@ -426,7 +426,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): ATTR_POST_PENDING_STATE: self._state, } - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" async_track_state_change_event( self.hass, [self.entity_id], self._async_state_changed_listener diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 6bb7d9cd0d1..bd2495fd5d1 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -264,7 +264,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): code_required = self._config.get(CONF_CODE_ARM_REQUIRED) return code_required - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. This method is a coroutine. @@ -275,7 +275,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): payload = self._config[CONF_PAYLOAD_DISARM] await self._publish(code, payload) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command. This method is a coroutine. @@ -286,7 +286,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_HOME] await self._publish(code, action) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command. This method is a coroutine. @@ -297,7 +297,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_AWAY] await self._publish(code, action) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command. This method is a coroutine. @@ -308,7 +308,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_NIGHT] await self._publish(code, action) - async def async_alarm_arm_vacation(self, code=None): + async def async_alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command. This method is a coroutine. @@ -319,7 +319,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_VACATION] await self._publish(code, action) - async def async_alarm_arm_custom_bypass(self, code=None): + async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command. This method is a coroutine. @@ -330,7 +330,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): action = self._config[CONF_PAYLOAD_ARM_CUSTOM_BYPASS] await self._publish(code, action) - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Send trigger command. This method is a coroutine. diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 9fccee6f64f..0e80ac57e01 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -53,7 +53,7 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): self._name = name self._state = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( @@ -81,19 +81,19 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): """Return the state of the device.""" return self._state - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._client.disarm(code) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._client.arm_away(code) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._client.arm_home(code) - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Send trigger/panic command.""" await self._client.panic(code) diff --git a/homeassistant/components/nx584/alarm_control_panel.py b/homeassistant/components/nx584/alarm_control_panel.py index 2ef664cb6d4..735c8104ef5 100644 --- a/homeassistant/components/nx584/alarm_control_panel.py +++ b/homeassistant/components/nx584/alarm_control_panel.py @@ -119,7 +119,7 @@ class NX584Alarm(alarm.AlarmControlPanelEntity): """Return the state of the device.""" return self._state - def update(self): + def update(self) -> None: """Process new events from panel.""" try: part = self._alarm.list_partitions()[0] @@ -157,15 +157,15 @@ class NX584Alarm(alarm.AlarmControlPanelEntity): if flag == "Siren on": self._state = STATE_ALARM_TRIGGERED - def alarm_disarm(self, code=None): + def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._alarm.disarm(code) - def alarm_arm_home(self, code=None): + def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._alarm.arm("stay") - def alarm_arm_away(self, code=None): + def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._alarm.arm("exit") diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index 46ea95ba927..bd3deb6e2c9 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Minut Point.""" +from __future__ import annotations + import logging from homeassistant.components.alarm_control_panel import ( @@ -58,14 +60,14 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): self._async_unsub_hook_dispatcher_connect = None self._changed_by = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" await super().async_added_to_hass() self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect( self.hass, SIGNAL_WEBHOOK, self._webhook_event ) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" await super().async_will_remove_from_hass() if self._async_unsub_hook_dispatcher_connect: @@ -106,13 +108,13 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): """Return the user the last change was triggered by.""" return self._changed_by - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" status = await self._client.async_alarm_disarm(self._home_id) if status: self._home["alarm_status"] = "off" - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" status = await self._client.async_alarm_arm(self._home_id) if status: diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index f3578151acc..f814be0f2bd 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Risco alarms.""" +from __future__ import annotations + import logging from homeassistant.components.alarm_control_panel import ( @@ -138,26 +140,26 @@ class RiscoAlarm(AlarmControlPanelEntity, RiscoEntity): """Validate given code.""" return code == self._code - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if self._code_disarm_required and not self._validate_code(code): _LOGGER.warning("Wrong code entered for disarming") return await self._call_alarm_method("disarm") - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._arm(STATE_ALARM_ARMED_HOME, code) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._arm(STATE_ALARM_ARMED_AWAY, code) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" await self._arm(STATE_ALARM_ARMED_NIGHT, code) - async def async_alarm_arm_custom_bypass(self, code=None): + async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" await self._arm(STATE_ALARM_ARMED_CUSTOM_BYPASS, code) diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 4c036b4be85..054909cd3c2 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -74,7 +74,7 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): self._partition_id = partition_id self._satel = controller - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Update alarm status and register callbacks for future updates.""" _LOGGER.debug("Starts listening for panel messages") self._update_alarm_status() @@ -149,7 +149,7 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): """Return the state of the device.""" return self._state - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not code: _LOGGER.debug("Code was empty or None") @@ -166,14 +166,14 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): await asyncio.sleep(1) await self._satel.clear_alarm(code, [self._partition_id]) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" _LOGGER.debug("Arming away") if code: await self._satel.arm(code, [self._partition_id]) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" _LOGGER.debug("Arming home") diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index d519e6b7f2b..a1b4e2b2392 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -62,7 +62,7 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): self._area = area self._api = api - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call for adding new entities.""" self.async_on_remove( async_dispatcher_connect( @@ -97,22 +97,22 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): """Return the state of the device.""" return _get_alarm_state(self._area) - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.UNSET) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_A) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm home command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.PART_SET_B) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._api.change_mode(area=self._area, new_mode=AreaMode.FULL_SET) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 96cc5a8330e..74d794d703a 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -216,7 +216,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): ) self._state = None - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._template: self.add_template_attribute( @@ -239,25 +239,25 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): if optimistic_set: self.async_write_ha_state() - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Arm the panel to Away.""" await self._async_alarm_arm( STATE_ALARM_ARMED_AWAY, script=self._arm_away_script, code=code ) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Arm the panel to Home.""" await self._async_alarm_arm( STATE_ALARM_ARMED_HOME, script=self._arm_home_script, code=code ) - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Arm the panel to Night.""" await self._async_alarm_arm( STATE_ALARM_ARMED_NIGHT, script=self._arm_night_script, code=code ) - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Disarm the panel.""" await self._async_alarm_arm( STATE_ALARM_DISARMED, script=self._disarm_script, code=code diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index bf7a1ae410b..6ed29dbcad3 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -1,4 +1,6 @@ """Interfaces with TotalConnect alarm control panels.""" +from __future__ import annotations + from total_connect_client import ArmingHelper from total_connect_client.exceptions import BadResultCodeError, UsercodeInvalid @@ -163,7 +165,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Return the state attributes of the device.""" return self._extra_state_attributes - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" try: await self.hass.async_add_executor_job(self._disarm) @@ -182,7 +184,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Disarm synchronous.""" ArmingHelper(self._partition).disarm() - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" try: await self.hass.async_add_executor_job(self._arm_home) @@ -201,7 +203,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm home synchronous.""" ArmingHelper(self._partition).arm_stay() - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" try: await self.hass.async_add_executor_job(self._arm_away) @@ -220,7 +222,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm away synchronous.""" ArmingHelper(self._partition).arm_away() - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" try: await self.hass.async_add_executor_job(self._arm_night) @@ -239,7 +241,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm night synchronous.""" ArmingHelper(self._partition).arm_stay_night() - async def async_alarm_arm_home_instant(self, code=None): + async def async_alarm_arm_home_instant(self, code: str | None = None) -> None: """Send arm home instant command.""" try: await self.hass.async_add_executor_job(self._arm_home_instant) @@ -258,7 +260,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): """Arm home instant synchronous.""" ArmingHelper(self._partition).arm_stay_instant() - async def async_alarm_arm_away_instant(self, code=None): + async def async_alarm_arm_away_instant(self, code: str | None = None) -> None: """Send arm away instant command.""" try: await self.hass.async_add_executor_job(self._arm_away_instant) diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index 25c995b2b24..be7daf5e077 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Xiomi Gateway alarm control panels.""" +from __future__ import annotations + from functools import partial import logging @@ -110,19 +112,19 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): except DeviceException as exc: _LOGGER.error(mask_error, exc) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Turn on.""" await self._try_command( "Turning the alarm on failed: %s", self._gateway.alarm.on ) - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Turn off.""" await self._try_command( "Turning the alarm off failed: %s", self._gateway.alarm.off ) - async def async_update(self): + async def async_update(self) -> None: """Fetch state from the device.""" try: state = await self.hass.async_add_executor_job(self._gateway.alarm.status) From f66acf293f080017a784c98523e59c3a5f10d190 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:02:16 +0200 Subject: [PATCH 734/947] Adjust type hints in prosegur alarm (#74093) * Adjust type hints in prosegur alarm * Adjust hint --- .../components/prosegur/alarm_control_panel.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/prosegur/alarm_control_panel.py b/homeassistant/components/prosegur/alarm_control_panel.py index e7176b241e5..133c182e2cc 100644 --- a/homeassistant/components/prosegur/alarm_control_panel.py +++ b/homeassistant/components/prosegur/alarm_control_panel.py @@ -1,4 +1,6 @@ """Support for Prosegur alarm control panels.""" +from __future__ import annotations + import logging from pyprosegur.auth import Auth @@ -44,12 +46,12 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_HOME ) + _installation: Installation def __init__(self, contract: str, auth: Auth) -> None: """Initialize the Prosegur alarm panel.""" self._changed_by = None - self._installation = None self.contract = contract self._auth = auth @@ -57,7 +59,7 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._attr_name = f"contract {self.contract}" self._attr_unique_id = self.contract - async def async_update(self): + async def async_update(self) -> None: """Update alarm status.""" try: @@ -70,14 +72,14 @@ class ProsegurAlarm(alarm.AlarmControlPanelEntity): self._attr_state = STATE_MAPPING.get(self._installation.status) self._attr_available = True - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._installation.disarm(self._auth) - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm away command.""" await self._installation.arm_partially(self._auth) - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" await self._installation.arm(self._auth) From 319ef38d346ed5ce87b7f8b11054a8c484597e7f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:02:57 +0200 Subject: [PATCH 735/947] Add AlarmControlPanelEntity to pylint checks (#74091) --- pylint/plugins/hass_enforce_type_hints.py | 87 +++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 711f40b26ef..5c1a35c757a 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -572,6 +572,93 @@ _TOGGLE_ENTITY_MATCH: list[TypeHintMatch] = [ ), ] _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { + "alarm_control_panel": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="AlarmControlPanelEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["str", None], + ), + TypeHintMatch( + function_name="code_format", + return_type=["CodeFormat", None], + ), + TypeHintMatch( + function_name="changed_by", + return_type=["str", None], + ), + TypeHintMatch( + function_name="code_arm_required", + return_type="bool", + ), + TypeHintMatch( + function_name="supported_features", + return_type="int", + ), + TypeHintMatch( + function_name="alarm_disarm", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_home", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_away", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_night", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_vacation", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_trigger", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + TypeHintMatch( + function_name="alarm_arm_custom_bypass", + named_arg_types={ + "code": "str | None", + }, + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], "cover": [ ClassTypeHintMatch( base_class="Entity", From 35df012b6e59f5cb36309ae5485d952c0d9ba01c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:06:05 +0200 Subject: [PATCH 736/947] Fix reauth step in nest (#74090) --- homeassistant/components/nest/config_flow.py | 9 ++------- tests/components/nest/test_config_flow_sdm.py | 12 ------------ 2 files changed, 2 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 479a54edbc7..2e89f7970fa 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -11,7 +11,7 @@ from __future__ import annotations import asyncio from collections import OrderedDict -from collections.abc import Iterable +from collections.abc import Iterable, Mapping from enum import Enum import logging import os @@ -218,14 +218,9 @@ class NestFlowHandler( return await self.async_step_finish() return await self.async_step_pubsub() - async def async_step_reauth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - if user_input is None: - _LOGGER.error("Reauth invoked with empty config entry data") - return self.async_abort(reason="missing_configuration") self._data.update(user_input) return await self.async_step_reauth_confirm() diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index 53a2d9cf2b6..b2ef95b138e 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -505,18 +505,6 @@ async def test_reauth_multiple_config_entries( assert entry.data.get("extra_data") -async def test_reauth_missing_config_entry(hass, setup_platform): - """Test the reauth flow invoked missing existing data.""" - await setup_platform() - - # Invoke the reauth flow with no existing data - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH}, data=None - ) - assert result["type"] == "abort" - assert result["reason"] == "missing_configuration" - - @pytest.mark.parametrize( "nest_test_config,auth_implementation", [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] ) From c19a8ef8e00f4a572042c51bbab11610b0120ea5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:08:36 +0200 Subject: [PATCH 737/947] Enforce flow-handler result type hint for step_* (#72834) --- pylint/plugins/hass_enforce_type_hints.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index 5c1a35c757a..e1517ede1ff 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -37,6 +37,8 @@ class TypeHintMatch: self.function_name == node.name or self.has_async_counterpart and node.name == f"async_{self.function_name}" + or self.function_name.endswith("*") + and node.name.startswith(self.function_name[:-1]) ) @@ -370,6 +372,16 @@ _FUNCTION_MATCH: dict[str, list[TypeHintMatch]] = { _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { "config_flow": [ + ClassTypeHintMatch( + base_class="FlowHandler", + matches=[ + TypeHintMatch( + function_name="async_step_*", + arg_types={}, + return_type="FlowResult", + ), + ], + ), ClassTypeHintMatch( base_class="ConfigFlow", matches=[ From 9a613aeb96c9887e5394345775816fab184b4cfc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 10:22:46 +0200 Subject: [PATCH 738/947] Modify behavior of media_player groups (#74056) --- .../components/group/media_player.py | 30 ++++++++++----- tests/components/group/test_media_player.py | 38 ++++++++++--------- 2 files changed, 42 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 46c019dbc7c..e0cbf84a693 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -103,6 +103,8 @@ async def async_setup_entry( class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" + _attr_available: bool = False + def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a Media Group entity.""" self._name = name @@ -390,19 +392,29 @@ class MediaPlayerGroup(MediaPlayerEntity): @callback def async_update_state(self) -> None: """Query all members and determine the media group state.""" - states = [self.hass.states.get(entity) for entity in self._entities] - states_values = [state.state for state in states if state is not None] - off_values = STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN + states = [ + state.state + for entity_id in self._entities + if (state := self.hass.states.get(entity_id)) is not None + ] - if states_values: - if states_values.count(states_values[0]) == len(states_values): - self._state = states_values[0] - elif any(state for state in states_values if state not in off_values): + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state != STATE_UNAVAILABLE for state in states) + + valid_state = any( + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states + ) + if not valid_state: + # Set as unknown if all members are unknown or unavailable + self._state = None + else: + off_values = (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN) + if states.count(states[0]) == len(states): + self._state = states[0] + elif any(state for state in states if state not in off_values): self._state = STATE_ON else: self._state = STATE_OFF - else: - self._state = None supported_features = 0 if self._features[KEY_CLEAR_PLAYLIST]: diff --git a/tests/components/group/test_media_player.py b/tests/components/group/test_media_player.py index 85e75ffcba6..5d0d52ae3ac 100644 --- a/tests/components/group/test_media_player.py +++ b/tests/components/group/test_media_player.py @@ -126,8 +126,24 @@ async def test_state_reporting(hass): await hass.async_start() await hass.async_block_till_done() - # Initial state with no group member in the state machine -> unknown - assert hass.states.get("media_player.media_group").state == STATE_UNKNOWN + # Initial state with no group member in the state machine -> unavailable + assert hass.states.get("media_player.media_group").state == STATE_UNAVAILABLE + + # All group members unavailable -> unavailable + hass.states.async_set("media_player.player_1", STATE_UNAVAILABLE) + hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_UNAVAILABLE + + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in ( + STATE_UNAVAILABLE, + STATE_UNKNOWN, + ): + hass.states.async_set("media_player.player_1", state_1) + hass.states.async_set("media_player.player_2", STATE_UNKNOWN) + await hass.async_block_till_done() + assert hass.states.get("media_player.media_group").state == STATE_UNKNOWN # All group members buffering -> buffering # All group members idle -> idle @@ -156,30 +172,18 @@ async def test_state_reporting(hass): await hass.async_block_till_done() assert hass.states.get("media_player.media_group").state == STATE_ON + # Otherwise off for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): hass.states.async_set("media_player.player_1", state_1) hass.states.async_set("media_player.player_2", STATE_OFF) await hass.async_block_till_done() assert hass.states.get("media_player.media_group").state == STATE_OFF - # Otherwise off - for state_1 in (STATE_OFF, STATE_UNKNOWN): - hass.states.async_set("media_player.player_1", state_1) - hass.states.async_set("media_player.player_2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_OFF - - for state_1 in (STATE_OFF, STATE_UNAVAILABLE): - hass.states.async_set("media_player.player_1", state_1) - hass.states.async_set("media_player.player_2", STATE_UNKNOWN) - await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_OFF - - # All group members removed from the state machine -> unknown + # All group members removed from the state machine -> unavailable hass.states.async_remove("media_player.player_1") hass.states.async_remove("media_player.player_2") await hass.async_block_till_done() - assert hass.states.get("media_player.media_group").state == STATE_UNKNOWN + assert hass.states.get("media_player.media_group").state == STATE_UNAVAILABLE async def test_supported_features(hass): From 8328f9b623753733b8309c09a95580acf375dddf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:26:36 +0200 Subject: [PATCH 739/947] Cleanup async_update in smartthings cover (#74040) --- homeassistant/components/smartthings/cover.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 59f6e09df19..0ff3a82d788 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -107,20 +107,17 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): # Do not set_status=True as device will report progress. await self._device.set_level(kwargs[ATTR_POSITION], 0) - async def async_update(self): + async def async_update(self) -> None: """Update the attrs of the cover.""" - value = None if Capability.door_control in self._device.capabilities: self._device_class = CoverDeviceClass.DOOR - value = self._device.status.door + self._state = VALUE_TO_STATE.get(self._device.status.door) elif Capability.window_shade in self._device.capabilities: self._device_class = CoverDeviceClass.SHADE - value = self._device.status.window_shade + self._state = VALUE_TO_STATE.get(self._device.status.window_shade) elif Capability.garage_door_control in self._device.capabilities: self._device_class = CoverDeviceClass.GARAGE - value = self._device.status.door - - self._state = VALUE_TO_STATE.get(value) + self._state = VALUE_TO_STATE.get(self._device.status.door) self._state_attrs = {} battery = self._device.status.attributes[Attribute.battery].value From 824de2ef4cdcdd59e8827ebffdaf26c0ffb58904 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 10:29:56 +0200 Subject: [PATCH 740/947] Modify behavior of lock groups (#74055) --- homeassistant/components/group/lock.py | 2 +- tests/components/group/test_lock.py | 28 +++++++++----------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/group/lock.py b/homeassistant/components/group/lock.py index fe9503137c6..610e15f3ecc 100644 --- a/homeassistant/components/group/lock.py +++ b/homeassistant/components/group/lock.py @@ -166,7 +166,7 @@ class LockGroup(GroupEntity, LockEntity): if (state := self.hass.states.get(entity_id)) is not None ] - valid_state = all( + valid_state = any( state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) diff --git a/tests/components/group/test_lock.py b/tests/components/group/test_lock.py index e76e47577c6..4b12bcfbd7c 100644 --- a/tests/components/group/test_lock.py +++ b/tests/components/group/test_lock.py @@ -90,28 +90,10 @@ async def test_state_reporting(hass): await hass.async_block_till_done() assert hass.states.get("lock.lock_group").state == STATE_UNAVAILABLE - # At least one member unknown or unavailable -> group unknown + # The group state is unknown if all group members are unknown or unavailable. for state_1 in ( - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, - STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, - ): - hass.states.async_set("lock.test1", state_1) - hass.states.async_set("lock.test2", STATE_UNAVAILABLE) - await hass.async_block_till_done() - assert hass.states.get("lock.lock_group").state == STATE_UNKNOWN - - for state_1 in ( - STATE_JAMMED, - STATE_LOCKED, - STATE_LOCKING, STATE_UNAVAILABLE, STATE_UNKNOWN, - STATE_UNLOCKED, - STATE_UNLOCKING, ): hass.states.async_set("lock.test1", state_1) hass.states.async_set("lock.test2", STATE_UNKNOWN) @@ -123,6 +105,8 @@ async def test_state_reporting(hass): STATE_JAMMED, STATE_LOCKED, STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, STATE_UNLOCKED, STATE_UNLOCKING, ): @@ -135,6 +119,8 @@ async def test_state_reporting(hass): for state_1 in ( STATE_LOCKED, STATE_LOCKING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, STATE_UNLOCKED, STATE_UNLOCKING, ): @@ -146,6 +132,8 @@ async def test_state_reporting(hass): # At least one member unlocking -> group unlocking for state_1 in ( STATE_LOCKED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, STATE_UNLOCKED, STATE_UNLOCKING, ): @@ -157,6 +145,8 @@ async def test_state_reporting(hass): # At least one member unlocked -> group unlocked for state_1 in ( STATE_LOCKED, + STATE_UNAVAILABLE, + STATE_UNKNOWN, STATE_UNLOCKED, ): hass.states.async_set("lock.test1", state_1) From 6eeb1855ff531cd7c2aff5ad16e90624d38266da Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 28 Jun 2022 04:32:50 -0400 Subject: [PATCH 741/947] Remove entities from Alexa when disabling Alexa (#73999) Co-authored-by: Martin Hjelmare --- .../components/cloud/alexa_config.py | 69 +++++++++++++------ homeassistant/components/cloud/prefs.py | 4 ++ tests/components/cloud/test_alexa_config.py | 36 +++++++--- 3 files changed, 78 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index cf52b458a28..1e59c9a6512 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -1,5 +1,8 @@ """Alexa configuration for Home Assistant Cloud.""" +from __future__ import annotations + import asyncio +from collections.abc import Callable from contextlib import suppress from datetime import timedelta from http import HTTPStatus @@ -24,7 +27,15 @@ from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .const import CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE +from .const import ( + CONF_ENTITY_CONFIG, + CONF_FILTER, + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + PREF_SHOULD_EXPOSE, +) from .prefs import CloudPreferences _LOGGER = logging.getLogger(__name__) @@ -54,8 +65,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._token = None self._token_valid = None self._cur_entity_prefs = prefs.alexa_entity_configs - self._cur_default_expose = prefs.alexa_default_expose - self._alexa_sync_unsub = None + self._alexa_sync_unsub: Callable[[], None] | None = None self._endpoint = None @property @@ -75,7 +85,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): @property def should_report_state(self): """Return if states should be proactively reported.""" - return self._prefs.alexa_report_state and self.authorized + return ( + self._prefs.alexa_enabled + and self._prefs.alexa_report_state + and self.authorized + ) @property def endpoint(self): @@ -179,7 +193,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._token_valid = utcnow() + timedelta(seconds=body["expires_in"]) return self._token - async def _async_prefs_updated(self, prefs): + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" if not self._cloud.is_logged_in: if self.is_reporting_states: @@ -190,6 +204,8 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._alexa_sync_unsub = None return + updated_prefs = prefs.last_updated + if ( ALEXA_DOMAIN not in self.hass.config.components and self.enabled @@ -211,28 +227,30 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): await self.async_sync_entities() return - # If user has filter in config.yaml, don't sync. - if not self._config[CONF_FILTER].empty_filter: - return - - # If entity prefs are the same, don't sync. - if ( - self._cur_entity_prefs is prefs.alexa_entity_configs - and self._cur_default_expose is prefs.alexa_default_expose + # Nothing to do if no Alexa related things have changed + if not any( + key in updated_prefs + for key in ( + PREF_ALEXA_DEFAULT_EXPOSE, + PREF_ALEXA_ENTITY_CONFIGS, + PREF_ALEXA_REPORT_STATE, + PREF_ENABLE_ALEXA, + ) ): return - if self._alexa_sync_unsub: - self._alexa_sync_unsub() - self._alexa_sync_unsub = None + # If we update just entity preferences, delay updating + # as we might update more + if updated_prefs == {PREF_ALEXA_ENTITY_CONFIGS}: + if self._alexa_sync_unsub: + self._alexa_sync_unsub() - if self._cur_default_expose is not prefs.alexa_default_expose: - await self.async_sync_entities() + self._alexa_sync_unsub = async_call_later( + self.hass, SYNC_DELAY, self._sync_prefs + ) return - self._alexa_sync_unsub = async_call_later( - self.hass, SYNC_DELAY, self._sync_prefs - ) + await self.async_sync_entities() async def _sync_prefs(self, _now): """Sync the updated preferences to Alexa.""" @@ -243,9 +261,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): seen = set() to_update = [] to_remove = [] + is_enabled = self.enabled for entity_id, info in old_prefs.items(): seen.add(entity_id) + + if not is_enabled: + to_remove.append(entity_id) + old_expose = info.get(PREF_SHOULD_EXPOSE) if entity_id in new_prefs: @@ -291,8 +314,10 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): to_update = [] to_remove = [] + is_enabled = self.enabled + for entity in alexa_entities.async_get_entities(self.hass, self): - if self.should_expose(entity.entity_id): + if is_enabled and self.should_expose(entity.entity_id): to_update.append(entity.entity_id) else: to_remove.append(entity.entity_id) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 275c2a56326..17ec00026bc 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -50,6 +50,7 @@ class CloudPreferences: self._store = Store(hass, STORAGE_VERSION, STORAGE_KEY) self._prefs = None self._listeners = [] + self.last_updated: set[str] = set() async def async_initialize(self): """Finish initializing the preferences.""" @@ -308,6 +309,9 @@ class CloudPreferences: async def _save_prefs(self, prefs): """Save preferences to disk.""" + self.last_updated = { + key for key, value in prefs.items() if value != self._prefs.get(key) + } self._prefs = prefs await self._store.async_save(self._prefs) diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py index 465ff7dd3d4..4e0df3c8ee3 100644 --- a/tests/components/cloud/test_alexa_config.py +++ b/tests/components/cloud/test_alexa_config.py @@ -9,7 +9,6 @@ from homeassistant.components.cloud import ALEXA_SCHEMA, alexa_config from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import EntityCategory -from homeassistant.util.dt import utcnow from tests.common import async_fire_time_changed, mock_registry @@ -270,10 +269,7 @@ async def test_alexa_config_fail_refresh_token( @contextlib.contextmanager def patch_sync_helper(): - """Patch sync helper. - - In Py3.7 this would have been an async context manager. - """ + """Patch sync helper.""" to_update = [] to_remove = [] @@ -291,21 +287,32 @@ def patch_sync_helper(): async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): """Test Alexa config responds to updating exposed entities.""" + hass.states.async_set("binary_sensor.door", "on") + hass.states.async_set( + "sensor.temp", + "23", + {"device_class": "temperature", "unit_of_measurement": "°C"}, + ) + hass.states.async_set("light.kitchen", "off") + await cloud_prefs.async_update( + alexa_enabled=True, alexa_report_state=False, ) - await alexa_config.CloudAlexaConfig( + conf = alexa_config.CloudAlexaConfig( hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, cloud_stub - ).async_initialize() + ) + await conf.async_initialize() with patch_sync_helper() as (to_update, to_remove): await cloud_prefs.async_update_alexa_entity_config( entity_id="light.kitchen", should_expose=True ) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow()) + async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() + assert conf._alexa_sync_unsub is None assert to_update == ["light.kitchen"] assert to_remove == [] @@ -320,12 +327,23 @@ async def test_alexa_update_expose_trigger_sync(hass, cloud_prefs, cloud_stub): entity_id="sensor.temp", should_expose=True ) await hass.async_block_till_done() - async_fire_time_changed(hass, utcnow()) + async_fire_time_changed(hass, fire_all=True) await hass.async_block_till_done() + assert conf._alexa_sync_unsub is None assert sorted(to_update) == ["binary_sensor.door", "sensor.temp"] assert to_remove == ["light.kitchen"] + with patch_sync_helper() as (to_update, to_remove): + await cloud_prefs.async_update( + alexa_enabled=False, + ) + await hass.async_block_till_done() + + assert conf._alexa_sync_unsub is None + assert to_update == [] + assert to_remove == ["binary_sensor.door", "sensor.temp", "light.kitchen"] + async def test_alexa_entity_registry_sync(hass, mock_cloud_login, cloud_prefs): """Test Alexa config responds to entity registry.""" From 28c1a5c09f7499c7b6b75d28831edde82d3b5a97 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 10:52:41 +0200 Subject: [PATCH 742/947] Enforce config-flow type hints for reauth step (#72830) --- pylint/plugins/hass_enforce_type_hints.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index e1517ede1ff..f3c7a01a10f 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -421,6 +421,13 @@ _CLASS_MATCH: dict[str, list[ClassTypeHintMatch]] = { }, return_type="FlowResult", ), + TypeHintMatch( + function_name="async_step_reauth", + arg_types={ + 1: "Mapping[str, Any]", + }, + return_type="FlowResult", + ), TypeHintMatch( function_name="async_step_ssdp", arg_types={ From 7d709c074d6564376193e307af2eeb59984ba472 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 10:58:52 +0200 Subject: [PATCH 743/947] Add support for unavailable and unknown to fan groups (#74054) --- homeassistant/components/group/fan.py | 31 ++++++++--- tests/components/group/test_fan.py | 79 ++++++++++++++------------- 2 files changed, 66 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4badbe6df51..7d09c9573b5 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -32,6 +32,8 @@ from homeassistant.const import ( CONF_NAME, CONF_UNIQUE_ID, STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -98,6 +100,7 @@ async def async_setup_entry( class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" + _attr_available: bool = False _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: @@ -109,7 +112,7 @@ class FanGroup(GroupEntity, FanEntity): self._direction = None self._supported_features = 0 self._speed_count = 100 - self._is_on = False + self._is_on: bool | None = False self._attr_name = name self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entities} self._attr_unique_id = unique_id @@ -125,7 +128,7 @@ class FanGroup(GroupEntity, FanEntity): return self._speed_count @property - def is_on(self) -> bool: + def is_on(self) -> bool | None: """Return true if the entity is on.""" return self._is_on @@ -270,11 +273,25 @@ class FanGroup(GroupEntity, FanEntity): """Update state and attributes.""" self._attr_assumed_state = False - on_states: list[State] = list( - filter(None, [self.hass.states.get(x) for x in self._entities]) + states = [ + state + for entity_id in self._entities + if (state := self.hass.states.get(entity_id)) is not None + ] + self._attr_assumed_state |= not states_equal(states) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) + + valid_state = any( + state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states ) - self._is_on = any(state.state == STATE_ON for state in on_states) - self._attr_assumed_state |= not states_equal(on_states) + if not valid_state: + # Set as unknown if all members are unknown or unavailable + self._is_on = None + else: + # Set as ON if any member is ON + self._is_on = any(state.state == STATE_ON for state in states) percentage_states = self._async_states_by_support_flag( FanEntityFeature.SET_SPEED @@ -306,5 +323,5 @@ class FanGroup(GroupEntity, FanEntity): ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 ) self._attr_assumed_state |= any( - state.attributes.get(ATTR_ASSUMED_STATE) for state in on_states + state.attributes.get(ATTR_ASSUMED_STATE) for state in states ) diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 8aefd12c93a..bb2cf311191 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -119,15 +119,45 @@ async def test_state(hass, setup_comp): Otherwise, the group state is off. """ state = hass.states.get(FAN_GROUP) - # No entity has a valid state -> group state off - assert state.state == STATE_OFF + # No entity has a valid state -> group state unavailable + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert ATTR_ENTITY_ID not in state.attributes + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # Test group members exposed as attribute + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) assert state.attributes[ATTR_ENTITY_ID] == [ *FULL_FAN_ENTITY_IDS, *LIMITED_FAN_ENTITY_IDS, ] - assert ATTR_ASSUMED_STATE not in state.attributes - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + + # All group members unavailable -> unavailable + hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_UNAVAILABLE) + hass.states.async_set(PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNAVAILABLE) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_UNAVAILABLE + + # The group state is unknown if all group members are unknown or unavailable. + for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): + print("meh") + hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) + hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) + hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) + hass.states.async_set( + PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNKNOWN, {} + ) + await hass.async_block_till_done() + state = hass.states.get(FAN_GROUP) + assert state.state == STATE_UNKNOWN # The group state is off if all group members are off, unknown or unavailable. for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): @@ -141,32 +171,6 @@ async def test_state(hass, setup_comp): state = hass.states.get(FAN_GROUP) assert state.state == STATE_OFF - for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) - hass.states.async_set( - PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNAVAILABLE, {} - ) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF - - for state_1 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_2 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_3 in (STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN): - hass.states.async_set(CEILING_FAN_ENTITY_ID, state_1, {}) - hass.states.async_set(LIVING_ROOM_FAN_ENTITY_ID, state_2, {}) - hass.states.async_set(PERCENTAGE_FULL_FAN_ENTITY_ID, state_3, {}) - hass.states.async_set( - PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_UNKNOWN, {} - ) - await hass.async_block_till_done() - state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF - # At least one member on -> group on for state_1 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): for state_2 in (STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN): @@ -183,7 +187,7 @@ async def test_state(hass, setup_comp): hass.states.async_remove(PERCENTAGE_LIMITED_FAN_ENTITY_ID) await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNKNOWN assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -193,7 +197,7 @@ async def test_state(hass, setup_comp): hass.states.async_remove(PERCENTAGE_FULL_FAN_ENTITY_ID) await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 @@ -208,12 +212,9 @@ async def test_state(hass, setup_comp): async def test_attributes(hass, setup_comp): """Test handling of state attributes.""" state = hass.states.get(FAN_GROUP) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME - assert state.attributes[ATTR_ENTITY_ID] == [ - *FULL_FAN_ENTITY_IDS, - *LIMITED_FAN_ENTITY_IDS, - ] + assert ATTR_ENTITY_ID not in state.attributes assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 hass.states.async_set(CEILING_FAN_ENTITY_ID, STATE_ON, {}) @@ -223,6 +224,10 @@ async def test_attributes(hass, setup_comp): await hass.async_block_till_done() state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON + assert state.attributes[ATTR_ENTITY_ID] == [ + *FULL_FAN_ENTITY_IDS, + *LIMITED_FAN_ENTITY_IDS, + ] # Add Entity that supports speed hass.states.async_set( From f66fc65d0b349ea52e495976800154450b954bca Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 11:01:14 +0200 Subject: [PATCH 744/947] Migrate environment_canada to native_* (#74048) --- .../components/environment_canada/weather.py | 45 ++++++++++--------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index b79323b0462..40706ffb6c1 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -17,14 +17,19 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_KILOMETERS, + PRESSURE_KPA, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -63,6 +68,11 @@ async def async_setup_entry( class ECWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = PRESSURE_KPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, coordinator, hourly): """Initialize Environment Canada weather.""" super().__init__(coordinator) @@ -78,7 +88,7 @@ class ECWeather(CoordinatorEntity, WeatherEntity): self._hourly = hourly @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" if ( temperature := self.ec_data.conditions.get("temperature", {}).get("value") @@ -92,11 +102,6 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return float(temperature) return None - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def humidity(self): """Return the humidity.""" @@ -105,7 +110,7 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return None @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" if self.ec_data.conditions.get("wind_speed", {}).get("value"): return float(self.ec_data.conditions["wind_speed"]["value"]) @@ -119,14 +124,14 @@ class ECWeather(CoordinatorEntity, WeatherEntity): return None @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" if self.ec_data.conditions.get("pressure", {}).get("value"): - return 10 * float(self.ec_data.conditions["pressure"]["value"]) + return float(self.ec_data.conditions["pressure"]["value"]) return None @property - def visibility(self): + def native_visibility(self): """Return the visibility.""" if self.ec_data.conditions.get("visibility", {}).get("value"): return float(self.ec_data.conditions["visibility"]["value"]) @@ -175,16 +180,16 @@ def get_forecast(ec_data, hourly): if half_days[0]["temperature_class"] == "high": today.update( { - ATTR_FORECAST_TEMP: int(half_days[0]["temperature"]), - ATTR_FORECAST_TEMP_LOW: int(half_days[1]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: int(half_days[0]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[1]["temperature"]), } ) half_days = half_days[2:] else: today.update( { - ATTR_FORECAST_TEMP: None, - ATTR_FORECAST_TEMP_LOW: int(half_days[0]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: None, + ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[0]["temperature"]), } ) half_days = half_days[1:] @@ -197,8 +202,8 @@ def get_forecast(ec_data, hourly): ATTR_FORECAST_TIME: ( dt.now() + datetime.timedelta(days=day) ).isoformat(), - ATTR_FORECAST_TEMP: int(half_days[high]["temperature"]), - ATTR_FORECAST_TEMP_LOW: int(half_days[low]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: int(half_days[high]["temperature"]), + ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[low]["temperature"]), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(half_days[high]["icon_code"]) ), @@ -213,7 +218,7 @@ def get_forecast(ec_data, hourly): forecast_array.append( { ATTR_FORECAST_TIME: hour["period"].isoformat(), - ATTR_FORECAST_TEMP: int(hour["temperature"]), + ATTR_FORECAST_NATIVE_TEMP: int(hour["temperature"]), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(hour["icon_code"]) ), From 37e8f113d41c89e6287c8b2b51602a8f473a9221 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 11:02:13 +0200 Subject: [PATCH 745/947] Migrate zamg to native_* (#74034) --- homeassistant/components/zamg/weather.py | 26 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 6a5d7ccdf81..2bf4a5b39f6 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -14,7 +14,14 @@ from homeassistant.components.weather import ( PLATFORM_SCHEMA, WeatherEntity, ) -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + LENGTH_MILLIMETERS, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -80,6 +87,12 @@ def setup_platform( class ZamgWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = ( + LENGTH_MILLIMETERS # API reports l/m², equivalent to mm + ) + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, zamg_data, stationname=None): """Initialise the platform with a data instance and station name.""" self.zamg_data = zamg_data @@ -104,17 +117,12 @@ class ZamgWeather(WeatherEntity): return ATTRIBUTION @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" return self.zamg_data.get_data(ATTR_WEATHER_TEMPERATURE) @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.zamg_data.get_data(ATTR_WEATHER_PRESSURE) @@ -124,7 +132,7 @@ class ZamgWeather(WeatherEntity): return self.zamg_data.get_data(ATTR_WEATHER_HUMIDITY) @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self.zamg_data.get_data(ATTR_WEATHER_WIND_SPEED) From 734b99e6ac278d0404e40d172bb21d67330e9769 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 11:07:46 +0200 Subject: [PATCH 746/947] Improve type hints in zha alarm (#74094) * Improve type hints in zha alarm * Allow None code --- .../components/zha/alarm_control_panel.py | 27 +++++++------------ .../components/zha/core/channels/security.py | 2 +- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index 15d27f95c5c..ee37a345e17 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -81,6 +81,7 @@ async def async_setup_entry( class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): """Entity for ZHA alarm control devices.""" + _attr_code_format = CodeFormat.TEXT _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -103,7 +104,7 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): cfg_entry, ZHA_ALARM_OPTIONS, CONF_ALARM_FAILED_TRIES, 3 ) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" await super().async_added_to_hass() self.async_accept_signal( @@ -119,45 +120,35 @@ class ZHAAlarmControlPanel(ZhaEntity, AlarmControlPanelEntity): self.async_write_ha_state() @property - def code_format(self): - """Regex for code format or None if no code is required.""" - return CodeFormat.TEXT - - @property - def changed_by(self): - """Last change triggered by.""" - return None - - @property - def code_arm_required(self): + def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self._channel.code_required_arm_actions - async def async_alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" self._channel.arm(IasAce.ArmMode.Disarm, code, 0) self.async_write_ha_state() - async def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" self._channel.arm(IasAce.ArmMode.Arm_Day_Home_Only, code, 0) self.async_write_ha_state() - async def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" self._channel.arm(IasAce.ArmMode.Arm_All_Zones, code, 0) self.async_write_ha_state() - async def async_alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" self._channel.arm(IasAce.ArmMode.Arm_Night_Sleep_Only, code, 0) self.async_write_ha_state() - async def async_alarm_trigger(self, code=None): + async def async_alarm_trigger(self, code: str | None = None) -> None: """Send alarm trigger command.""" self.async_write_ha_state() @property - def state(self): + def state(self) -> str | None: """Return the state of the entity.""" return IAS_ACE_STATE_MAP.get(self._channel.armed_state) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 41e65019415..9463a0351c1 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -92,7 +92,7 @@ class IasAce(ZigbeeChannel): ) self.command_map[command_id](*args) - def arm(self, arm_mode: int, code: str, zone_id: int): + def arm(self, arm_mode: int, code: str | None, zone_id: int) -> None: """Handle the IAS ACE arm command.""" mode = AceCluster.ArmMode(arm_mode) From 38759bb98bff4bce5a6e52c694496043a251b5d7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 11:08:31 +0200 Subject: [PATCH 747/947] Adjust tilt_position method in esphome cover (#74041) --- homeassistant/components/esphome/cover.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py index ab8b7af2185..97ae22dcccc 100644 --- a/homeassistant/components/esphome/cover.py +++ b/homeassistant/components/esphome/cover.py @@ -125,8 +125,9 @@ class EsphomeCover(EsphomeEntity[CoverInfo, CoverState], CoverEntity): """Close the cover tilt.""" await self._client.cover_command(key=self._static_info.key, tilt=0.0) - async def async_set_cover_tilt_position(self, **kwargs: int) -> None: + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: """Move the cover tilt to a specific position.""" + tilt_position: int = kwargs[ATTR_TILT_POSITION] await self._client.cover_command( - key=self._static_info.key, tilt=kwargs[ATTR_TILT_POSITION] / 100 + key=self._static_info.key, tilt=tilt_position / 100 ) From af71c250d559d00f10f9be5f42fea50b83e00535 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 11:10:31 +0200 Subject: [PATCH 748/947] Use attributes in concord232 alarm (#74097) --- .../concord232/alarm_control_panel.py | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 4b46d1bf98c..de5d4495a85 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -72,6 +72,8 @@ def setup_platform( class Concord232Alarm(alarm.AlarmControlPanelEntity): """Representation of the Concord232-based alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -80,29 +82,13 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): def __init__(self, url, name, code, mode): """Initialize the Concord232 alarm panel.""" - self._state = None - self._name = name + self._attr_name = name self._code = code self._mode = mode self._url = url self._alarm = concord232_client.Client(self._url) self._alarm.partitions = self._alarm.list_partitions() - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def code_format(self): - """Return the characters if code is defined.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - def update(self) -> None: """Update values from API.""" try: @@ -118,11 +104,11 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): return if part["arming_level"] == "Off": - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif "Home" in part["arming_level"]: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME else: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" @@ -152,7 +138,7 @@ class Concord232Alarm(alarm.AlarmControlPanelEntity): if isinstance(self._code, str): alarm_code = self._code else: - alarm_code = self._code.render(from_state=self._state, to_state=state) + alarm_code = self._code.render(from_state=self._attr_state, to_state=state) check = not alarm_code or code == alarm_code if not check: _LOGGER.warning("Invalid code given for %s", state) From ae63cd8677c029bb368ab79ed688c479b55b6e5a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 11:12:14 +0200 Subject: [PATCH 749/947] Add support for unavailable to cover groups (#74053) --- homeassistant/components/group/cover.py | 24 ++++++++--- tests/components/group/test_cover.py | 57 ++++++++++++++----------- 2 files changed, 49 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index c2cd3c6e9d2..a867c92d956 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -35,6 +35,8 @@ from homeassistant.const import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNAVAILABLE, + STATE_UNKNOWN, ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, entity_registry as er @@ -98,6 +100,7 @@ async def async_setup_entry( class CoverGroup(GroupEntity, CoverEntity): """Representation of a CoverGroup.""" + _attr_available: bool = False _attr_is_closed: bool | None = None _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False @@ -267,29 +270,38 @@ class CoverGroup(GroupEntity, CoverEntity): """Update state and attributes.""" self._attr_assumed_state = False + states = [ + state.state + for entity_id in self._entities + if (state := self.hass.states.get(entity_id)) is not None + ] + + valid_state = any( + state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) for state in states + ) + + # Set group as unavailable if all members are unavailable or missing + self._attr_available = any(state != STATE_UNAVAILABLE for state in states) + self._attr_is_closed = True self._attr_is_closing = False self._attr_is_opening = False - has_valid_state = False for entity_id in self._entities: if not (state := self.hass.states.get(entity_id)): continue if state.state == STATE_OPEN: self._attr_is_closed = False - has_valid_state = True continue if state.state == STATE_CLOSED: - has_valid_state = True continue if state.state == STATE_CLOSING: self._attr_is_closing = True - has_valid_state = True continue if state.state == STATE_OPENING: self._attr_is_opening = True - has_valid_state = True continue - if not has_valid_state: + if not valid_state: + # Set as unknown if all members are unknown or unavailable self._attr_is_closed = None position_covers = self._covers[KEY_POSITION] diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 83c85a70b63..57c54c7c502 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -109,32 +109,36 @@ async def test_state(hass, setup_comp): Otherwise, the group state is closed. """ state = hass.states.get(COVER_GROUP) - # No entity has a valid state -> group state unknown - assert state.state == STATE_UNKNOWN + # No entity has a valid state -> group state unavailable + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME + assert ATTR_ENTITY_ID not in state.attributes + assert ATTR_ASSUMED_STATE not in state.attributes + assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + # Test group members exposed as attribute + hass.states.async_set(DEMO_COVER, STATE_UNKNOWN, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) assert state.attributes[ATTR_ENTITY_ID] == [ DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT, ] - assert ATTR_ASSUMED_STATE not in state.attributes - assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 - assert ATTR_CURRENT_POSITION not in state.attributes - assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + # The group state is unavailable if all group members are unavailable. + hass.states.async_set(DEMO_COVER, STATE_UNAVAILABLE, {}) + hass.states.async_set(DEMO_COVER_POS, STATE_UNAVAILABLE, {}) + hass.states.async_set(DEMO_COVER_TILT, STATE_UNAVAILABLE, {}) + hass.states.async_set(DEMO_TILT, STATE_UNAVAILABLE, {}) + await hass.async_block_till_done() + state = hass.states.get(COVER_GROUP) + assert state.state == STATE_UNAVAILABLE # The group state is unknown if all group members are unknown or unavailable. - for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): - for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): - hass.states.async_set(DEMO_COVER, state_1, {}) - hass.states.async_set(DEMO_COVER_POS, state_2, {}) - hass.states.async_set(DEMO_COVER_TILT, state_3, {}) - hass.states.async_set(DEMO_TILT, STATE_UNAVAILABLE, {}) - await hass.async_block_till_done() - state = hass.states.get(COVER_GROUP) - assert state.state == STATE_UNKNOWN - for state_1 in (STATE_UNAVAILABLE, STATE_UNKNOWN): for state_2 in (STATE_UNAVAILABLE, STATE_UNKNOWN): for state_3 in (STATE_UNAVAILABLE, STATE_UNKNOWN): @@ -233,28 +237,23 @@ async def test_state(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED - # All group members removed from the state machine -> unknown + # All group members removed from the state machine -> unavailable hass.states.async_remove(DEMO_COVER) hass.states.async_remove(DEMO_COVER_POS) hass.states.async_remove(DEMO_COVER_TILT) hass.states.async_remove(DEMO_TILT) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)]) async def test_attributes(hass, setup_comp): """Test handling of state attributes.""" state = hass.states.get(COVER_GROUP) - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNAVAILABLE assert state.attributes[ATTR_FRIENDLY_NAME] == DEFAULT_NAME - assert state.attributes[ATTR_ENTITY_ID] == [ - DEMO_COVER, - DEMO_COVER_POS, - DEMO_COVER_TILT, - DEMO_TILT, - ] + assert ATTR_ENTITY_ID not in state.attributes assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 0 assert ATTR_CURRENT_POSITION not in state.attributes @@ -266,6 +265,12 @@ async def test_attributes(hass, setup_comp): state = hass.states.get(COVER_GROUP) assert state.state == STATE_CLOSED + assert state.attributes[ATTR_ENTITY_ID] == [ + DEMO_COVER, + DEMO_COVER_POS, + DEMO_COVER_TILT, + DEMO_TILT, + ] # Set entity as opening hass.states.async_set(DEMO_COVER, STATE_OPENING, {}) From bc33818b20d145cba370247f5bb3b69d078cd9f3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 11:12:46 +0200 Subject: [PATCH 750/947] Use attributes in egardia alarm (#74098) --- .../components/egardia/alarm_control_panel.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py index f35d248c968..de179c248bb 100644 --- a/homeassistant/components/egardia/alarm_control_panel.py +++ b/homeassistant/components/egardia/alarm_control_panel.py @@ -63,6 +63,7 @@ def setup_platform( class EgardiaAlarm(alarm.AlarmControlPanelEntity): """Representation of a Egardia alarm.""" + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -72,9 +73,8 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010 ): """Initialize the Egardia alarm.""" - self._name = name + self._attr_name = name self._egardiasystem = egardiasystem - self._status = None self._rs_enabled = rs_enabled self._rs_codes = rs_codes self._rs_port = rs_port @@ -86,17 +86,7 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): self.hass.data[EGARDIA_SERVER].register_callback(self.handle_status_event) @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._status - - @property - def should_poll(self): + def should_poll(self) -> bool: """Poll if no report server is enabled.""" if not self._rs_enabled: return True @@ -130,7 +120,7 @@ class EgardiaAlarm(alarm.AlarmControlPanelEntity): _LOGGER.debug("Not ignoring status %s", status) newstatus = STATES.get(status.upper()) _LOGGER.debug("newstatus %s", newstatus) - self._status = newstatus + self._attr_state = newstatus else: _LOGGER.error("Ignoring status") From 5787eb058de9f075403f87a91a5c72e450032f57 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 28 Jun 2022 11:14:06 +0200 Subject: [PATCH 751/947] Build opencv at core build pipeline (#73961) --- .github/workflows/wheels.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index e65b0e7091a..95f1d8e437e 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -50,6 +50,10 @@ jobs: # Fix out of memory issues with rust echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" + + # OpenCV headless installation + echo "CI_BUILD=1" + echo "ENABLE_HEADLESS=1" ) > .env_file - name: Upload env_file @@ -138,14 +142,21 @@ jobs: sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} sed -i "s|# python-gammu|python-gammu|g" ${requirement_file} + sed -i "s|# opencv-python-headless|opencv-python-headless|g" ${requirement_file} done - - name: Adjust ENV + - name: Adjust build env run: | if [ "${{ matrix.arch }}" = "i386" ]; then echo "NPY_DISABLE_SVML=1" >> .env_file fi + ( + # cmake > 3.22.2 have issue on arm + # Tested until 3.22.5 + echo "cmake==3.22.2" + ) >> homeassistant/package_constraints.txt + - name: Build wheels uses: home-assistant/wheels@2022.06.7 with: @@ -154,7 +165,7 @@ jobs: arch: ${{ matrix.arch }} wheels-key: ${{ secrets.WHEELS_KEY }} env-file: true - apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran" + apk: "libexecinfo-dev;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev" skip-binary: aiohttp;grpcio legacy: true constraints: "homeassistant/package_constraints.txt" From 45cdfa10491f46c88180d62edc4607d3547cf618 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 12:53:50 +0200 Subject: [PATCH 752/947] Use attributes in point alarm (#74111) --- .../components/point/alarm_control_panel.py | 49 ++++++------------- 1 file changed, 15 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py index bd3deb6e2c9..bfffb934407 100644 --- a/homeassistant/components/point/alarm_control_panel.py +++ b/homeassistant/components/point/alarm_control_panel.py @@ -1,6 +1,7 @@ """Support for Minut Point.""" from __future__ import annotations +from collections.abc import Callable import logging from homeassistant.components.alarm_control_panel import ( @@ -19,6 +20,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from . import MinutPointClient from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK _LOGGER = logging.getLogger(__name__) @@ -53,12 +55,20 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY - def __init__(self, point_client, home_id): + def __init__(self, point_client: MinutPointClient, home_id: str) -> None: """Initialize the entity.""" self._client = point_client self._home_id = home_id - self._async_unsub_hook_dispatcher_connect = None - self._changed_by = None + self._async_unsub_hook_dispatcher_connect: Callable[[], None] | None = None + self._home = point_client.homes[self._home_id] + + self._attr_name = self._home["name"] + self._attr_unique_id = f"point.{home_id}" + self._attr_device_info = DeviceInfo( + identifiers={(POINT_DOMAIN, home_id)}, + manufacturer="Minut", + name=self._attr_name, + ) async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" @@ -85,29 +95,14 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): return _LOGGER.debug("Received webhook: %s", _type) self._home["alarm_status"] = _type - self._changed_by = _changed_by + self._attr_changed_by = _changed_by self.async_write_ha_state() @property - def _home(self): - """Return the home object.""" - return self._client.homes[self._home_id] - - @property - def name(self): - """Return name of the device.""" - return self._home["name"] - - @property - def state(self): + def state(self) -> str: """Return state of the device.""" return EVENT_MAP.get(self._home["alarm_status"], STATE_ALARM_ARMED_AWAY) - @property - def changed_by(self): - """Return the user the last change was triggered by.""" - return self._changed_by - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" status = await self._client.async_alarm_disarm(self._home_id) @@ -119,17 +114,3 @@ class MinutPointAlarmControl(AlarmControlPanelEntity): status = await self._client.async_alarm_arm(self._home_id) if status: self._home["alarm_status"] = "on" - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._home_id}" - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - identifiers={(POINT_DOMAIN, self._home_id)}, - manufacturer="Minut", - name=self.name, - ) From 389664e37c9657c7b0899e938d0918e8f48b8036 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:05:30 +0200 Subject: [PATCH 753/947] Use attributes in lupusec alarm (#74109) --- .../components/lupusec/alarm_control_panel.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py index 425b813d18a..2ae0b5944bd 100644 --- a/homeassistant/components/lupusec/alarm_control_panel.py +++ b/homeassistant/components/lupusec/alarm_control_panel.py @@ -19,8 +19,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice -ICON = "mdi:security" - SCAN_INTERVAL = timedelta(seconds=2) @@ -44,18 +42,14 @@ def setup_platform( class LupusecAlarm(LupusecDevice, AlarmControlPanelEntity): """An alarm_control_panel implementation for Lupusec.""" + _attr_icon = "mdi:security" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) @property - def icon(self): - """Return the icon.""" - return ICON - - @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if self._device.is_standby: state = STATE_ALARM_DISARMED From 03d2d503932e3b26e279d8bb37dd3dbb8b4acb69 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:07:46 +0200 Subject: [PATCH 754/947] Use attributes in ifttt alarm (#74107) --- .../components/ifttt/alarm_control_panel.py | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py index 840dd2fed62..8bd267891a6 100644 --- a/homeassistant/components/ifttt/alarm_control_panel.py +++ b/homeassistant/components/ifttt/alarm_control_panel.py @@ -127,6 +127,7 @@ def setup_platform( class IFTTTAlarmPanel(AlarmControlPanelEntity): """Representation of an alarm control panel controlled through IFTTT.""" + _attr_assumed_state = True _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -145,7 +146,7 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): optimistic, ): """Initialize the alarm control panel.""" - self._name = name + self._attr_name = name self._code = code self._code_arm_required = code_arm_required self._event_away = event_away @@ -153,25 +154,9 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): self._event_night = event_night self._event_disarm = event_disarm self._optimistic = optimistic - self._state = None @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state - - @property - def assumed_state(self): - """Notify that this platform return an assumed state.""" - return True - - @property - def code_format(self): + def code_format(self) -> CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None @@ -210,13 +195,13 @@ class IFTTTAlarmPanel(AlarmControlPanelEntity): self.hass.services.call(DOMAIN, SERVICE_TRIGGER, data) _LOGGER.debug("Called IFTTT integration to trigger event %s", event) if self._optimistic: - self._state = state + self._attr_state = state def push_alarm_state(self, value): """Push the alarm state to the given value.""" if value in ALLOWED_STATES: _LOGGER.debug("Pushed the alarm state to %s", value) - self._state = value + self._attr_state = value - def _check_code(self, code): + def _check_code(self, code: str | None) -> bool: return self._code is None or self._code == code From 79b3865b6014fe1fb45dea5d792cdc3d1e44ed91 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:15:38 +0200 Subject: [PATCH 755/947] Use attributes in ialarm alarm (#74099) --- homeassistant/components/ialarm/__init__.py | 12 ++++--- .../components/ialarm/alarm_control_panel.py | 35 +++++++++---------- 2 files changed, 23 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py index db9aa000066..254bf6f685f 100644 --- a/homeassistant/components/ialarm/__init__.py +++ b/homeassistant/components/ialarm/__init__.py @@ -1,4 +1,6 @@ """iAlarm integration.""" +from __future__ import annotations + import asyncio import logging @@ -20,8 +22,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up iAlarm config.""" - host = entry.data[CONF_HOST] - port = entry.data[CONF_PORT] + host: str = entry.data[CONF_HOST] + port: int = entry.data[CONF_PORT] ialarm = IAlarm(host, port) try: @@ -55,11 +57,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class IAlarmDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching iAlarm data.""" - def __init__(self, hass, ialarm, mac): + def __init__(self, hass: HomeAssistant, ialarm: IAlarm, mac: str) -> None: """Initialize global iAlarm data updater.""" self.ialarm = ialarm - self.state = None - self.host = ialarm.host + self.state: str | None = None + self.host: str = ialarm.host self.mac = mac super().__init__( diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py index 74310d940e7..6a4e3d191eb 100644 --- a/homeassistant/components/ialarm/alarm_control_panel.py +++ b/homeassistant/components/ialarm/alarm_control_panel.py @@ -11,6 +11,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity +from . import IAlarmDataUpdateCoordinator from .const import DATA_COORDINATOR, DOMAIN @@ -18,39 +19,35 @@ async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up a iAlarm alarm control panel based on a config entry.""" - coordinator = hass.data[DOMAIN][entry.entry_id][DATA_COORDINATOR] + coordinator: IAlarmDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] async_add_entities([IAlarmPanel(coordinator)], False) -class IAlarmPanel(CoordinatorEntity, AlarmControlPanelEntity): +class IAlarmPanel( + CoordinatorEntity[IAlarmDataUpdateCoordinator], AlarmControlPanelEntity +): """Representation of an iAlarm device.""" + _attr_name = "iAlarm" _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, + def __init__(self, coordinator: IAlarmDataUpdateCoordinator) -> None: + """Create the entity with a DataUpdateCoordinator.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.mac)}, manufacturer="Antifurto365 - Meian", - name=self.name, + name="iAlarm", ) + self._attr_unique_id = coordinator.mac @property - def unique_id(self): - """Return a unique id.""" - return self.coordinator.mac - - @property - def name(self): - """Return the name.""" - return "iAlarm" - - @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self.coordinator.state From 39c7056be58455f80fe8715c11844160013f7073 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 13:39:37 +0200 Subject: [PATCH 756/947] Migrate climacell to native_* (#74039) --- homeassistant/components/climacell/weather.py | 63 +++++-------------- tests/components/climacell/test_weather.py | 2 +- 2 files changed, 15 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index 0167cb72513..2b284114981 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -23,13 +23,9 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_API_VERSION, CONF_NAME, - LENGTH_FEET, - LENGTH_KILOMETERS, - LENGTH_METERS, + LENGTH_INCHES, LENGTH_MILES, - PRESSURE_HPA, PRESSURE_INHG, - SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, TEMP_FAHRENHEIT, ) @@ -37,8 +33,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sun import is_up from homeassistant.util import dt as dt_util -from homeassistant.util.distance import convert as distance_convert -from homeassistant.util.pressure import convert as pressure_convert from homeassistant.util.speed import convert as speed_convert from . import ClimaCellDataUpdateCoordinator, ClimaCellEntity @@ -89,6 +83,12 @@ async def async_setup_entry( class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Base ClimaCell weather entity.""" + _attr_native_precipitation_unit = LENGTH_INCHES + _attr_native_pressure_unit = PRESSURE_INHG + _attr_native_temperature_unit = TEMP_FAHRENHEIT + _attr_native_visibility_unit = LENGTH_MILES + _attr_native_wind_speed_unit = SPEED_MILES_PER_HOUR + def __init__( self, config_entry: ConfigEntry, @@ -132,21 +132,6 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): else: translated_condition = self._translate_condition(condition, True) - if self.hass.config.units.is_metric: - if precipitation: - precipitation = round( - distance_convert(precipitation / 12, LENGTH_FEET, LENGTH_METERS) - * 1000, - 4, - ) - if wind_speed: - wind_speed = round( - speed_convert( - wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ), - 4, - ) - data = { ATTR_FORECAST_TIME: forecast_dt.isoformat(), ATTR_FORECAST_CONDITION: translated_condition, @@ -164,13 +149,10 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): def extra_state_attributes(self) -> Mapping[str, Any] | None: """Return additional state attributes.""" wind_gust = self.wind_gust - if wind_gust and self.hass.config.units.is_metric: - wind_gust = round( - speed_convert( - self.wind_gust, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ), - 4, - ) + wind_gust = round( + speed_convert(self.wind_gust, SPEED_MILES_PER_HOUR, self._wind_speed_unit), + 4, + ) cloud_cover = self.cloud_cover return { ATTR_CLOUD_COVER: cloud_cover, @@ -199,12 +181,8 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the raw pressure.""" @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" - if self.hass.config.units.is_metric and self._pressure: - return round( - pressure_convert(self._pressure, PRESSURE_INHG, PRESSURE_HPA), 4 - ) return self._pressure @property @@ -213,15 +191,8 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the raw wind speed.""" @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - if self.hass.config.units.is_metric and self._wind_speed: - return round( - speed_convert( - self._wind_speed, SPEED_MILES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR - ), - 4, - ) return self._wind_speed @property @@ -230,20 +201,14 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): """Return the raw visibility.""" @property - def visibility(self): + def native_visibility(self): """Return the visibility.""" - if self.hass.config.units.is_metric and self._visibility: - return round( - distance_convert(self._visibility, LENGTH_MILES, LENGTH_KILOMETERS), 4 - ) return self._visibility class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): """Entity that talks to ClimaCell v3 API to retrieve weather data.""" - _attr_temperature_unit = TEMP_FAHRENHEIT - @staticmethod def _translate_condition( condition: int | str | None, sun_is_up: bool = True diff --git a/tests/components/climacell/test_weather.py b/tests/components/climacell/test_weather.py index 593caa7755f..e3326f267d4 100644 --- a/tests/components/climacell/test_weather.py +++ b/tests/components/climacell/test_weather.py @@ -156,7 +156,7 @@ async def test_v3_weather( { ATTR_FORECAST_CONDITION: ATTR_CONDITION_SNOWY, ATTR_FORECAST_TIME: "2021-03-15T00:00:00-07:00", # DST starts - ATTR_FORECAST_PRECIPITATION: 7.3, + ATTR_FORECAST_PRECIPITATION: 7.31, ATTR_FORECAST_PRECIPITATION_PROBABILITY: 95, ATTR_FORECAST_TEMP: 1.2, ATTR_FORECAST_TEMP_LOW: 0.2, From 2a0b2ecca195edcbf660b6f7cb5673043d758fb5 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 28 Jun 2022 13:40:36 +0200 Subject: [PATCH 757/947] Fix depreciation period for Weather (#74106) Fix period --- homeassistant/components/weather/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index f09e76b0073..6f2de6a3cb0 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -304,7 +304,7 @@ class WeatherEntity(Entity): _LOGGER.warning( "%s::%s is overriding deprecated methods on an instance of " "WeatherEntity, this is not valid and will be unsupported " - "from Home Assistant 2022.10. Please %s", + "from Home Assistant 2023.1. Please %s", cls.__module__, cls.__name__, report_issue, From dac8f242e0e0650a42ad781be6387907823e8912 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:41:23 +0200 Subject: [PATCH 758/947] Improve type hints in mqtt and template alarms (#74101) --- homeassistant/components/mqtt/alarm_control_panel.py | 11 +++++------ .../components/template/alarm_control_panel.py | 12 ++++++------ 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index bd2495fd5d1..8e5ee54d688 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -172,7 +172,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): def __init__(self, hass, config, config_entry, discovery_data): """Init the MQTT Alarm Control Panel.""" - self._state = None + self._state: str | None = None MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @@ -233,7 +233,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): await subscription.async_subscribe_topics(self.hass, self._sub_state) @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self._state @@ -250,7 +250,7 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): ) @property - def code_format(self): + def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" if (code := self._config.get(CONF_CODE)) is None: return None @@ -259,10 +259,9 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): return alarm.CodeFormat.TEXT @property - def code_arm_required(self): + def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" - code_required = self._config.get(CONF_CODE_ARM_REQUIRED) - return code_required + return self._config[CONF_CODE_ARM_REQUIRED] async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index 74d794d703a..ae26e58ac04 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -144,8 +144,8 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): name = self._attr_name self._template = config.get(CONF_VALUE_TEMPLATE) self._disarm_script = None - self._code_arm_required = config[CONF_CODE_ARM_REQUIRED] - self._code_format = config[CONF_CODE_FORMAT] + self._code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] + self._code_format: TemplateCodeFormat = config[CONF_CODE_FORMAT] if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None: self._disarm_script = Script(hass, disarm_action, name, DOMAIN) self._arm_away_script = None @@ -158,10 +158,10 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): if (arm_night_action := config.get(CONF_ARM_NIGHT_ACTION)) is not None: self._arm_night_script = Script(hass, arm_night_action, name, DOMAIN) - self._state = None + self._state: str | None = None @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return self._state @@ -187,12 +187,12 @@ class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity): return supported_features @property - def code_format(self): + def code_format(self) -> CodeFormat | None: """Regex for code format or None if no code is required.""" return self._code_format.value @property - def code_arm_required(self): + def code_arm_required(self) -> bool: """Whether the code is required for arm actions.""" return self._code_arm_required From c1f621e9c0b30e8b1df22dd8f2ad41d3fba8adbf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:42:43 +0200 Subject: [PATCH 759/947] Use attributes in nx584 alarm (#74105) --- .../components/nx584/alarm_control_panel.py | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/nx584/alarm_control_panel.py b/homeassistant/components/nx584/alarm_control_panel.py index 735c8104ef5..3eaaf07ad1c 100644 --- a/homeassistant/components/nx584/alarm_control_panel.py +++ b/homeassistant/components/nx584/alarm_control_panel.py @@ -55,9 +55,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the NX584 platform.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + name: str = config[CONF_NAME] + host: str = config[CONF_HOST] + port: int = config[CONF_PORT] url = f"http://{host}:{port}" @@ -92,33 +92,19 @@ async def async_setup_platform( class NX584Alarm(alarm.AlarmControlPanelEntity): """Representation of a NX584-based alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY ) - def __init__(self, name, alarm_client, url): + def __init__(self, name: str, alarm_client: client.Client, url: str) -> None: """Init the nx584 alarm panel.""" - self._name = name - self._state = None + self._attr_name = name self._alarm = alarm_client self._url = url - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def code_format(self): - """Return one or more digits/characters.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - def update(self) -> None: """Process new events from panel.""" try: @@ -129,11 +115,11 @@ class NX584Alarm(alarm.AlarmControlPanelEntity): "Unable to connect to %(host)s: %(reason)s", {"host": self._url, "reason": ex}, ) - self._state = None + self._attr_state = None zones = [] except IndexError: _LOGGER.error("NX584 reports no partitions") - self._state = None + self._attr_state = None zones = [] bypassed = False @@ -147,15 +133,15 @@ class NX584Alarm(alarm.AlarmControlPanelEntity): break if not part["armed"]: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif bypassed: - self._state = STATE_ALARM_ARMED_HOME + self._attr_state = STATE_ALARM_ARMED_HOME else: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY for flag in part["condition_flags"]: if flag == "Siren on": - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" @@ -169,10 +155,10 @@ class NX584Alarm(alarm.AlarmControlPanelEntity): """Send arm away command.""" self._alarm.arm("exit") - def alarm_bypass(self, zone): + def alarm_bypass(self, zone: int) -> None: """Send bypass command.""" self._alarm.set_bypass(zone, True) - def alarm_unbypass(self, zone): + def alarm_unbypass(self, zone: int) -> None: """Send bypass command.""" self._alarm.set_bypass(zone, False) From 4b5c0be89673269bbe9dbefd6af5c7540aec3818 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 28 Jun 2022 13:42:58 +0200 Subject: [PATCH 760/947] Native to Weather Template (#74060) * Native to Weather template * Add validation --- homeassistant/components/template/weather.py | 61 +++++++++++++------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 5d1a48269aa..d65e03b9656 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -20,15 +20,22 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ENTITY_ID_FORMAT, + Forecast, WeatherEntity, ) -from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID +from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import ( + distance as distance_util, + pressure as pressure_util, + speed as speed_util, + temperature as temp_util, +) from .template_entity import TemplateEntity, rewrite_common_legacy_to_modern_conf @@ -61,6 +68,10 @@ CONF_WIND_BEARING_TEMPLATE = "wind_bearing_template" CONF_OZONE_TEMPLATE = "ozone_template" CONF_VISIBILITY_TEMPLATE = "visibility_template" CONF_FORECAST_TEMPLATE = "forecast_template" +CONF_PRESSURE_UNIT = "pressure_unit" +CONF_WIND_SPEED_UNIT = "wind_speed_unit" +CONF_VISIBILITY_UNIT = "visibility_unit" +CONF_PRECIPITATION_UNIT = "precipitation_unit" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -76,6 +87,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_VISIBILITY_TEMPLATE): cv.template, vol.Optional(CONF_FORECAST_TEMPLATE): cv.template, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_TEMPERATURE_UNIT): vol.In(temp_util.VALID_UNITS), + vol.Optional(CONF_PRESSURE_UNIT): vol.In(pressure_util.VALID_UNITS), + vol.Optional(CONF_WIND_SPEED_UNIT): vol.In(speed_util.VALID_UNITS), + vol.Optional(CONF_VISIBILITY_UNIT): vol.In(distance_util.VALID_UNITS), + vol.Optional(CONF_PRECIPITATION_UNIT): vol.In(distance_util.VALID_UNITS), } ) @@ -109,10 +125,10 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): def __init__( self, - hass, - config, - unique_id, - ): + hass: HomeAssistant, + config: ConfigType, + unique_id: str | None, + ) -> None: """Initialize the Template weather.""" super().__init__(hass, config=config, unique_id=unique_id) @@ -128,6 +144,12 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._visibility_template = config.get(CONF_VISIBILITY_TEMPLATE) self._forecast_template = config.get(CONF_FORECAST_TEMPLATE) + self._attr_native_precipitation_unit = config.get(CONF_PRECIPITATION_UNIT) + self._attr_native_pressure_unit = config.get(CONF_PRESSURE_UNIT) + self._attr_native_temperature_unit = config.get(CONF_TEMPERATURE_UNIT) + self._attr_native_visibility_unit = config.get(CONF_VISIBILITY_UNIT) + self._attr_native_wind_speed_unit = config.get(CONF_WIND_SPEED_UNIT) + self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name, hass=hass) self._condition = None @@ -139,66 +161,61 @@ class WeatherTemplate(TemplateEntity, WeatherEntity): self._wind_bearing = None self._ozone = None self._visibility = None - self._forecast = [] + self._forecast: list[Forecast] = [] @property - def condition(self): + def condition(self) -> str | None: """Return the current condition.""" return self._condition @property - def temperature(self): + def native_temperature(self) -> float | None: """Return the temperature.""" return self._temperature @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self.hass.config.units.temperature_unit - - @property - def humidity(self): + def humidity(self) -> float | None: """Return the humidity.""" return self._humidity @property - def wind_speed(self): + def native_wind_speed(self) -> float | None: """Return the wind speed.""" return self._wind_speed @property - def wind_bearing(self): + def wind_bearing(self) -> float | str | None: """Return the wind bearing.""" return self._wind_bearing @property - def ozone(self): + def ozone(self) -> float | None: """Return the ozone level.""" return self._ozone @property - def visibility(self): + def native_visibility(self) -> float | None: """Return the visibility.""" return self._visibility @property - def pressure(self): + def native_pressure(self) -> float | None: """Return the air pressure.""" return self._pressure @property - def forecast(self): + def forecast(self) -> list[Forecast]: """Return the forecast.""" return self._forecast @property - def attribution(self): + def attribution(self) -> str | None: """Return the attribution.""" if self._attribution is None: return "Powered by Home Assistant" return self._attribution - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Register callbacks.""" if self._condition_template: From 8bed2e6459bfc1efb25d6a55aaea2eb1b9953cf9 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:53:20 +0200 Subject: [PATCH 761/947] Remove zha from mypy ignore list (#73603) --- .../components/zha/core/channels/__init__.py | 4 +- .../components/zha/core/discovery.py | 8 +-- .../components/zha/core/registries.py | 61 ++++++++++--------- mypy.ini | 6 -- script/hassfest/mypy_config.py | 2 - 5 files changed, 38 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 2da7462f3eb..9042856b456 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -221,7 +221,7 @@ class ChannelPool: return self._channels.zha_device.is_mains_powered @property - def manufacturer(self) -> str | None: + def manufacturer(self) -> str: """Return device manufacturer.""" return self._channels.zha_device.manufacturer @@ -236,7 +236,7 @@ class ChannelPool: return self._channels.zha_device.hass @property - def model(self) -> str | None: + def model(self) -> str: """Return device model.""" return self._channels.zha_device.model diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index cdad57834eb..6b690f4da08 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -6,7 +6,7 @@ from collections.abc import Callable import logging from typing import TYPE_CHECKING -from homeassistant import const as ha_const +from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -86,9 +86,7 @@ class ProbeEndpoint: unique_id = channel_pool.unique_id - component: str | None = self._device_configs.get(unique_id, {}).get( - ha_const.CONF_TYPE - ) + component: str | None = self._device_configs.get(unique_id, {}).get(CONF_TYPE) if component is None: ep_profile_id = channel_pool.endpoint.profile_id ep_device_type = channel_pool.endpoint.device_type @@ -136,7 +134,7 @@ class ProbeEndpoint: @staticmethod def probe_single_cluster( - component: str, + component: Platform | None, channel: base.ZigbeeChannel, ep_channels: ChannelPool, ) -> None: diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 7e2114b5911..d271f2ecba3 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -110,7 +110,7 @@ CLIENT_CHANNELS_REGISTRY: DictRegistry[type[ClientChannel]] = DictRegistry() ZIGBEE_CHANNEL_REGISTRY: DictRegistry[type[ZigbeeChannel]] = DictRegistry() -def set_or_callable(value): +def set_or_callable(value) -> frozenset[str] | Callable: """Convert single str or None to a set. Pass through callables and sets.""" if value is None: return frozenset() @@ -121,22 +121,26 @@ def set_or_callable(value): return frozenset([str(value)]) +def _get_empty_frozenset() -> frozenset[str]: + return frozenset() + + @attr.s(frozen=True) class MatchRule: """Match a ZHA Entity to a channel name or generic id.""" - channel_names: set[str] | str = attr.ib( + channel_names: frozenset[str] = attr.ib( factory=frozenset, converter=set_or_callable ) - generic_ids: set[str] | str = attr.ib(factory=frozenset, converter=set_or_callable) - manufacturers: Callable | set[str] | str = attr.ib( - factory=frozenset, converter=set_or_callable + generic_ids: frozenset[str] = attr.ib(factory=frozenset, converter=set_or_callable) + manufacturers: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable ) - models: Callable | set[str] | str = attr.ib( - factory=frozenset, converter=set_or_callable + models: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable ) - aux_channels: Callable | set[str] | str = attr.ib( - factory=frozenset, converter=set_or_callable + aux_channels: frozenset[str] | Callable = attr.ib( + factory=_get_empty_frozenset, converter=set_or_callable ) @property @@ -162,7 +166,8 @@ class MatchRule: weight += 10 * len(self.channel_names) weight += 5 * len(self.generic_ids) - weight += 1 * len(self.aux_channels) + if isinstance(self.aux_channels, frozenset): + weight += 1 * len(self.aux_channels) return weight def claim_channels(self, channel_pool: list[ZigbeeChannel]) -> list[ZigbeeChannel]: @@ -321,11 +326,11 @@ class ZHAEntityRegistry: def strict_match( self, component: str, - channel_names: set[str] | str = None, - generic_ids: set[str] | str = None, - manufacturers: Callable | set[str] | str = None, - models: Callable | set[str] | str = None, - aux_channels: Callable | set[str] | str = None, + channel_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_channels: Callable | set[str] | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a strict match rule.""" @@ -346,11 +351,11 @@ class ZHAEntityRegistry: def multipass_match( self, component: str, - channel_names: set[str] | str = None, - generic_ids: set[str] | str = None, - manufacturers: Callable | set[str] | str = None, - models: Callable | set[str] | str = None, - aux_channels: Callable | set[str] | str = None, + channel_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -379,11 +384,11 @@ class ZHAEntityRegistry: def config_diagnostic_match( self, component: str, - channel_names: set[str] | str = None, - generic_ids: set[str] | str = None, - manufacturers: Callable | set[str] | str = None, - models: Callable | set[str] | str = None, - aux_channels: Callable | set[str] | str = None, + channel_names: set[str] | str | None = None, + generic_ids: set[str] | str | None = None, + manufacturers: Callable | set[str] | str | None = None, + models: Callable | set[str] | str | None = None, + aux_channels: Callable | set[str] | str | None = None, stop_on_match_group: int | str | None = None, ) -> Callable[[_ZhaEntityT], _ZhaEntityT]: """Decorate a loose match rule.""" @@ -432,9 +437,9 @@ class ZHAEntityRegistry: def clean_up(self) -> None: """Clean up post discovery.""" - self.single_device_matches: dict[ - Platform, dict[EUI64, list[str]] - ] = collections.defaultdict(lambda: collections.defaultdict(list)) + self.single_device_matches = collections.defaultdict( + lambda: collections.defaultdict(list) + ) ZHA_ENTITIES = ZHAEntityRegistry() diff --git a/mypy.ini b/mypy.ini index 0f8c8fc0b61..fb67983a31c 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2995,9 +2995,3 @@ ignore_errors = true [mypy-homeassistant.components.xiaomi_miio.switch] ignore_errors = true - -[mypy-homeassistant.components.zha.core.discovery] -ignore_errors = true - -[mypy-homeassistant.components.zha.core.registries] -ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index 0fd62d49ae2..bbb628a76bb 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -144,8 +144,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.xiaomi_miio.light", "homeassistant.components.xiaomi_miio.sensor", "homeassistant.components.xiaomi_miio.switch", - "homeassistant.components.zha.core.discovery", - "homeassistant.components.zha.core.registries", ] # Component modules which should set no_implicit_reexport = true. From 3b30d8a279e2c1632e0359a9a01a560d932a6d12 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 13:56:14 +0200 Subject: [PATCH 762/947] Use attributes in satel_integra alarm (#74103) --- .../satel_integra/alarm_control_panel.py | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 054909cd3c2..79ef4c048b3 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -61,6 +61,9 @@ async def async_setup_platform( class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): """Representation of an AlarmDecoder-based alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_should_poll = False + _attr_state: str | None _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -68,8 +71,7 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): def __init__(self, controller, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" - self._name = name - self._state = None + self._attr_name = name self._arm_home_mode = arm_home_mode self._partition_id = partition_id self._satel = controller @@ -89,8 +91,8 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): """Handle alarm status update.""" state = self._read_alarm_state() _LOGGER.debug("Got status update, current status: %s", state) - if state != self._state: - self._state = state + if state != self._attr_state: + self._attr_state = state self.async_write_ha_state() else: _LOGGER.debug("Ignoring alarm status message, same state") @@ -129,35 +131,15 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanelEntity): return hass_alarm_status - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def code_format(self): - """Return the regex for code format or None if no code is required.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not code: _LOGGER.debug("Code was empty or None") return - clear_alarm_necessary = self._state == STATE_ALARM_TRIGGERED + clear_alarm_necessary = self._attr_state == STATE_ALARM_TRIGGERED - _LOGGER.debug("Disarming, self._state: %s", self._state) + _LOGGER.debug("Disarming, self._attr_state: %s", self._attr_state) await self._satel.disarm(code, [self._partition_id]) From 4335cafb3f0b84d2281caa0cdfbd8c49743981ca Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 14:01:49 +0200 Subject: [PATCH 763/947] Use attributes in totalconnect alarm (#74113) --- .../totalconnect/alarm_control_panel.py | 55 +++++++------------ 1 file changed, 21 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py index 6ed29dbcad3..5798f4d31d3 100644 --- a/homeassistant/components/totalconnect/alarm_control_panel.py +++ b/homeassistant/components/totalconnect/alarm_control_panel.py @@ -20,6 +20,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -85,7 +86,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): self._partition = self._location.partitions[partition_id] self._device = self._location.devices[self._location.security_device_id] self._state = None - self._extra_state_attributes = {} + self._attr_extra_state_attributes = {} """ Set unique_id to location_id for partition 1 to avoid breaking change @@ -93,35 +94,25 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): Add _# for partition 2 and beyond. """ if partition_id == 1: - self._name = name - self._unique_id = f"{location_id}" + self._attr_name = name + self._attr_unique_id = f"{location_id}" else: - self._name = f"{name} partition {partition_id}" - self._unique_id = f"{location_id}_{partition_id}" + self._attr_name = f"{name} partition {partition_id}" + self._attr_unique_id = f"{location_id}_{partition_id}" @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info.""" - return { - "identifiers": {(DOMAIN, self._device.serial_number)}, - "name": self._device.name, - } + return DeviceInfo( + identifiers={(DOMAIN, self._device.serial_number)}, + name=self._device.name, + ) @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" attr = { - "location_name": self._name, + "location_name": self.name, "location_id": self._location_id, "partition": self._partition_id, "ac_loss": self._location.ac_loss, @@ -131,6 +122,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): "triggered_zone": None, } + state = None if self._partition.arming_state.is_disarmed(): state = STATE_ALARM_DISARMED elif self._partition.arming_state.is_armed_night(): @@ -156,15 +148,10 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): attr["triggered_source"] = "Carbon Monoxide" self._state = state - self._extra_state_attributes = attr + self._attr_extra_state_attributes = attr return self._state - @property - def extra_state_attributes(self): - """Return the state attributes of the device.""" - return self._extra_state_attributes - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" try: @@ -176,7 +163,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to disarm {self._name}." + f"TotalConnect failed to disarm {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -195,7 +182,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm home {self._name}." + f"TotalConnect failed to arm home {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -214,7 +201,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm away {self._name}." + f"TotalConnect failed to arm away {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -233,7 +220,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm night {self._name}." + f"TotalConnect failed to arm night {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -252,7 +239,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm home instant {self._name}." + f"TotalConnect failed to arm home instant {self.name}." ) from error await self.coordinator.async_request_refresh() @@ -271,7 +258,7 @@ class TotalConnectAlarm(CoordinatorEntity, alarm.AlarmControlPanelEntity): ) from error except BadResultCodeError as error: raise HomeAssistantError( - f"TotalConnect failed to arm away instant {self._name}." + f"TotalConnect failed to arm away instant {self.name}." ) from error await self.coordinator.async_request_refresh() From 50cba60d1fc77dcdff028ffbae3c3c1f123bd4bc Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 28 Jun 2022 14:07:28 +0200 Subject: [PATCH 764/947] Update base image to 2022.06.2 (#74114) --- build.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/build.yaml b/build.yaml index 23486fb5510..7bd4abcfc20 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.06.1 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.06.1 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.06.1 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.06.1 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.06.1 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2022.06.2 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2022.06.2 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2022.06.2 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2022.06.2 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2022.06.2 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io From c4ff317ec65586f8d6c204988ffdf49facfe1506 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 28 Jun 2022 14:29:00 +0200 Subject: [PATCH 765/947] Smhi minor fixes (#72606) * Initial commit * Tests * From review comments --- homeassistant/components/smhi/__init__.py | 43 ++++++++++++++- homeassistant/components/smhi/config_flow.py | 32 ++++------- homeassistant/components/smhi/weather.py | 7 ++- tests/components/smhi/__init__.py | 13 ++++- tests/components/smhi/test_config_flow.py | 58 +++++++++++++------- tests/components/smhi/test_init.py | 50 +++++++++++++++-- tests/components/smhi/test_weather.py | 18 +++--- 7 files changed, 161 insertions(+), 60 deletions(-) diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index 398932bf4d6..e3f55904b77 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -1,7 +1,14 @@ """Support for the Swedish weather institute weather service.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, Platform -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_NAME, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries PLATFORMS = [Platform.WEATHER] @@ -11,7 +18,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Setting unique id where missing if entry.unique_id is None: - unique_id = f"{entry.data[CONF_LATITUDE]}-{entry.data[CONF_LONGITUDE]}" + unique_id = f"{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" hass.config_entries.async_update_entry(entry, unique_id=unique_id) hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -21,3 +28,33 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Migrate old entry.""" + if entry.version == 1: + new_data = { + CONF_NAME: entry.data[CONF_NAME], + CONF_LOCATION: { + CONF_LATITUDE: entry.data[CONF_LATITUDE], + CONF_LONGITUDE: entry.data[CONF_LONGITUDE], + }, + } + new_unique_id = f"smhi-{entry.data[CONF_LATITUDE]}-{entry.data[CONF_LONGITUDE]}" + + if not hass.config_entries.async_update_entry( + entry, data=new_data, unique_id=new_unique_id + ): + return False + + entry.version = 2 + new_unique_id_entity = f"smhi-{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" + + @callback + def update_unique_id(entity_entry: RegistryEntry) -> dict[str, str]: + """Update unique ID of entity entry.""" + return {"new_unique_id": new_unique_id_entity} + + await async_migrate_entries(hass, entry.entry_id, update_unique_id) + + return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 770f549efe0..8d338e3eb3e 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -7,11 +7,11 @@ from smhi.smhi_lib import Smhi, SmhiForecastException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client -import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import LocationSelector from .const import DEFAULT_NAME, DOMAIN, HOME_LOCATION_NAME @@ -33,7 +33,7 @@ async def async_check_location( class SmhiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for SMHI component.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -43,8 +43,8 @@ class SmhiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors: dict[str, str] = {} if user_input is not None: - lat: float = user_input[CONF_LATITUDE] - lon: float = user_input[CONF_LONGITUDE] + lat: float = user_input[CONF_LOCATION][CONF_LATITUDE] + lon: float = user_input[CONF_LOCATION][CONF_LONGITUDE] if await async_check_location(self.hass, lon, lat): name = f"{DEFAULT_NAME} {round(lat, 6)} {round(lon, 6)}" if ( @@ -57,30 +57,20 @@ class SmhiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): HOME_LOCATION_NAME if name == HOME_LOCATION_NAME else DEFAULT_NAME ) - await self.async_set_unique_id(f"{lat}-{lon}") + await self.async_set_unique_id(f"smhi-{lat}-{lon}") self._abort_if_unique_id_configured() return self.async_create_entry(title=name, data=user_input) errors["base"] = "wrong_location" - default_lat: float = self.hass.config.latitude - default_lon: float = self.hass.config.longitude - - for entry in self.hass.config_entries.async_entries(DOMAIN): - if ( - entry.data[CONF_LATITUDE] == self.hass.config.latitude - and entry.data[CONF_LONGITUDE] == self.hass.config.longitude - ): - default_lat = 0 - default_lon = 0 - + home_location = { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } return self.async_show_form( step_id="user", data_schema=vol.Schema( - { - vol.Required(CONF_LATITUDE, default=default_lat): cv.latitude, - vol.Required(CONF_LONGITUDE, default=default_lon): cv.longitude, - } + {vol.Required(CONF_LOCATION, default=home_location): LocationSelector()} ), errors=errors, ) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index cbe2ad37fe9..5d1f3e3c87e 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -42,6 +42,7 @@ from homeassistant.components.weather import ( from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_LATITUDE, + CONF_LOCATION, CONF_LONGITUDE, CONF_NAME, LENGTH_KILOMETERS, @@ -106,8 +107,8 @@ async def async_setup_entry( entity = SmhiWeather( location[CONF_NAME], - location[CONF_LATITUDE], - location[CONF_LONGITUDE], + location[CONF_LOCATION][CONF_LATITUDE], + location[CONF_LOCATION][CONF_LONGITUDE], session=session, ) entity.entity_id = ENTITY_ID_SENSOR_FORMAT.format(name) @@ -135,7 +136,7 @@ class SmhiWeather(WeatherEntity): """Initialize the SMHI weather entity.""" self._attr_name = name - self._attr_unique_id = f"{latitude}, {longitude}" + self._attr_unique_id = f"smhi-{latitude}-{longitude}" self._forecasts: list[SmhiForecast] | None = None self._fail_count = 0 self._smhi_api = Smhi(longitude, latitude, session=session) diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py index d815aafc8f5..377552da4d5 100644 --- a/tests/components/smhi/__init__.py +++ b/tests/components/smhi/__init__.py @@ -1,3 +1,14 @@ """Tests for the SMHI component.""" ENTITY_ID = "weather.smhi_test" -TEST_CONFIG = {"name": "test", "longitude": "17.84197", "latitude": "59.32624"} +TEST_CONFIG = { + "name": "test", + "location": { + "longitude": "17.84197", + "latitude": "59.32624", + }, +} +TEST_CONFIG_MIGRATE = { + "name": "test", + "longitude": "17.84197", + "latitude": "17.84197", +} diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index 60879e8af75..ab3e36f81de 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -7,7 +7,7 @@ from smhi.smhi_lib import SmhiForecastException from homeassistant import config_entries from homeassistant.components.smhi.const import DOMAIN -from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -40,8 +40,10 @@ async def test_form(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 0.0, - CONF_LONGITUDE: 0.0, + CONF_LOCATION: { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + } }, ) await hass.async_block_till_done() @@ -49,8 +51,10 @@ async def test_form(hass: HomeAssistant) -> None: assert result2["type"] == RESULT_TYPE_CREATE_ENTRY assert result2["title"] == "Home" assert result2["data"] == { - "latitude": 0.0, - "longitude": 0.0, + "location": { + "latitude": 0.0, + "longitude": 0.0, + }, "name": "Home", } assert len(mock_setup_entry.mock_calls) == 1 @@ -69,8 +73,10 @@ async def test_form(hass: HomeAssistant) -> None: result4 = await hass.config_entries.flow.async_configure( result3["flow_id"], { - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 1.0, + CONF_LOCATION: { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.0, + } }, ) await hass.async_block_till_done() @@ -78,8 +84,10 @@ async def test_form(hass: HomeAssistant) -> None: assert result4["type"] == RESULT_TYPE_CREATE_ENTRY assert result4["title"] == "Weather 1.0 1.0" assert result4["data"] == { - "latitude": 1.0, - "longitude": 1.0, + "location": { + "latitude": 1.0, + "longitude": 1.0, + }, "name": "Weather", } @@ -97,8 +105,10 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 0.0, - CONF_LONGITUDE: 0.0, + CONF_LOCATION: { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + } }, ) await hass.async_block_till_done() @@ -117,8 +127,10 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 2.0, - CONF_LONGITUDE: 2.0, + CONF_LOCATION: { + CONF_LATITUDE: 2.0, + CONF_LONGITUDE: 2.0, + } }, ) await hass.async_block_till_done() @@ -126,8 +138,10 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: assert result3["type"] == RESULT_TYPE_CREATE_ENTRY assert result3["title"] == "Weather 2.0 2.0" assert result3["data"] == { - "latitude": 2.0, - "longitude": 2.0, + "location": { + "latitude": 2.0, + "longitude": 2.0, + }, "name": "Weather", } @@ -136,10 +150,12 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: """Test we handle unique id already exist.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="1.0-1.0", + unique_id="smhi-1.0-1.0", data={ - "latitude": 1.0, - "longitude": 1.0, + "location": { + "latitude": 1.0, + "longitude": 1.0, + }, "name": "Weather", }, ) @@ -155,8 +171,10 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - CONF_LATITUDE: 1.0, - CONF_LONGITUDE: 1.0, + CONF_LOCATION: { + CONF_LATITUDE: 1.0, + CONF_LONGITUDE: 1.0, + } }, ) await hass.async_block_till_done() diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index 2cf54ba7533..ea6d55fabf6 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -3,8 +3,9 @@ from smhi.smhi_lib import APIURL_TEMPLATE from homeassistant.components.smhi.const import DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get -from . import ENTITY_ID, TEST_CONFIG +from . import ENTITY_ID, TEST_CONFIG, TEST_CONFIG_MIGRATE from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -14,9 +15,11 @@ async def test_setup_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test setup entry.""" - uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -30,9 +33,11 @@ async def test_remove_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test remove entry.""" - uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -46,3 +51,38 @@ async def test_remove_entry( state = hass.states.get(ENTITY_ID) assert not state + + +async def test_migrate_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: + """Test migrate entry and entities unique id.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry.add_to_hass(hass) + assert entry.version == 1 + + entity_reg = async_get(hass) + entity = entity_reg.async_get_or_create( + domain="weather", + config_entry=entry, + original_name="Weather", + platform="smhi", + supported_features=0, + unique_id="17.84197, 17.84197", + ) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(entity.entity_id) + assert state + + assert entry.version == 2 + assert entry.unique_id == "smhi-17.84197-17.84197" + + entity_get = entity_reg.async_get(entity.entity_id) + assert entity_get.unique_id == "smhi-17.84197-17.84197" diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py index 0097a7a5c5a..f33e8c9fa71 100644 --- a/tests/components/smhi/test_weather.py +++ b/tests/components/smhi/test_weather.py @@ -46,10 +46,12 @@ async def test_setup_hass( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test for successfully setting up the smhi integration.""" - uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) @@ -87,7 +89,7 @@ async def test_setup_hass( async def test_properties_no_data(hass: HomeAssistant) -> None: """Test properties when no API data available.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) with patch( @@ -176,7 +178,7 @@ async def test_properties_unknown_symbol(hass: HomeAssistant) -> None: testdata = [data, data2, data3] - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) with patch( @@ -203,7 +205,7 @@ async def test_refresh_weather_forecast_retry( hass: HomeAssistant, error: Exception ) -> None: """Test the refresh weather forecast function.""" - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) now = utcnow() @@ -320,10 +322,12 @@ async def test_custom_speed_unit( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: """Test Wind Gust speed with custom unit.""" - uri = APIURL_TEMPLATE.format(TEST_CONFIG["longitude"], TEST_CONFIG["latitude"]) + uri = APIURL_TEMPLATE.format( + TEST_CONFIG["location"]["longitude"], TEST_CONFIG["location"]["latitude"] + ) aioclient_mock.get(uri, text=api_response) - entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG) + entry = MockConfigEntry(domain="smhi", data=TEST_CONFIG, version=2) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) From b75a6d265d18d1732f8572dc21c50d03809a4c4a Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 14:49:01 +0200 Subject: [PATCH 766/947] Use attributes in spc alarm and binary sensor (#74120) --- .../components/spc/alarm_control_panel.py | 22 +++++++-------- homeassistant/components/spc/binary_sensor.py | 27 +++++++++---------- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index a1b4e2b2392..b78703666bc 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -1,6 +1,8 @@ """Support for Vanderbilt (formerly Siemens) SPC alarm systems.""" from __future__ import annotations +from pyspcwebgw import SpcWebGateway +from pyspcwebgw.area import Area from pyspcwebgw.const import AreaMode import homeassistant.components.alarm_control_panel as alarm @@ -20,7 +22,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_API, SIGNAL_UPDATE_ALARM -def _get_alarm_state(area): +def _get_alarm_state(area: Area) -> str | None: """Get the alarm state.""" if area.verified_alarm: @@ -44,20 +46,21 @@ async def async_setup_platform( """Set up the SPC alarm control panel platform.""" if discovery_info is None: return - api = hass.data[DATA_API] + api: SpcWebGateway = hass.data[DATA_API] async_add_entities([SpcAlarm(area=area, api=api) for area in api.areas.values()]) class SpcAlarm(alarm.AlarmControlPanelEntity): """Representation of the SPC alarm panel.""" + _attr_should_poll = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.ARM_NIGHT ) - def __init__(self, area, api): + def __init__(self, area: Area, api: SpcWebGateway) -> None: """Initialize the SPC alarm panel.""" self._area = area self._api = api @@ -73,27 +76,22 @@ class SpcAlarm(alarm.AlarmControlPanelEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._area.name @property - def changed_by(self): + def changed_by(self) -> str: """Return the user the last change was triggered by.""" return self._area.last_changed_by @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" return _get_alarm_state(self._area) diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index 87068d97b8a..c4aaefdd518 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -1,7 +1,9 @@ """Support for Vanderbilt (formerly Siemens) SPC alarm systems.""" from __future__ import annotations +from pyspcwebgw import SpcWebGateway from pyspcwebgw.const import ZoneInput, ZoneType +from pyspcwebgw.zone import Zone from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -15,12 +17,12 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import DATA_API, SIGNAL_UPDATE_SENSOR -def _get_device_class(zone_type): +def _get_device_class(zone_type: ZoneType) -> BinarySensorDeviceClass | None: return { ZoneType.ALARM: BinarySensorDeviceClass.MOTION, ZoneType.ENTRY_EXIT: BinarySensorDeviceClass.OPENING, ZoneType.FIRE: BinarySensorDeviceClass.SMOKE, - ZoneType.TECHNICAL: "power", + ZoneType.TECHNICAL: BinarySensorDeviceClass.POWER, }.get(zone_type) @@ -33,7 +35,7 @@ async def async_setup_platform( """Set up the SPC binary sensor.""" if discovery_info is None: return - api = hass.data[DATA_API] + api: SpcWebGateway = hass.data[DATA_API] async_add_entities( [ SpcBinarySensor(zone) @@ -46,11 +48,13 @@ async def async_setup_platform( class SpcBinarySensor(BinarySensorEntity): """Representation of a sensor based on a SPC zone.""" - def __init__(self, zone): + _attr_should_poll = False + + def __init__(self, zone: Zone) -> None: """Initialize the sensor device.""" self._zone = zone - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call for adding new entities.""" self.async_on_remove( async_dispatcher_connect( @@ -61,26 +65,21 @@ class SpcBinarySensor(BinarySensorEntity): ) @callback - def _update_callback(self): + def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) @property - def name(self): + def name(self) -> str: """Return the name of the device.""" return self._zone.name @property - def is_on(self): + def is_on(self) -> bool: """Whether the device is switched on.""" return self._zone.input == ZoneInput.OPEN @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def device_class(self): + def device_class(self) -> BinarySensorDeviceClass | None: """Return the device class.""" return _get_device_class(self._zone.type) From 3836da48b36107feadfcb1a451af1b62eda55530 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 15:12:56 +0200 Subject: [PATCH 767/947] Use attributes in ness_alarm alarm (#74121) --- .../ness_alarm/alarm_control_panel.py | 45 ++++++------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py index 0e80ac57e01..2f54b3abde6 100644 --- a/homeassistant/components/ness_alarm/alarm_control_panel.py +++ b/homeassistant/components/ness_alarm/alarm_control_panel.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from nessclient import ArmingState +from nessclient import ArmingState, Client import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel import AlarmControlPanelEntityFeature @@ -41,17 +41,18 @@ async def async_setup_platform( class NessAlarmPanel(alarm.AlarmControlPanelEntity): """Representation of a Ness alarm panel.""" + _attr_code_format = alarm.CodeFormat.NUMBER + _attr_should_poll = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY | AlarmControlPanelEntityFeature.TRIGGER ) - def __init__(self, client, name): + def __init__(self, client: Client, name: str) -> None: """Initialize the alarm panel.""" self._client = client - self._name = name - self._state = None + self._attr_name = name async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -61,26 +62,6 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): ) ) - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def code_format(self): - """Return the regex for code format or None if no code is required.""" - return alarm.CodeFormat.NUMBER - - @property - def state(self): - """Return the state of the device.""" - return self._state - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" await self._client.disarm(code) @@ -98,23 +79,23 @@ class NessAlarmPanel(alarm.AlarmControlPanelEntity): await self._client.panic(code) @callback - def _handle_arming_state_change(self, arming_state): + def _handle_arming_state_change(self, arming_state: ArmingState) -> None: """Handle arming state update.""" if arming_state == ArmingState.UNKNOWN: - self._state = None + self._attr_state = None elif arming_state == ArmingState.DISARMED: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif arming_state == ArmingState.ARMING: - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING elif arming_state == ArmingState.EXIT_DELAY: - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING elif arming_state == ArmingState.ARMED: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif arming_state == ArmingState.ENTRY_DELAY: - self._state = STATE_ALARM_PENDING + self._attr_state = STATE_ALARM_PENDING elif arming_state == ArmingState.TRIGGERED: - self._state = STATE_ALARM_TRIGGERED + self._attr_state = STATE_ALARM_TRIGGERED else: _LOGGER.warning("Unhandled arming state: %s", arming_state) From 29c389b34230dfcbd9af6bd26d0f7c44848900b1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 15:22:46 +0200 Subject: [PATCH 768/947] Adjust remaining type hints in alarm properties (#74126) --- homeassistant/components/envisalink/alarm_control_panel.py | 4 ++-- homeassistant/components/tuya/alarm_control_panel.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py index 35cfe8558a8..ad65bf70275 100644 --- a/homeassistant/components/envisalink/alarm_control_panel.py +++ b/homeassistant/components/envisalink/alarm_control_panel.py @@ -141,14 +141,14 @@ class EnvisalinkAlarm(EnvisalinkDevice, AlarmControlPanelEntity): self.async_write_ha_state() @property - def code_format(self): + def code_format(self) -> CodeFormat | None: """Regex for code format or None if no code is required.""" if self._code: return None return CodeFormat.NUMBER @property - def state(self): + def state(self) -> str: """Return the state of the device.""" state = STATE_UNKNOWN diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index aca09131b4c..aae50902d03 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -116,7 +116,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity): self._attr_supported_features |= AlarmControlPanelEntityFeature.TRIGGER @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if not (status := self.device.status.get(self.entity_description.key)): return None From 0063274f83b4744d68f47f51b9d847420cf05397 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jun 2022 06:24:20 -0700 Subject: [PATCH 769/947] Bump HAP-python to 4.5.0 (#74127) --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index bde540d6372..2ca78b6d915 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==4.4.0", + "HAP-python==4.5.0", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1" diff --git a/requirements_all.txt b/requirements_all.txt index 5f5fc9fe4d6..b15ce59108e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -8,7 +8,7 @@ AEMET-OpenData==0.2.1 Adax-local==0.1.4 # homeassistant.components.homekit -HAP-python==4.4.0 +HAP-python==4.5.0 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 24903c43b30..a1a8604659e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -10,7 +10,7 @@ AEMET-OpenData==0.2.1 Adax-local==0.1.4 # homeassistant.components.homekit -HAP-python==4.4.0 +HAP-python==4.5.0 # homeassistant.components.flick_electric PyFlick==0.0.2 From 670af6fde342f5a1bcd7fa0cf6630b1546d5dcc1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 15:26:50 +0200 Subject: [PATCH 770/947] Use attributes in risco alarm (#74117) --- .../components/risco/alarm_control_panel.py | 44 +++++++------------ 1 file changed, 16 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/risco/alarm_control_panel.py b/homeassistant/components/risco/alarm_control_panel.py index f814be0f2bd..3bad03fda10 100644 --- a/homeassistant/components/risco/alarm_control_panel.py +++ b/homeassistant/components/risco/alarm_control_panel.py @@ -20,6 +20,7 @@ from homeassistant.const import ( STATE_ALARM_TRIGGERED, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -65,44 +66,46 @@ async def async_setup_entry( class RiscoAlarm(AlarmControlPanelEntity, RiscoEntity): """Representation of a Risco partition.""" + _attr_code_format = CodeFormat.NUMBER + def __init__(self, coordinator, partition_id, code, options): """Init the partition.""" super().__init__(coordinator) self._partition_id = partition_id self._partition = self.coordinator.data.partitions[self._partition_id] self._code = code - self._code_arm_required = options[CONF_CODE_ARM_REQUIRED] + self._attr_code_arm_required = options[CONF_CODE_ARM_REQUIRED] self._code_disarm_required = options[CONF_CODE_DISARM_REQUIRED] self._risco_to_ha = options[CONF_RISCO_STATES_TO_HA] self._ha_to_risco = options[CONF_HA_STATES_TO_RISCO] - self._supported_states = 0 + self._attr_supported_features = 0 for state in self._ha_to_risco: - self._supported_states |= STATES_TO_SUPPORTED_FEATURES[state] + self._attr_supported_features |= STATES_TO_SUPPORTED_FEATURES[state] def _get_data_from_coordinator(self): self._partition = self.coordinator.data.partitions[self._partition_id] @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "Risco", - } + return DeviceInfo( + identifiers={(DOMAIN, self.unique_id)}, + name=self.name, + manufacturer="Risco", + ) @property - def name(self): + def name(self) -> str: """Return the name of the partition.""" return f"Risco {self._risco.site_name} Partition {self._partition_id}" @property - def unique_id(self): + def unique_id(self) -> str: """Return a unique id for that partition.""" return f"{self._risco.site_uuid}_{self._partition_id}" @property - def state(self): + def state(self) -> str | None: """Return the state of the device.""" if self._partition.triggered: return STATE_ALARM_TRIGGERED @@ -121,21 +124,6 @@ class RiscoAlarm(AlarmControlPanelEntity, RiscoEntity): return None - @property - def supported_features(self): - """Return the list of supported features.""" - return self._supported_states - - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - - @property - def code_format(self): - """Return one or more digits/characters.""" - return CodeFormat.NUMBER - def _validate_code(self, code): """Validate given code.""" return code == self._code @@ -164,7 +152,7 @@ class RiscoAlarm(AlarmControlPanelEntity, RiscoEntity): await self._arm(STATE_ALARM_ARMED_CUSTOM_BYPASS, code) async def _arm(self, mode, code): - if self._code_arm_required and not self._validate_code(code): + if self.code_arm_required and not self._validate_code(code): _LOGGER.warning("Wrong code entered for %s", mode) return From f3a24d5a45acd8221d82ae5a09d889cfb241a2f4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 15:29:33 +0200 Subject: [PATCH 771/947] Use attributes in xiaomi_miio alarm (#74125) --- .../xiaomi_miio/alarm_control_panel.py | 64 ++++--------------- 1 file changed, 14 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py index be7daf5e077..b5057a4a3dd 100644 --- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py +++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py @@ -51,6 +51,7 @@ async def async_setup_entry( class XiaomiGatewayAlarm(AlarmControlPanelEntity): """Representation of the XiaomiGatewayAlarm.""" + _attr_icon = "mdi:shield-home" _attr_supported_features = AlarmControlPanelEntityFeature.ARM_AWAY def __init__( @@ -58,50 +59,13 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): ): """Initialize the entity.""" self._gateway = gateway_device - self._name = gateway_name - self._gateway_device_id = gateway_device_id - self._unique_id = f"{model}-{mac_address}" - self._icon = "mdi:shield-home" - self._available = None - self._state = None - - @property - def unique_id(self): - """Return an unique ID.""" - return self._unique_id - - @property - def device_id(self): - """Return the device id of the gateway.""" - return self._gateway_device_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the gateway.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway_device_id)}, + self._attr_name = gateway_name + self._attr_unique_id = f"{model}-{mac_address}" + self._attr_available = False + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gateway_device_id)}, ) - @property - def name(self): - """Return the name of this entity, if any.""" - return self._name - - @property - def icon(self): - """Return the icon to use for device if any.""" - return self._icon - - @property - def available(self): - """Return true when state is known.""" - return self._available - - @property - def state(self): - """Return the state of the device.""" - return self._state - async def _try_command(self, mask_error, func, *args, **kwargs): """Call a device command handling error messages.""" try: @@ -129,22 +93,22 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): try: state = await self.hass.async_add_executor_job(self._gateway.alarm.status) except DeviceException as ex: - if self._available: - self._available = False + if self._attr_available: + self._attr_available = False _LOGGER.error("Got exception while fetching the state: %s", ex) return _LOGGER.debug("Got new state: %s", state) - self._available = True + self._attr_available = True if state == XIAOMI_STATE_ARMED_VALUE: - self._state = STATE_ALARM_ARMED_AWAY + self._attr_state = STATE_ALARM_ARMED_AWAY elif state == XIAOMI_STATE_DISARMED_VALUE: - self._state = STATE_ALARM_DISARMED + self._attr_state = STATE_ALARM_DISARMED elif state == XIAOMI_STATE_ARMING_VALUE: - self._state = STATE_ALARM_ARMING + self._attr_state = STATE_ALARM_ARMING else: _LOGGER.warning( "New state (%s) doesn't match expected values: %s/%s/%s", @@ -153,6 +117,6 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity): XIAOMI_STATE_DISARMED_VALUE, XIAOMI_STATE_ARMING_VALUE, ) - self._state = None + self._attr_state = None - _LOGGER.debug("State value: %s", self._state) + _LOGGER.debug("State value: %s", self._attr_state) From ed7ea1423a08eb56a41455c620be6438f54b1ca9 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 28 Jun 2022 15:53:00 +0200 Subject: [PATCH 772/947] Fix ZHA color mode not being set correctly when changing light state (#74018) --- homeassistant/components/zha/light.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 7c9e8d738a4..2f3379fa6b1 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -262,6 +262,7 @@ class BaseLight(LogMixin, light.LightEntity): if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return + self._color_mode = ColorMode.COLOR_TEMP self._color_temp = temperature self._hs_color = None @@ -275,6 +276,7 @@ class BaseLight(LogMixin, light.LightEntity): if isinstance(result, Exception) or result[1] is not Status.SUCCESS: self.debug("turned on: %s", t_log) return + self._color_mode = ColorMode.HS self._hs_color = hs_color self._color_temp = None From 0e9164b082f5f460d47c5f567f09f0c7c8e7e0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5Bp=CA=B2=C9=B5s=5D?= Date: Tue, 28 Jun 2022 16:22:09 +0200 Subject: [PATCH 773/947] Add bool template filter and function (#74068) Co-authored-by: Erik --- homeassistant/helpers/template.py | 41 ++++++++++++++++++++++++------- tests/helpers/test_template.py | 28 +++++++++++++++++++++ 2 files changed, 60 insertions(+), 9 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 53e2eb122f6..36f5dc9b22c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -19,7 +19,7 @@ import re import statistics from struct import error as StructError, pack, unpack_from import sys -from typing import Any, cast +from typing import Any, NoReturn, TypeVar, cast, overload from urllib.parse import urlencode as urllib_urlencode import weakref @@ -92,6 +92,8 @@ _COLLECTABLE_STATE_ATTRIBUTES = { "name", } +_T = TypeVar("_T") + ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) @@ -946,6 +948,31 @@ def _resolve_state( return None +@overload +def forgiving_boolean(value: Any) -> bool | object: + ... + + +@overload +def forgiving_boolean(value: Any, default: _T) -> bool | _T: + ... + + +def forgiving_boolean( + value: Any, default: _T | object = _SENTINEL +) -> bool | _T | object: + """Try to convert value to a boolean.""" + try: + # Import here, not at top-level to avoid circular import + from . import config_validation as cv # pylint: disable=import-outside-toplevel + + return cv.boolean(value) + except vol.Invalid: + if default is _SENTINEL: + raise_no_default("bool", value) + return default + + def result_as_boolean(template_result: Any | None) -> bool: """Convert the template result to a boolean. @@ -956,13 +983,7 @@ def result_as_boolean(template_result: Any | None) -> bool: if template_result is None: return False - try: - # Import here, not at top-level to avoid circular import - from . import config_validation as cv # pylint: disable=import-outside-toplevel - - return cv.boolean(template_result) - except vol.Invalid: - return False + return forgiving_boolean(template_result, default=False) def expand(hass: HomeAssistant, *args: Any) -> Iterable[State]: @@ -1368,7 +1389,7 @@ def utcnow(hass: HomeAssistant) -> datetime: return dt_util.utcnow() -def raise_no_default(function, value): +def raise_no_default(function: str, value: Any) -> NoReturn: """Log warning if no default is specified.""" template, action = template_cv.get() or ("", "rendering or compiling") raise ValueError( @@ -1981,6 +2002,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.filters["relative_time"] = relative_time self.filters["slugify"] = slugify self.filters["iif"] = iif + self.filters["bool"] = forgiving_boolean self.globals["log"] = logarithm self.globals["sin"] = sine self.globals["cos"] = cosine @@ -2012,6 +2034,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment): self.globals["unpack"] = struct_unpack self.globals["slugify"] = slugify self.globals["iif"] = iif + self.globals["bool"] = forgiving_boolean self.tests["is_number"] = is_number self.tests["match"] = regex_match self.tests["search"] = regex_search diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 59b653bc23e..fb57eff7685 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -296,6 +296,34 @@ def test_int_function(hass): assert render(hass, "{{ int('bad', default=1) }}") == 1 +def test_bool_function(hass): + """Test bool function.""" + assert render(hass, "{{ bool(true) }}") is True + assert render(hass, "{{ bool(false) }}") is False + assert render(hass, "{{ bool('on') }}") is True + assert render(hass, "{{ bool('off') }}") is False + with pytest.raises(TemplateError): + render(hass, "{{ bool('unknown') }}") + with pytest.raises(TemplateError): + render(hass, "{{ bool(none) }}") + assert render(hass, "{{ bool('unavailable', none) }}") is None + assert render(hass, "{{ bool('unavailable', default=none) }}") is None + + +def test_bool_filter(hass): + """Test bool filter.""" + assert render(hass, "{{ true | bool }}") is True + assert render(hass, "{{ false | bool }}") is False + assert render(hass, "{{ 'on' | bool }}") is True + assert render(hass, "{{ 'off' | bool }}") is False + with pytest.raises(TemplateError): + render(hass, "{{ 'unknown' | bool }}") + with pytest.raises(TemplateError): + render(hass, "{{ none | bool }}") + assert render(hass, "{{ 'unavailable' | bool(none) }}") is None + assert render(hass, "{{ 'unavailable' | bool(default=none) }}") is None + + @pytest.mark.parametrize( "value, expected", [ From 12c49e1c94e8e871ff4953fcfa6b41f499f51902 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 28 Jun 2022 10:41:21 -0400 Subject: [PATCH 774/947] Add Aqara FP1 configuration entities to ZHA (#73027) * add multi state input * config entities * remove multistate input sensor used for testing * mypy --- homeassistant/components/zha/button.py | 14 +++++++- .../zha/core/channels/manufacturerspecific.py | 7 ++++ homeassistant/components/zha/select.py | 35 ++++++++++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 29bfb2ca248..0f98bfaad51 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -154,9 +154,21 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity): }, ) class FrostLockResetButton(ZHAAttributeButton, id_suffix="reset_frost_lock"): - """Defines a ZHA identify button.""" + """Defines a ZHA frost lock reset button.""" _attribute_name = "frost_lock_reset" _attribute_value = 0 _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +class NoPresenceStatusResetButton( + ZHAAttributeButton, id_suffix="reset_no_presence_status" +): + """Defines a ZHA no presence status reset button.""" + + _attribute_name = "reset_no_presence_status" + _attribute_value = 1 + _attr_device_class = ButtonDeviceClass.RESTART + _attr_entity_category = EntityCategory.CONFIG diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 0c246e28db7..101db65a66e 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -71,6 +71,13 @@ class OppleRemote(ZigbeeChannel): "motion_sensitivity": True, "trigger_indicator": True, } + elif self.cluster.endpoint.model == "lumi.motion.ac01": + self.ZCL_INIT_ATTRS = { # pylint: disable=invalid-name + "presence": True, + "monitoring_mode": True, + "motion_sensitivity": True, + "approach_distance": True, + } async def async_initialize_channel_specific(self, from_cache: bool) -> None: """Initialize channel specific.""" diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 6d202ccceb2..79349273e38 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -225,9 +225,42 @@ class AqaraMotionSensitivities(types.enum8): High = 0x03 -@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac02"}) +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="opple_cluster", models={"lumi.motion.ac01", "lumi.motion.ac02"} +) class AqaraMotionSensitivity(ZCLEnumSelectEntity, id_suffix="motion_sensitivity"): """Representation of a ZHA on off transition time configuration entity.""" _select_attr = "motion_sensitivity" _enum = AqaraMotionSensitivities + + +class AqaraMonitoringModess(types.enum8): + """Aqara monitoring modes.""" + + Undirected = 0x00 + Left_Right = 0x01 + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +class AqaraMonitoringMode(ZCLEnumSelectEntity, id_suffix="monitoring_mode"): + """Representation of a ZHA monitoring mode configuration entity.""" + + _select_attr = "monitoring_mode" + _enum = AqaraMonitoringModess + + +class AqaraApproachDistances(types.enum8): + """Aqara approach distances.""" + + Far = 0x00 + Medium = 0x01 + Near = 0x02 + + +@CONFIG_DIAGNOSTIC_MATCH(channel_names="opple_cluster", models={"lumi.motion.ac01"}) +class AqaraApproachDistance(ZCLEnumSelectEntity, id_suffix="approach_distance"): + """Representation of a ZHA approach distance configuration entity.""" + + _select_attr = "approach_distance" + _enum = AqaraApproachDistances From a053a3a8a42a79b97d83b0d3ce990bb69fe2be7d Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 28 Jun 2022 11:01:27 -0400 Subject: [PATCH 775/947] Add cluster attr data to ZHA device diagnostics (#70238) * Add cluster attr data to ZHA device diagnostics * add unsupported attributes and refactor * remove await * make parseable --- homeassistant/components/zha/diagnostics.py | 84 ++++++++++++++++++++- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 5ae2ff23e96..697e7be336c 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -8,6 +8,8 @@ import bellows import pkg_resources import zigpy from zigpy.config import CONF_NWK_EXTENDED_PAN_ID +from zigpy.profiles import PROFILES +from zigpy.zcl import Cluster import zigpy_deconz import zigpy_xbee import zigpy_zigate @@ -15,11 +17,24 @@ import zigpy_znp from homeassistant.components.diagnostics.util import async_redact_data from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.const import CONF_ID, CONF_NAME, CONF_UNIQUE_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from .core.const import ATTR_IEEE, DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_GATEWAY +from .core.const import ( + ATTR_ATTRIBUTE_NAME, + ATTR_DEVICE_TYPE, + ATTR_IEEE, + ATTR_IN_CLUSTERS, + ATTR_OUT_CLUSTERS, + ATTR_PROFILE_ID, + ATTR_VALUE, + CONF_ALARM_MASTER_CODE, + DATA_ZHA, + DATA_ZHA_CONFIG, + DATA_ZHA_GATEWAY, + UNKNOWN, +) from .core.device import ZHADevice from .core.gateway import ZHAGateway from .core.helpers import async_get_zha_device @@ -27,11 +42,16 @@ from .core.helpers import async_get_zha_device KEYS_TO_REDACT = { ATTR_IEEE, CONF_UNIQUE_ID, + CONF_ALARM_MASTER_CODE, "network_key", CONF_NWK_EXTENDED_PAN_ID, "partner_ieee", } +ATTRIBUTES = "attributes" +CLUSTER_DETAILS = "cluster_details" +UNSUPPORTED_ATTRIBUTES = "unsupported_attributes" + def shallow_asdict(obj: Any) -> dict: """Return a shallow copy of a dataclass as a dict.""" @@ -77,4 +97,62 @@ async def async_get_device_diagnostics( ) -> dict: """Return diagnostics for a device.""" zha_device: ZHADevice = async_get_zha_device(hass, device.id) - return async_redact_data(zha_device.zha_device_info, KEYS_TO_REDACT) + device_info: dict[str, Any] = zha_device.zha_device_info + device_info[CLUSTER_DETAILS] = get_endpoint_cluster_attr_data(zha_device) + return async_redact_data(device_info, KEYS_TO_REDACT) + + +def get_endpoint_cluster_attr_data(zha_device: ZHADevice) -> dict: + """Return endpoint cluster attribute data.""" + cluster_details = {} + for ep_id, endpoint in zha_device.device.endpoints.items(): + if ep_id == 0: + continue + endpoint_key = ( + f"{PROFILES.get(endpoint.profile_id).DeviceType(endpoint.device_type).name}" + if PROFILES.get(endpoint.profile_id) is not None + and endpoint.device_type is not None + else UNKNOWN + ) + cluster_details[ep_id] = { + ATTR_DEVICE_TYPE: { + CONF_NAME: endpoint_key, + CONF_ID: endpoint.device_type, + }, + ATTR_PROFILE_ID: endpoint.profile_id, + ATTR_IN_CLUSTERS: { + f"0x{cluster_id:04x}": { + "endpoint_attribute": cluster.ep_attribute, + **get_cluster_attr_data(cluster), + } + for cluster_id, cluster in endpoint.in_clusters.items() + }, + ATTR_OUT_CLUSTERS: { + f"0x{cluster_id:04x}": { + "endpoint_attribute": cluster.ep_attribute, + **get_cluster_attr_data(cluster), + } + for cluster_id, cluster in endpoint.out_clusters.items() + }, + } + return cluster_details + + +def get_cluster_attr_data(cluster: Cluster) -> dict: + """Return cluster attribute data.""" + return { + ATTRIBUTES: { + f"0x{attr_id:04x}": { + ATTR_ATTRIBUTE_NAME: attr_def.name, + ATTR_VALUE: attr_value, + } + for attr_id, attr_def in cluster.attributes.items() + if (attr_value := cluster.get(attr_def.name)) is not None + }, + UNSUPPORTED_ATTRIBUTES: { + f"0x{cluster.find_attribute(u_attr).id:04x}": { + ATTR_ATTRIBUTE_NAME: cluster.find_attribute(u_attr).name + } + for u_attr in cluster.unsupported_attributes + }, + } From 2225d0e899a16870f7da2673988929cf87d9aa41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jun 2022 10:07:40 -0500 Subject: [PATCH 776/947] Enable serialization of float subclasses with orjson (#74136) --- homeassistant/helpers/json.py | 2 ++ tests/helpers/test_json.py | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index dbe3163da08..8b91f5eb2b5 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -35,6 +35,8 @@ def json_encoder_default(obj: Any) -> Any: """ if isinstance(obj, set): return list(obj) + if isinstance(obj, float): + return float(obj) if hasattr(obj, "as_dict"): return obj.as_dict() if isinstance(obj, Path): diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index 17066b682af..cfb403ca4a9 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -8,6 +8,7 @@ from homeassistant import core from homeassistant.helpers.json import ( ExtendedJSONEncoder, JSONEncoder, + json_dumps, json_dumps_sorted, ) from homeassistant.util import dt as dt_util @@ -77,3 +78,12 @@ def test_json_dumps_sorted(): assert json_dumps_sorted(data) == json.dumps( data, sort_keys=True, separators=(",", ":") ) + + +def test_json_dumps_float_subclass(): + """Test the json dumps a float subclass.""" + + class FloatSubclass(float): + """A float subclass.""" + + assert json_dumps({"c": FloatSubclass(1.2)}) == '{"c":1.2}' From dc039f52185c74c501da57a5f090f423184461ae Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 17:12:32 +0200 Subject: [PATCH 777/947] Use standard argument name in async_step_reauth (#74137) --- homeassistant/components/aladdin_connect/config_flow.py | 2 +- homeassistant/components/ambee/config_flow.py | 2 +- homeassistant/components/awair/config_flow.py | 2 +- homeassistant/components/brunt/config_flow.py | 2 +- homeassistant/components/cloudflare/config_flow.py | 2 +- homeassistant/components/deluge/config_flow.py | 2 +- homeassistant/components/discord/config_flow.py | 2 +- homeassistant/components/efergy/config_flow.py | 2 +- homeassistant/components/esphome/config_flow.py | 2 +- homeassistant/components/geocaching/config_flow.py | 2 +- homeassistant/components/google/config_flow.py | 2 +- homeassistant/components/laundrify/config_flow.py | 2 +- homeassistant/components/motioneye/config_flow.py | 5 +---- homeassistant/components/neato/config_flow.py | 2 +- homeassistant/components/netatmo/config_flow.py | 2 +- homeassistant/components/powerwall/config_flow.py | 2 +- homeassistant/components/pvoutput/config_flow.py | 2 +- homeassistant/components/sensibo/config_flow.py | 2 +- homeassistant/components/sonarr/config_flow.py | 2 +- homeassistant/components/steam_online/config_flow.py | 2 +- homeassistant/components/tailscale/config_flow.py | 2 +- homeassistant/components/tankerkoenig/config_flow.py | 2 +- homeassistant/components/tautulli/config_flow.py | 2 +- homeassistant/components/tractive/config_flow.py | 2 +- homeassistant/components/trafikverket_ferry/config_flow.py | 2 +- homeassistant/components/trafikverket_train/config_flow.py | 2 +- homeassistant/components/transmission/config_flow.py | 2 +- homeassistant/components/unifi/config_flow.py | 2 +- homeassistant/components/unifiprotect/config_flow.py | 2 +- homeassistant/components/uptimerobot/config_flow.py | 2 +- homeassistant/components/verisure/config_flow.py | 2 +- homeassistant/components/vlc_telnet/config_flow.py | 2 +- homeassistant/components/wallbox/config_flow.py | 2 +- homeassistant/components/yale_smart_alarm/config_flow.py | 2 +- homeassistant/components/yolink/config_flow.py | 2 +- 35 files changed, 35 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index f0f622b3ab7..0b928e9d423 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -45,7 +45,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 entry: config_entries.ConfigEntry | None - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Aladdin Connect.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/ambee/config_flow.py b/homeassistant/components/ambee/config_flow.py index 6c11f01b759..7bfc1fa11af 100644 --- a/homeassistant/components/ambee/config_flow.py +++ b/homeassistant/components/ambee/config_flow.py @@ -72,7 +72,7 @@ class AmbeeFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Ambee.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py index fc7fd1e79a4..1e83144945d 100644 --- a/homeassistant/components/awair/config_flow.py +++ b/homeassistant/components/awair/config_flow.py @@ -48,7 +48,7 @@ class AwairFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-auth if token invalid.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/brunt/config_flow.py b/homeassistant/components/brunt/config_flow.py index bba58deea45..cfd3bfa69cb 100644 --- a/homeassistant/components/brunt/config_flow.py +++ b/homeassistant/components/brunt/config_flow.py @@ -78,7 +78,7 @@ class BruntConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/cloudflare/config_flow.py b/homeassistant/components/cloudflare/config_flow.py index af67cbe8ffc..215411bc667 100644 --- a/homeassistant/components/cloudflare/config_flow.py +++ b/homeassistant/components/cloudflare/config_flow.py @@ -98,7 +98,7 @@ class CloudflareConfigFlow(ConfigFlow, domain=DOMAIN): self.zones: list[str] | None = None self.records: list[str] | None = None - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Cloudflare.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/deluge/config_flow.py b/homeassistant/components/deluge/config_flow.py index a4bb1893b7e..359ed1635c5 100644 --- a/homeassistant/components/deluge/config_flow.py +++ b/homeassistant/components/deluge/config_flow.py @@ -76,7 +76,7 @@ class DelugeFlowHandler(ConfigFlow, domain=DOMAIN): ) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_user() diff --git a/homeassistant/components/discord/config_flow.py b/homeassistant/components/discord/config_flow.py index 93027132850..b28c55b022f 100644 --- a/homeassistant/components/discord/config_flow.py +++ b/homeassistant/components/discord/config_flow.py @@ -23,7 +23,7 @@ CONFIG_SCHEMA = vol.Schema({vol.Required(CONF_API_TOKEN): str}) class DiscordFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Discord.""" - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/efergy/config_flow.py b/homeassistant/components/efergy/config_flow.py index 0abf99c2504..b2f2a368a9e 100644 --- a/homeassistant/components/efergy/config_flow.py +++ b/homeassistant/components/efergy/config_flow.py @@ -53,7 +53,7 @@ class EfergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_user() diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 552d7ed420e..76359cda4e7 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -64,7 +64,7 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): """Handle a flow initialized by the user.""" return await self._async_step_user_base(user_input=user_input) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a flow initialized by a reauth event.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry is not None diff --git a/homeassistant/components/geocaching/config_flow.py b/homeassistant/components/geocaching/config_flow.py index 9ce3cb76775..56fa56a1f82 100644 --- a/homeassistant/components/geocaching/config_flow.py +++ b/homeassistant/components/geocaching/config_flow.py @@ -25,7 +25,7 @@ class GeocachingFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): """Return logger.""" return logging.getLogger(__name__) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index a5951edec22..cbe1de69f9e 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -156,7 +156,7 @@ class OAuth2FlowHandler( }, ) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/laundrify/config_flow.py b/homeassistant/components/laundrify/config_flow.py index c091324d9a7..55a29fec2e7 100644 --- a/homeassistant/components/laundrify/config_flow.py +++ b/homeassistant/components/laundrify/config_flow.py @@ -77,7 +77,7 @@ class LaundrifyConfigFlow(ConfigFlow, domain=DOMAIN): step_id="init", data_schema=CONFIG_SCHEMA, errors=errors ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 6b88f47a588..662c4d23660 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -157,10 +157,7 @@ class MotionEyeConfigFlow(ConfigFlow, domain=DOMAIN): data=user_input, ) - async def async_step_reauth( - self, - config_data: Mapping[str, Any], - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthentication flow.""" return await self.async_step_user() diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 1c180fe1dbd..6b31cf9c05d 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -35,7 +35,7 @@ class OAuth2FlowHandler( return await super().async_step_user(user_input=user_input) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/netatmo/config_flow.py b/homeassistant/components/netatmo/config_flow.py index 125e6ee38e5..ba63c76ad66 100644 --- a/homeassistant/components/netatmo/config_flow.py +++ b/homeassistant/components/netatmo/config_flow.py @@ -68,7 +68,7 @@ class NetatmoFlowHandler( return await super().async_step_user(user_input) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/powerwall/config_flow.py b/homeassistant/components/powerwall/config_flow.py index b541c1b4bf7..b9f6f3969fd 100644 --- a/homeassistant/components/powerwall/config_flow.py +++ b/homeassistant/components/powerwall/config_flow.py @@ -206,7 +206,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/pvoutput/config_flow.py b/homeassistant/components/pvoutput/config_flow.py index 25cc68acc24..2016f87e611 100644 --- a/homeassistant/components/pvoutput/config_flow.py +++ b/homeassistant/components/pvoutput/config_flow.py @@ -84,7 +84,7 @@ class PVOutputFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with PVOutput.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/sensibo/config_flow.py b/homeassistant/components/sensibo/config_flow.py index a3214bdad56..c7aaa30b3db 100644 --- a/homeassistant/components/sensibo/config_flow.py +++ b/homeassistant/components/sensibo/config_flow.py @@ -29,7 +29,7 @@ class SensiboConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Sensibo.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py index 8e34d7a7ed4..3ea386faa78 100644 --- a/homeassistant/components/sonarr/config_flow.py +++ b/homeassistant/components/sonarr/config_flow.py @@ -63,7 +63,7 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return SonarrOptionsFlowHandler(config_entry) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/steam_online/config_flow.py b/homeassistant/components/steam_online/config_flow.py index 94b3e8d809c..7ed1f0a3610 100644 --- a/homeassistant/components/steam_online/config_flow.py +++ b/homeassistant/components/steam_online/config_flow.py @@ -126,7 +126,7 @@ class SteamFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): import_config[CONF_ACCOUNT] = import_config[CONF_ACCOUNTS][0] return await self.async_step_user(import_config) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/tailscale/config_flow.py b/homeassistant/components/tailscale/config_flow.py index a51cb722988..5f28c566801 100644 --- a/homeassistant/components/tailscale/config_flow.py +++ b/homeassistant/components/tailscale/config_flow.py @@ -82,7 +82,7 @@ class TailscaleFlowHandler(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Tailscale.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/tankerkoenig/config_flow.py b/homeassistant/components/tankerkoenig/config_flow.py index 77baeddfce9..e3d273825a5 100644 --- a/homeassistant/components/tankerkoenig/config_flow.py +++ b/homeassistant/components/tankerkoenig/config_flow.py @@ -144,7 +144,7 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): options={CONF_SHOW_ON_MAP: True}, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/tautulli/config_flow.py b/homeassistant/components/tautulli/config_flow.py index b4f3e3985ec..d70384c5485 100644 --- a/homeassistant/components/tautulli/config_flow.py +++ b/homeassistant/components/tautulli/config_flow.py @@ -71,7 +71,7 @@ class TautulliConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/tractive/config_flow.py b/homeassistant/components/tractive/config_flow.py index 647d97f7179..ba42aeb600d 100644 --- a/homeassistant/components/tractive/config_flow.py +++ b/homeassistant/components/tractive/config_flow.py @@ -70,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index 2b0a1dec655..1f5d19118eb 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -60,7 +60,7 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ferry_api = TrafikverketFerry(web_session, api_key) await ferry_api.async_get_next_ferry_stop(ferry_from, ferry_to) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index 521e499ec5d..c620e264142 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -55,7 +55,7 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await train_api.async_get_train_station(train_from) await train_api.async_get_train_station(train_to) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py index c0bc51b0683..a21fe9b8837 100644 --- a/homeassistant/components/transmission/config_flow.py +++ b/homeassistant/components/transmission/config_flow.py @@ -92,7 +92,7 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 50e578b1dae..fcf5970bf6c 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -202,7 +202,7 @@ class UnifiFlowHandler(config_entries.ConfigFlow, domain=UNIFI_DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" config_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/unifiprotect/config_flow.py b/homeassistant/components/unifiprotect/config_flow.py index 8e114c4f38b..330a5e530a1 100644 --- a/homeassistant/components/unifiprotect/config_flow.py +++ b/homeassistant/components/unifiprotect/config_flow.py @@ -266,7 +266,7 @@ class ProtectFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return nvr_data, errors - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 83371bdd4a7..14ec1ae6cdc 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -85,7 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Return the reauth confirm step.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/verisure/config_flow.py b/homeassistant/components/verisure/config_flow.py index 91bde6db219..41687dbc6a4 100644 --- a/homeassistant/components/verisure/config_flow.py +++ b/homeassistant/components/verisure/config_flow.py @@ -109,7 +109,7 @@ class VerisureConfigFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Verisure.""" self.entry = cast( ConfigEntry, diff --git a/homeassistant/components/vlc_telnet/config_flow.py b/homeassistant/components/vlc_telnet/config_flow.py index 9c97e876e1b..35898e91b34 100644 --- a/homeassistant/components/vlc_telnet/config_flow.py +++ b/homeassistant/components/vlc_telnet/config_flow.py @@ -105,7 +105,7 @@ class VLCTelnetConfigFlow(ConfigFlow, domain=DOMAIN): step_id="user", data_schema=user_form_schema(user_input), errors=errors ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth flow.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert self.entry diff --git a/homeassistant/components/wallbox/config_flow.py b/homeassistant/components/wallbox/config_flow.py index dbd1f3612a5..85f5d02ba99 100644 --- a/homeassistant/components/wallbox/config_flow.py +++ b/homeassistant/components/wallbox/config_flow.py @@ -48,7 +48,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=COMPONENT_DOMAIN): """Start the Wallbox config flow.""" self._reauth_entry: config_entries.ConfigEntry | None = None - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/yale_smart_alarm/config_flow.py b/homeassistant/components/yale_smart_alarm/config_flow.py index a3f350cef23..a2462df41cb 100644 --- a/homeassistant/components/yale_smart_alarm/config_flow.py +++ b/homeassistant/components/yale_smart_alarm/config_flow.py @@ -54,7 +54,7 @@ class YaleConfigFlow(ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return YaleOptionsFlowHandler(config_entry) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Yale.""" self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/yolink/config_flow.py b/homeassistant/components/yolink/config_flow.py index 68eabdfa183..128cd6cb35c 100644 --- a/homeassistant/components/yolink/config_flow.py +++ b/homeassistant/components/yolink/config_flow.py @@ -31,7 +31,7 @@ class OAuth2FlowHandler( scopes = ["create"] return {"scope": " ".join(scopes)} - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] From b51ad16db9e5cefb7224c08d6658940af626ed36 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 17:19:03 +0200 Subject: [PATCH 778/947] Adjust button type hints in components (#74132) --- homeassistant/components/august/button.py | 2 +- homeassistant/components/bond/button.py | 3 +-- homeassistant/components/esphome/button.py | 3 +-- homeassistant/components/mqtt/button.py | 2 +- homeassistant/components/octoprint/button.py | 3 ++- homeassistant/components/tuya/button.py | 4 +--- homeassistant/components/xiaomi_miio/button.py | 3 +-- homeassistant/components/yale_smart_alarm/button.py | 4 ++-- homeassistant/components/zwave_me/button.py | 4 +--- 9 files changed, 11 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/august/button.py b/homeassistant/components/august/button.py index 5f4032153a2..c96db61ca1a 100644 --- a/homeassistant/components/august/button.py +++ b/homeassistant/components/august/button.py @@ -30,7 +30,7 @@ class AugustWakeLockButton(AugustEntityMixin, ButtonEntity): self._attr_name = f"{device.device_name} Wake" self._attr_unique_id = f"{self._device_id}_wake" - async def async_press(self, **kwargs): + async def async_press(self) -> None: """Wake the device.""" await self._data.async_status_async(self._device_id, self._hyper_bridge) diff --git a/homeassistant/components/bond/button.py b/homeassistant/components/bond/button.py index 2c6ffc69693..9a82309e347 100644 --- a/homeassistant/components/bond/button.py +++ b/homeassistant/components/bond/button.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from bond_async import Action, BPUPSubscriptions @@ -290,7 +289,7 @@ class BondButtonEntity(BondEntity, ButtonEntity): hub, device, bpup_subs, description.name, description.key.lower() ) - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" if self.entity_description.argument: action = Action( diff --git a/homeassistant/components/esphome/button.py b/homeassistant/components/esphome/button.py index 5b6f2c153c8..3f610c8bbfa 100644 --- a/homeassistant/components/esphome/button.py +++ b/homeassistant/components/esphome/button.py @@ -2,7 +2,6 @@ from __future__ import annotations from contextlib import suppress -from typing import Any from aioesphomeapi import ButtonInfo, EntityState @@ -46,6 +45,6 @@ class EsphomeButton(EsphomeEntity[ButtonInfo, EntityState], ButtonEntity): # never gets a state update. self._on_state_update() - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" await self._client.button_command(self._static_info.key) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 370243c3579..0374727bf7d 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -130,7 +130,7 @@ class MqttButton(MqttEntity, ButtonEntity): """Return the device class of the sensor.""" return self._config.get(CONF_DEVICE_CLASS) - async def async_press(self, **kwargs): + async def async_press(self) -> None: """Turn the device on. This method is a coroutine. diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index e16f123a73a..0d403c3ec87 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -5,6 +5,7 @@ from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -54,7 +55,7 @@ class OctoprintButton(CoordinatorEntity[OctoprintDataUpdateCoordinator], ButtonE self._attr_unique_id = f"{button_type}-{device_id}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Device info.""" return self.coordinator.device_info diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 3b4a2883266..26014e53b74 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -1,8 +1,6 @@ """Support for Tuya buttons.""" from __future__ import annotations -from typing import Any - from tuya_iot import TuyaDevice, TuyaDeviceManager from homeassistant.components.button import ButtonEntity, ButtonEntityDescription @@ -109,6 +107,6 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity): self.entity_description = description self._attr_unique_id = f"{super().unique_id}{description.key}" - def press(self, **kwargs: Any) -> None: + def press(self) -> None: """Press the button.""" self._send_command([{"code": self.entity_description.key, "value": True}]) diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 6a69289f7ef..0f5b59a262d 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -2,7 +2,6 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any from homeassistant.components.button import ( ButtonDeviceClass, @@ -111,7 +110,7 @@ class XiaomiGenericCoordinatedButton(XiaomiCoordinatedMiioEntity, ButtonEntity): super().__init__(name, device, entry, unique_id, coordinator) self.entity_description = description - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" method = getattr(self._device, self.entity_description.method_press) await self._try_command( diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index 081c25c4342..cd312e79ceb 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -1,7 +1,7 @@ """Support for Yale Smart Alarm button.""" from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry @@ -50,7 +50,7 @@ class YalePanicButton(YaleAlarmEntity, ButtonEntity): self._attr_name = f"{coordinator.entry.data[CONF_NAME]} {description.name}" self._attr_unique_id = f"yale_smart_alarm-{description.key}" - async def async_press(self, **kwargs: Any) -> None: + async def async_press(self) -> None: """Press the button.""" if TYPE_CHECKING: assert self.coordinator.yale, "Connection to API is missing" diff --git a/homeassistant/components/zwave_me/button.py b/homeassistant/components/zwave_me/button.py index 69354daad43..7e0b4f02728 100644 --- a/homeassistant/components/zwave_me/button.py +++ b/homeassistant/components/zwave_me/button.py @@ -1,6 +1,4 @@ """Representation of a toggleButton.""" -from typing import Any - from homeassistant.components.button import ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -41,6 +39,6 @@ async def async_setup_entry( class ZWaveMeButton(ZWaveMeEntity, ButtonEntity): """Representation of a ZWaveMe button.""" - def press(self, **kwargs: Any) -> None: + def press(self) -> None: """Turn the entity on.""" self.controller.zwave_api.send_command(self.device.id, "on") From b9c135870a4bb7de38451bf9e9dbfadaa26c5be4 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 17:54:07 +0200 Subject: [PATCH 779/947] Fix model in vicare device_info (#74135) --- .../components/vicare/binary_sensor.py | 19 +++++++++--------- homeassistant/components/vicare/button.py | 20 +++++++++---------- homeassistant/components/vicare/climate.py | 19 +++++++++--------- homeassistant/components/vicare/sensor.py | 19 +++++++++--------- .../components/vicare/water_heater.py | 19 +++++++++--------- 5 files changed, 50 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 01cfff59357..3f54e5bd7e7 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.binary_sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -198,15 +199,15 @@ class ViCareBinarySensor(BinarySensorEntity): self._state = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + return DeviceInfo( + identifiers={(DOMAIN, self._device_config.getConfig().serial)}, + name=self._device_config.getModel(), + manufacturer="Viessmann", + model=self._device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) @property def available(self): @@ -214,7 +215,7 @@ class ViCareBinarySensor(BinarySensorEntity): return self._state is not None @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for this device.""" tmp_id = ( f"{self._device_config.getConfig().serial}-{self.entity_description.key}" diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index e1d6bc4223c..b691c01796b 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -15,7 +15,7 @@ import requests from homeassistant.components.button import ButtonEntity, ButtonEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -94,18 +94,18 @@ class ViCareButton(ButtonEntity): _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + return DeviceInfo( + identifiers={(DOMAIN, self._device_config.getConfig().serial)}, + name=self._device_config.getModel(), + manufacturer="Viessmann", + model=self._device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for this device.""" tmp_id = ( f"{self._device_config.getConfig().serial}-{self.entity_description.key}" diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index fb8e60c3318..8f00f9e6c3b 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -31,6 +31,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -161,20 +162,20 @@ class ViCareClimate(ClimateEntity): self._current_action = None @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for this device.""" return f"{self._device_config.getConfig().serial}-{self._circuit.id}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + return DeviceInfo( + identifiers={(DOMAIN, self._device_config.getConfig().serial)}, + name=self._device_config.getModel(), + manufacturer="Viessmann", + model=self._device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) def update(self): """Let HA know there has been an update from the ViCare API.""" diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 06ed618ec86..e1deef0df00 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -30,6 +30,7 @@ from homeassistant.const import ( VOLUME_CUBIC_METERS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import ViCareRequiredKeysMixin @@ -597,15 +598,15 @@ class ViCareSensor(SensorEntity): self._state = None @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + return DeviceInfo( + identifiers={(DOMAIN, self._device_config.getConfig().serial)}, + name=self._device_config.getModel(), + manufacturer="Viessmann", + model=self._device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) @property def available(self): @@ -613,7 +614,7 @@ class ViCareSensor(SensorEntity): return self._state is not None @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for this device.""" tmp_id = ( f"{self._device_config.getConfig().serial}-{self.entity_description.key}" diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index 6f9262200ec..ae8456cac6f 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -21,6 +21,7 @@ from homeassistant.const import ( TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( @@ -140,20 +141,20 @@ class ViCareWater(WaterHeaterEntity): _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) @property - def unique_id(self): + def unique_id(self) -> str: """Return unique ID for this device.""" return f"{self._device_config.getConfig().serial}-{self._circuit.id}" @property - def device_info(self): + def device_info(self) -> DeviceInfo: """Return device info for this device.""" - return { - "identifiers": {(DOMAIN, self._device_config.getConfig().serial)}, - "name": self._device_config.getModel(), - "manufacturer": "Viessmann", - "model": (DOMAIN, self._device_config.getModel()), - "configuration_url": "https://developer.viessmann.com/", - } + return DeviceInfo( + identifiers={(DOMAIN, self._device_config.getConfig().serial)}, + name=self._device_config.getModel(), + manufacturer="Viessmann", + model=self._device_config.getModel(), + configuration_url="https://developer.viessmann.com/", + ) @property def name(self): From 54138cda418de49e824644824f0b124f3a85b3c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Tue, 28 Jun 2022 18:08:31 +0200 Subject: [PATCH 780/947] Fix app browsing and local file streaming in Apple TV integration (#74112) --- homeassistant/components/apple_tv/browse_media.py | 4 ++-- homeassistant/components/apple_tv/media_player.py | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/apple_tv/browse_media.py b/homeassistant/components/apple_tv/browse_media.py index 8d0a94ca858..0673c9923fb 100644 --- a/homeassistant/components/apple_tv/browse_media.py +++ b/homeassistant/components/apple_tv/browse_media.py @@ -21,8 +21,8 @@ def build_app_list(app_list): media_content_id="apps", media_content_type=MEDIA_TYPE_APPS, title="Apps", - can_play=True, - can_expand=False, + can_play=False, + can_expand=True, children=[item_payload(item) for item in app_list], children_media_class=MEDIA_CLASS_APP, ) diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 30a397d953c..362a09fb5fc 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -282,22 +282,20 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # RAOP. Otherwise try to play it with regular AirPlay. if media_type == MEDIA_TYPE_APP: await self.atv.apps.launch_app(media_id) + return if media_source.is_media_source_id(media_id): play_item = await media_source.async_resolve_media( self.hass, media_id, self.entity_id ) - media_id = play_item.url + media_id = async_process_play_media_url(self.hass, play_item.url) media_type = MEDIA_TYPE_MUSIC - media_id = async_process_play_media_url(self.hass, media_id) - if self._is_feature_available(FeatureName.StreamFile) and ( media_type == MEDIA_TYPE_MUSIC or await is_streamable(media_id) ): _LOGGER.debug("Streaming %s via RAOP", media_id) await self.atv.stream.stream_file(media_id) - elif self._is_feature_available(FeatureName.PlayUrl): _LOGGER.debug("Playing %s via AirPlay", media_id) await self.atv.stream.play_url(media_id) From 2f60db6f80cedc99ebdeccc926986942c4f77970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 28 Jun 2022 18:20:56 +0200 Subject: [PATCH 781/947] Pin charset-normalizer to 2.0.12 (#74104) --- homeassistant/package_constraints.txt | 4 ++++ script/gen_requirements_all.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index dfbf056936f..9f849146fe3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -114,3 +114,7 @@ backoff<2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 pydantic!=1.9.1 + +# Pin charset-normalizer to 2.0.12 due to version conflict. +# https://github.com/home-assistant/core/pull/74104 +charset-normalizer==2.0.12 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a2a0eab897a..c6b50c6bd32 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -132,6 +132,10 @@ backoff<2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 pydantic!=1.9.1 + +# Pin charset-normalizer to 2.0.12 due to version conflict. +# https://github.com/home-assistant/core/pull/74104 +charset-normalizer==2.0.12 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From c883aec7116f3474c3ad8b11fcc4757095289117 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 28 Jun 2022 18:21:54 +0200 Subject: [PATCH 782/947] Bump pynetgear to 0.10.6 (#74123) --- homeassistant/components/netgear/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index f65f5aa6686..5fd59faac83 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -2,7 +2,7 @@ "domain": "netgear", "name": "NETGEAR", "documentation": "https://www.home-assistant.io/integrations/netgear", - "requirements": ["pynetgear==0.10.4"], + "requirements": ["pynetgear==0.10.6"], "codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"], "iot_class": "local_polling", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index b15ce59108e..ecf268c8b1d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1673,7 +1673,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.4 +pynetgear==0.10.6 # homeassistant.components.netio pynetio==0.1.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a1a8604659e..820e6134b82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1134,7 +1134,7 @@ pymyq==3.1.4 pymysensors==0.22.1 # homeassistant.components.netgear -pynetgear==0.10.4 +pynetgear==0.10.6 # homeassistant.components.nina pynina==0.1.8 From 26a85c6644991f626ccce62c05665095c2577234 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 18:38:05 +0200 Subject: [PATCH 783/947] Add Entity.has_entity_name attribute (#73217) --- .../components/config/entity_registry.py | 1 + homeassistant/helpers/entity.py | 34 ++++++++++- homeassistant/helpers/entity_platform.py | 25 +++++--- homeassistant/helpers/entity_registry.py | 19 +++++- tests/common.py | 5 ++ .../components/config/test_entity_registry.py | 8 +++ tests/helpers/test_entity.py | 60 +++++++++++++++++-- tests/helpers/test_entity_platform.py | 46 ++++++++++++++ tests/helpers/test_entity_registry.py | 6 ++ 9 files changed, 186 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 2bb585e12c6..e6b91ee5a50 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -218,6 +218,7 @@ def _entry_ext_dict(entry): data = _entry_dict(entry) data["capabilities"] = entry.capabilities data["device_class"] = entry.device_class + data["has_entity_name"] = entry.has_entity_name data["options"] = entry.options data["original_device_class"] = entry.original_device_class data["original_icon"] = entry.original_icon diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 39af16892f5..f00f7d85e76 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -38,7 +38,7 @@ from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify -from . import entity_registry as er +from . import device_registry as dr, entity_registry as er from .device_registry import DeviceEntryType from .entity_platform import EntityPlatform from .event import async_track_entity_registry_updated_event @@ -221,6 +221,7 @@ class EntityDescription: entity_registry_visible_default: bool = True force_update: bool = False icon: str | None = None + has_entity_name: bool = False name: str | None = None unit_of_measurement: str | None = None @@ -277,6 +278,7 @@ class Entity(ABC): _attr_device_class: str | None _attr_device_info: DeviceInfo | None = None _attr_entity_category: EntityCategory | None + _attr_has_entity_name: bool _attr_entity_picture: str | None = None _attr_entity_registry_enabled_default: bool _attr_entity_registry_visible_default: bool @@ -303,6 +305,15 @@ class Entity(ABC): """Return a unique ID.""" return self._attr_unique_id + @property + def has_entity_name(self) -> bool: + """Return if the name of the entity is describing only the entity itself.""" + if hasattr(self, "_attr_has_entity_name"): + return self._attr_has_entity_name + if hasattr(self, "entity_description"): + return self.entity_description.has_entity_name + return False + @property def name(self) -> str | None: """Return the name of the entity.""" @@ -583,7 +594,26 @@ class Entity(ABC): if (icon := (entry and entry.icon) or self.icon) is not None: attr[ATTR_ICON] = icon - if (name := (entry and entry.name) or self.name) is not None: + def friendly_name() -> str | None: + """Return the friendly name. + + If has_entity_name is False, this returns self.name + If has_entity_name is True, this returns device.name + self.name + """ + if not self.has_entity_name or not self.registry_entry: + return self.name + + device_registry = dr.async_get(self.hass) + if not (device_id := self.registry_entry.device_id) or not ( + device_entry := device_registry.async_get(device_id) + ): + return self.name + + if not self.name: + return device_entry.name_by_user or device_entry.name + return f"{device_entry.name_by_user or device_entry.name} {self.name}" + + if (name := (entry and entry.name) or friendly_name()) is not None: attr[ATTR_FRIENDLY_NAME] = name if (supported_features := self.supported_features) is not None: diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ecf2125962a..ec71778af12 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -440,15 +440,6 @@ class EntityPlatform: # Get entity_id from unique ID registration if entity.unique_id is not None: - if entity.entity_id is not None: - requested_entity_id = entity.entity_id - suggested_object_id = split_entity_id(entity.entity_id)[1] - else: - suggested_object_id = entity.name # type: ignore[unreachable] - - if self.entity_namespace is not None: - suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" - if self.config_entry is not None: config_entry_id: str | None = self.config_entry.entry_id else: @@ -503,6 +494,22 @@ class EntityPlatform: except RequiredParameterMissing: pass + if entity.entity_id is not None: + requested_entity_id = entity.entity_id + suggested_object_id = split_entity_id(entity.entity_id)[1] + else: + if device and entity.has_entity_name: # type: ignore[unreachable] + device_name = device.name_by_user or device.name + if not entity.name: + suggested_object_id = device_name + else: + suggested_object_id = f"{device_name} {entity.name}" + if not suggested_object_id: + suggested_object_id = entity.name + + if self.entity_namespace is not None: + suggested_object_id = f"{self.entity_namespace} {suggested_object_id}" + disabled_by: RegistryEntryDisabler | None = None if not entity.entity_registry_enabled_default: disabled_by = RegistryEntryDisabler.INTEGRATION diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index eb5590b7fdf..ff38a48da75 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -60,7 +60,7 @@ SAVE_DELAY = 10 _LOGGER = logging.getLogger(__name__) STORAGE_VERSION_MAJOR = 1 -STORAGE_VERSION_MINOR = 6 +STORAGE_VERSION_MINOR = 7 STORAGE_KEY = "core.entity_registry" # Attributes relevant to describing entity @@ -111,6 +111,7 @@ class RegistryEntry: hidden_by: RegistryEntryHider | None = attr.ib(default=None) icon: str | None = attr.ib(default=None) id: str = attr.ib(factory=uuid_util.random_uuid_hex) + has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) options: Mapping[str, Mapping[str, Any]] = attr.ib( default=None, converter=attr.converters.default_if_none(factory=dict) # type: ignore[misc] @@ -328,6 +329,7 @@ class EntityRegistry: config_entry: ConfigEntry | None = None, device_id: str | None = None, entity_category: EntityCategory | None = None, + has_entity_name: bool | None = None, original_device_class: str | None = None, original_icon: str | None = None, original_name: str | None = None, @@ -349,6 +351,9 @@ class EntityRegistry: config_entry_id=config_entry_id or UNDEFINED, device_id=device_id or UNDEFINED, entity_category=entity_category or UNDEFINED, + has_entity_name=has_entity_name + if has_entity_name is not None + else UNDEFINED, original_device_class=original_device_class or UNDEFINED, original_icon=original_icon or UNDEFINED, original_name=original_name or UNDEFINED, @@ -393,6 +398,7 @@ class EntityRegistry: entity_category=entity_category, entity_id=entity_id, hidden_by=hidden_by, + has_entity_name=has_entity_name or False, original_device_class=original_device_class, original_icon=original_icon, original_name=original_name, @@ -499,6 +505,7 @@ class EntityRegistry: entity_category: EntityCategory | None | UndefinedType = UNDEFINED, hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + has_entity_name: bool | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -548,6 +555,7 @@ class EntityRegistry: ("entity_category", entity_category), ("hidden_by", hidden_by), ("icon", icon), + ("has_entity_name", has_entity_name), ("name", name), ("original_device_class", original_device_class), ("original_icon", original_icon), @@ -621,6 +629,7 @@ class EntityRegistry: entity_category: EntityCategory | None | UndefinedType = UNDEFINED, hidden_by: RegistryEntryHider | None | UndefinedType = UNDEFINED, icon: str | None | UndefinedType = UNDEFINED, + has_entity_name: bool | UndefinedType = UNDEFINED, name: str | None | UndefinedType = UNDEFINED, new_entity_id: str | UndefinedType = UNDEFINED, new_unique_id: str | UndefinedType = UNDEFINED, @@ -642,6 +651,7 @@ class EntityRegistry: entity_category=entity_category, hidden_by=hidden_by, icon=icon, + has_entity_name=has_entity_name, name=name, new_entity_id=new_entity_id, new_unique_id=new_unique_id, @@ -742,6 +752,7 @@ class EntityRegistry: else None, icon=entity["icon"], id=entity["id"], + has_entity_name=entity["has_entity_name"], name=entity["name"], options=entity["options"], original_device_class=entity["original_device_class"], @@ -778,6 +789,7 @@ class EntityRegistry: "hidden_by": entry.hidden_by, "icon": entry.icon, "id": entry.id, + "has_entity_name": entry.has_entity_name, "name": entry.name, "options": entry.options, "original_device_class": entry.original_device_class, @@ -944,6 +956,11 @@ async def _async_migrate( for entity in data["entities"]: entity["hidden_by"] = None + if old_major_version == 1 and old_minor_version < 7: + # Version 1.6 adds has_entity_name + for entity in data["entities"]: + entity["has_entity_name"] = False + if old_major_version > 1: raise NotImplementedError return data diff --git a/tests/common.py b/tests/common.py index 1a29d0d6dc4..80f0913cace 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1007,6 +1007,11 @@ class MockEntity(entity.Entity): """Return the entity category.""" return self._handle("entity_category") + @property + def has_entity_name(self): + """Return the has_entity_name name flag.""" + return self._handle("has_entity_name") + @property def entity_registry_enabled_default(self): """Return if the entity should be enabled when first added to the entity registry.""" diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index e74e43de701..69744817a27 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -117,6 +117,7 @@ async def test_get_entity(hass, client): "entity_id": "test_domain.name", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": "Hello World", "options": {}, "original_device_class": None, @@ -146,6 +147,7 @@ async def test_get_entity(hass, client): "entity_id": "test_domain.no_name", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, @@ -208,6 +210,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {}, "original_device_class": None, @@ -279,6 +282,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {}, "original_device_class": None, @@ -315,6 +319,7 @@ async def test_update_entity(hass, client): "entity_id": "test_domain.world", "hidden_by": "user", # We exchange strings over the WS API, not enums "icon": "icon:after update", + "has_entity_name": False, "name": "after update", "options": {"sensor": {"unit_of_measurement": "beard_second"}}, "original_device_class": None, @@ -373,6 +378,7 @@ async def test_update_entity_require_restart(hass, client): "entity_id": "test_domain.world", "icon": None, "hidden_by": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, @@ -479,6 +485,7 @@ async def test_update_entity_no_changes(hass, client): "entity_id": "test_domain.world", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": "name of entity", "options": {}, "original_device_class": None, @@ -564,6 +571,7 @@ async def test_update_entity_id(hass, client): "entity_id": "test_domain.planet", "hidden_by": None, "icon": None, + "has_entity_name": False, "name": None, "options": {}, "original_device_class": None, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 7141c5f0903..b9067a3db1c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -12,16 +12,18 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import Context, HomeAssistantError -from homeassistant.helpers import entity, entity_registry +from homeassistant.helpers import device_registry as dr, entity, entity_registry as er from tests.common import ( MockConfigEntry, MockEntity, MockEntityPlatform, + MockPlatform, get_test_home_assistant, mock_registry, ) @@ -594,11 +596,11 @@ async def test_set_context_expired(hass): async def test_warn_disabled(hass, caplog): """Test we warn once if we write to a disabled entity.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", - disabled_by=entity_registry.RegistryEntryDisabler.USER, + disabled_by=er.RegistryEntryDisabler.USER, ) mock_registry(hass, {"hello.world": entry}) @@ -621,7 +623,7 @@ async def test_warn_disabled(hass, caplog): async def test_disabled_in_entity_registry(hass): """Test entity is removed if we disable entity registry entry.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -640,7 +642,7 @@ async def test_disabled_in_entity_registry(hass): assert hass.states.get("hello.world") is not None entry2 = registry.async_update_entity( - "hello.world", disabled_by=entity_registry.RegistryEntryDisabler.USER + "hello.world", disabled_by=er.RegistryEntryDisabler.USER ) await hass.async_block_till_done() assert entry2 != entry @@ -749,7 +751,7 @@ async def test_setup_source(hass): async def test_removing_entity_unavailable(hass): """Test removing an entity that is still registered creates an unavailable state.""" - entry = entity_registry.RegistryEntry( + entry = er.RegistryEntry( entity_id="hello.world", unique_id="test-unique-id", platform="test-platform", @@ -886,3 +888,49 @@ async def test_entity_description_fallback(): continue assert getattr(ent, field.name) == getattr(ent_with_description, field.name) + + +@pytest.mark.parametrize( + "has_entity_name, entity_name, expected_friendly_name", + ( + (False, "Entity Blu", "Entity Blu"), + (False, None, None), + (True, "Entity Blu", "Device Bla Entity Blu"), + (True, None, "Device Bla"), + ), +) +async def test_friendly_name( + hass, has_entity_name, entity_name, expected_friendly_name +): + """Test entity_id is influenced by entity name.""" + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ), + ] + ) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 933669ebc53..80a37f9f2fd 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -1392,3 +1392,49 @@ class SlowEntity(MockEntity): """Make sure control is returned to the event loop on add.""" await asyncio.sleep(0.1) await super().async_added_to_hass() + + +@pytest.mark.parametrize( + "has_entity_name, entity_name, expected_entity_id", + ( + (False, "Entity Blu", "test_domain.entity_blu"), + (False, None, "test_domain.test_qwer"), # Set to _ + (True, "Entity Blu", "test_domain.device_bla_entity_blu"), + (True, None, "test_domain.device_bla"), + ), +) +async def test_entity_name_influences_entity_id( + hass, has_entity_name, entity_name, expected_entity_id +): + """Test entity_id is influenced by entity name.""" + registry = er.async_get(hass) + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=has_entity_name, + name=entity_name, + ), + ] + ) + return True + + platform = MockPlatform(async_setup_entry=async_setup_entry) + config_entry = MockConfigEntry(entry_id="super-mock-id") + entity_platform = MockEntityPlatform( + hass, platform_name=config_entry.domain, platform=platform + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + assert registry.async_get(expected_entity_id) is not None diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index 8f5b4a7d333..ba69a98d5a8 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -80,6 +80,7 @@ def test_get_or_create_updates_data(registry): disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, hidden_by=er.RegistryEntryHider.INTEGRATION, + has_entity_name=True, original_device_class="mock-device-class", original_icon="initial-original_icon", original_name="initial-original_name", @@ -101,6 +102,7 @@ def test_get_or_create_updates_data(registry): hidden_by=er.RegistryEntryHider.INTEGRATION, icon=None, id=orig_entry.id, + has_entity_name=True, name=None, original_device_class="mock-device-class", original_icon="initial-original_icon", @@ -122,6 +124,7 @@ def test_get_or_create_updates_data(registry): disabled_by=er.RegistryEntryDisabler.USER, entity_category=None, hidden_by=er.RegistryEntryHider.USER, + has_entity_name=False, original_device_class="new-mock-device-class", original_icon="updated-original_icon", original_name="updated-original_name", @@ -143,6 +146,7 @@ def test_get_or_create_updates_data(registry): hidden_by=er.RegistryEntryHider.INTEGRATION, # Should not be updated icon=None, id=orig_entry.id, + has_entity_name=False, name=None, original_device_class="new-mock-device-class", original_icon="updated-original_icon", @@ -196,6 +200,7 @@ async def test_loading_saving_data(hass, registry): disabled_by=er.RegistryEntryDisabler.HASS, entity_category=EntityCategory.CONFIG, hidden_by=er.RegistryEntryHider.INTEGRATION, + has_entity_name=True, original_device_class="mock-device-class", original_icon="hass:original-icon", original_name="Original Name", @@ -237,6 +242,7 @@ async def test_loading_saving_data(hass, registry): assert new_entry2.entity_category == "config" assert new_entry2.icon == "hass:user-icon" assert new_entry2.hidden_by == er.RegistryEntryHider.INTEGRATION + assert new_entry2.has_entity_name is True assert new_entry2.name == "User Name" assert new_entry2.options == {"light": {"minimum_brightness": 20}} assert new_entry2.original_device_class == "mock-device-class" From 9fef1004a27df4102f6dc0c40e090b6c7a3b9209 Mon Sep 17 00:00:00 2001 From: Bryton Hall Date: Tue, 28 Jun 2022 12:38:30 -0400 Subject: [PATCH 784/947] Bump venstarcolortouch to 0.16 (#73038) --- homeassistant/components/venstar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 42a97020fa3..e63c75792bf 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -3,7 +3,7 @@ "name": "Venstar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", - "requirements": ["venstarcolortouch==0.15"], + "requirements": ["venstarcolortouch==0.16"], "codeowners": ["@garbled1"], "iot_class": "local_polling", "loggers": ["venstarcolortouch"] diff --git a/requirements_all.txt b/requirements_all.txt index ecf268c8b1d..d0e96e0e0e1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2387,7 +2387,7 @@ vehicle==0.4.0 velbus-aio==2022.6.1 # homeassistant.components.venstar -venstarcolortouch==0.15 +venstarcolortouch==0.16 # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 820e6134b82..903c9479b24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1584,7 +1584,7 @@ vehicle==0.4.0 velbus-aio==2022.6.1 # homeassistant.components.venstar -venstarcolortouch==0.15 +venstarcolortouch==0.16 # homeassistant.components.vilfo vilfo-api-client==0.3.2 From 040ece76ab690bf10af5490a921bc61db3f43d6b Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Tue, 28 Jun 2022 18:41:29 +0200 Subject: [PATCH 785/947] Add velbus buttons platform (#73323) --- .coveragerc | 1 + homeassistant/components/velbus/__init__.py | 1 + homeassistant/components/velbus/button.py | 42 +++++++++++++++++++ homeassistant/components/velbus/manifest.json | 2 +- homeassistant/components/velbus/sensor.py | 4 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/velbus/button.py diff --git a/.coveragerc b/.coveragerc index 928f4d7789e..8253ed56ccd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1361,6 +1361,7 @@ omit = homeassistant/components/vasttrafik/sensor.py homeassistant/components/velbus/__init__.py homeassistant/components/velbus/binary_sensor.py + homeassistant/components/velbus/button.py homeassistant/components/velbus/climate.py homeassistant/components/velbus/const.py homeassistant/components/velbus/cover.py diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index d2aa9531467..9b5a52306d8 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -28,6 +28,7 @@ _LOGGER = logging.getLogger(__name__) PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.CLIMATE, Platform.COVER, Platform.LIGHT, diff --git a/homeassistant/components/velbus/button.py b/homeassistant/components/velbus/button.py new file mode 100644 index 00000000000..189cfb495e4 --- /dev/null +++ b/homeassistant/components/velbus/button.py @@ -0,0 +1,42 @@ +"""Support for Velbus Buttons.""" +from __future__ import annotations + +from velbusaio.channels import ( + Button as VelbusaioButton, + ButtonCounter as VelbusaioButtonCounter, +) + +from homeassistant.components.button import ButtonEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import VelbusEntity +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Velbus switch based on config_entry.""" + await hass.data[DOMAIN][entry.entry_id]["tsk"] + cntrl = hass.data[DOMAIN][entry.entry_id]["cntrl"] + entities = [] + for channel in cntrl.get_all("button"): + entities.append(VelbusButton(channel)) + async_add_entities(entities) + + +class VelbusButton(VelbusEntity, ButtonEntity): + """Representation of a Velbus Binary Sensor.""" + + _channel: VelbusaioButton | VelbusaioButtonCounter + _attr_entity_registry_enabled_default = False + _attr_entity_category = EntityCategory.CONFIG + + async def async_press(self) -> None: + """Handle the button press.""" + await self._channel.press() diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index e627412a00e..ec0c0f5f2d9 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -2,7 +2,7 @@ "domain": "velbus", "name": "Velbus", "documentation": "https://www.home-assistant.io/integrations/velbus", - "requirements": ["velbus-aio==2022.6.1"], + "requirements": ["velbus-aio==2022.6.2"], "config_flow": true, "codeowners": ["@Cereal2nd", "@brefra"], "dependencies": ["usb"], diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py index 86e9a606d36..a0bd9b6c173 100644 --- a/homeassistant/components/velbus/sensor.py +++ b/homeassistant/components/velbus/sensor.py @@ -53,9 +53,9 @@ class VelbusSensor(VelbusEntity, SensorEntity): self._attr_name = f"{self._attr_name}-counter" # define the device class if self._is_counter: - self._attr_device_class = SensorDeviceClass.ENERGY - elif channel.is_counter_channel(): self._attr_device_class = SensorDeviceClass.POWER + elif channel.is_counter_channel(): + self._attr_device_class = SensorDeviceClass.ENERGY elif channel.is_temperature(): self._attr_device_class = SensorDeviceClass.TEMPERATURE # define the icon diff --git a/requirements_all.txt b/requirements_all.txt index d0e96e0e0e1..318511f800d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2384,7 +2384,7 @@ vallox-websocket-api==2.11.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.6.1 +velbus-aio==2022.6.2 # homeassistant.components.venstar venstarcolortouch==0.16 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 903c9479b24..3b8e17fcb21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1581,7 +1581,7 @@ vallox-websocket-api==2.11.0 vehicle==0.4.0 # homeassistant.components.velbus -velbus-aio==2022.6.1 +velbus-aio==2022.6.2 # homeassistant.components.venstar venstarcolortouch==0.16 From a8349a4866d22cddbca9ac9367d4affae39a8325 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jun 2022 11:42:51 -0500 Subject: [PATCH 786/947] Adjust entity filters to make includes stronger than excludes (#74080) * Adjust entity filters to make includes stronger than excludes Fixes #59080 * adjust test for stronger entity glob includes * sync with docs --- homeassistant/components/recorder/filters.py | 57 ++++---- homeassistant/helpers/entityfilter.py | 74 ++++++----- tests/components/apache_kafka/test_init.py | 2 +- tests/components/azure_event_hub/test_init.py | 2 +- tests/components/google_pubsub/test_init.py | 2 +- tests/components/history/test_init.py | 12 +- tests/components/influxdb/test_init.py | 2 +- tests/components/logbook/test_init.py | 3 +- .../test_filters_with_entityfilter.py | 125 ++++++++++++++++++ tests/helpers/test_entityfilter.py | 120 ++++++++++++++--- 10 files changed, 312 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/recorder/filters.py b/homeassistant/components/recorder/filters.py index 02c342441a7..45db64e0097 100644 --- a/homeassistant/components/recorder/filters.py +++ b/homeassistant/components/recorder/filters.py @@ -139,49 +139,52 @@ class Filters: have_exclude = self._have_exclude have_include = self._have_include - # Case 1 - no includes or excludes - pass all entities + # Case 1 - No filter + # - All entities included if not have_include and not have_exclude: return None - # Case 2 - includes, no excludes - only include specified entities + # Case 2 - Only includes + # - Entity listed in entities include: include + # - Otherwise, entity matches domain include: include + # - Otherwise, entity matches glob include: include + # - Otherwise: exclude if have_include and not have_exclude: return or_(*includes).self_group() - # Case 3 - excludes, no includes - only exclude specified entities + # Case 3 - Only excludes + # - Entity listed in exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise: include if not have_include and have_exclude: return not_(or_(*excludes).self_group()) - # Case 4 - both includes and excludes specified - # Case 4a - include domain or glob specified - # - if domain is included, pass if entity not excluded - # - if glob is included, pass if entity and domain not excluded - # - if domain and glob are not included, pass if entity is included - # note: if both include domain matches then exclude domains ignored. - # If glob matches then exclude domains and glob checked + # Case 4 - Domain and/or glob includes (may also have excludes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in entities exclude: exclude + # - Otherwise, entity matches glob include: include + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain include: include + # - Otherwise: exclude if self.included_domains or self.included_entity_globs: return or_( - (i_domains & ~(e_entities | e_entity_globs)), - ( - ~i_domains - & or_( - (i_entity_globs & ~(or_(*excludes))), - (~i_entity_globs & i_entities), - ) - ), + i_entities, + (~e_entities & (i_entity_globs | (~e_entity_globs & i_domains))), ).self_group() - # Case 4b - exclude domain or glob specified, include has no domain or glob - # In this one case the traditional include logic is inverted. Even though an - # include is specified since its only a list of entity IDs its used only to - # expose specific entities excluded by domain or glob. Any entities not - # excluded are then presumed included. Logic is as follows - # - if domain or glob is excluded, pass if entity is included - # - if domain is not excluded, pass if entity not excluded by ID + # Case 5 - Domain and/or glob excludes (no domain and/or glob includes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise: include if self.excluded_domains or self.excluded_entity_globs: return (not_(or_(*excludes)) | i_entities).self_group() - # Case 4c - neither include or exclude domain specified - # - Only pass if entity is included. Ignore entity excludes. + # Case 6 - No Domain and/or glob includes or excludes + # - Entity listed in entities include: include + # - Otherwise: exclude return i_entities def states_entity_filter(self) -> ClauseList: diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index d4722eeca44..109c5454cc2 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -145,11 +145,7 @@ def _glob_to_re(glob: str) -> re.Pattern[str]: def _test_against_patterns(patterns: list[re.Pattern[str]], entity_id: str) -> bool: """Test entity against list of patterns, true if any match.""" - for pattern in patterns: - if pattern.match(entity_id): - return True - - return False + return any(pattern.match(entity_id) for pattern in patterns) def _convert_globs_to_pattern_list(globs: list[str] | None) -> list[re.Pattern[str]]: @@ -193,7 +189,7 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in include_e or domain in include_d - or bool(include_eg and _test_against_patterns(include_eg, entity_id)) + or _test_against_patterns(include_eg, entity_id) ) def entity_excluded(domain: str, entity_id: str) -> bool: @@ -201,14 +197,19 @@ def _generate_filter_from_sets_and_pattern_lists( return ( entity_id in exclude_e or domain in exclude_d - or bool(exclude_eg and _test_against_patterns(exclude_eg, entity_id)) + or _test_against_patterns(exclude_eg, entity_id) ) - # Case 1 - no includes or excludes - pass all entities + # Case 1 - No filter + # - All entities included if not have_include and not have_exclude: return lambda entity_id: True - # Case 2 - includes, no excludes - only include specified entities + # Case 2 - Only includes + # - Entity listed in entities include: include + # - Otherwise, entity matches domain include: include + # - Otherwise, entity matches glob include: include + # - Otherwise: exclude if have_include and not have_exclude: def entity_filter_2(entity_id: str) -> bool: @@ -218,7 +219,11 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_filter_2 - # Case 3 - excludes, no includes - only exclude specified entities + # Case 3 - Only excludes + # - Entity listed in exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise: include if not have_include and have_exclude: def entity_filter_3(entity_id: str) -> bool: @@ -228,38 +233,36 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_filter_3 - # Case 4 - both includes and excludes specified - # Case 4a - include domain or glob specified - # - if domain is included, pass if entity not excluded - # - if glob is included, pass if entity and domain not excluded - # - if domain and glob are not included, pass if entity is included - # note: if both include domain matches then exclude domains ignored. - # If glob matches then exclude domains and glob checked + # Case 4 - Domain and/or glob includes (may also have excludes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in entities exclude: exclude + # - Otherwise, entity matches glob include: include + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain include: include + # - Otherwise: exclude if include_d or include_eg: def entity_filter_4a(entity_id: str) -> bool: """Return filter function for case 4a.""" - domain = split_entity_id(entity_id)[0] - if domain in include_d: - return not ( - entity_id in exclude_e - or bool( - exclude_eg and _test_against_patterns(exclude_eg, entity_id) + return entity_id in include_e or ( + entity_id not in exclude_e + and ( + _test_against_patterns(include_eg, entity_id) + or ( + split_entity_id(entity_id)[0] in include_d + and not _test_against_patterns(exclude_eg, entity_id) ) ) - if _test_against_patterns(include_eg, entity_id): - return not entity_excluded(domain, entity_id) - return entity_id in include_e + ) return entity_filter_4a - # Case 4b - exclude domain or glob specified, include has no domain or glob - # In this one case the traditional include logic is inverted. Even though an - # include is specified since its only a list of entity IDs its used only to - # expose specific entities excluded by domain or glob. Any entities not - # excluded are then presumed included. Logic is as follows - # - if domain or glob is excluded, pass if entity is included - # - if domain is not excluded, pass if entity not excluded by ID + # Case 5 - Domain and/or glob excludes (no domain and/or glob includes) + # - Entity listed in entities include: include + # - Otherwise, entity listed in exclude: exclude + # - Otherwise, entity matches glob exclude: exclude + # - Otherwise, entity matches domain exclude: exclude + # - Otherwise: include if exclude_d or exclude_eg: def entity_filter_4b(entity_id: str) -> bool: @@ -273,6 +276,7 @@ def _generate_filter_from_sets_and_pattern_lists( return entity_filter_4b - # Case 4c - neither include or exclude domain specified - # - Only pass if entity is included. Ignore entity excludes. + # Case 6 - No Domain and/or glob includes or excludes + # - Entity listed in entities include: include + # - Otherwise: exclude return lambda entity_id: entity_id in include_e diff --git a/tests/components/apache_kafka/test_init.py b/tests/components/apache_kafka/test_init.py index 3f594b3fce3..9f5fa2800bc 100644 --- a/tests/components/apache_kafka/test_init.py +++ b/tests/components/apache_kafka/test_init.py @@ -169,7 +169,7 @@ async def test_filtered_allowlist(hass, mock_client): FilterTest("light.excluded_test", False), FilterTest("light.excluded", False), FilterTest("sensor.included_test", True), - FilterTest("climate.included_test", False), + FilterTest("climate.included_test", True), ] await _run_filter_tests(hass, tests, mock_client) diff --git a/tests/components/azure_event_hub/test_init.py b/tests/components/azure_event_hub/test_init.py index cf7226e20b0..c1393483c8c 100644 --- a/tests/components/azure_event_hub/test_init.py +++ b/tests/components/azure_event_hub/test_init.py @@ -176,7 +176,7 @@ async def test_full_batch(hass, entry_with_one_event, mock_create_batch): FilterTest("light.excluded_test", 0), FilterTest("light.excluded", 0), FilterTest("sensor.included_test", 1), - FilterTest("climate.included_test", 0), + FilterTest("climate.included_test", 1), ], ), ( diff --git a/tests/components/google_pubsub/test_init.py b/tests/components/google_pubsub/test_init.py index 1b6d1dbf4b4..71fab923972 100644 --- a/tests/components/google_pubsub/test_init.py +++ b/tests/components/google_pubsub/test_init.py @@ -222,7 +222,7 @@ async def test_filtered_allowlist(hass, mock_client): FilterTest("light.excluded_test", False), FilterTest("light.excluded", False), FilterTest("sensor.included_test", True), - FilterTest("climate.included_test", False), + FilterTest("climate.included_test", True), ] for test in tests: diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 56ac68d944d..5eb4894c72a 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -753,7 +753,7 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( { "history": { "exclude": { - "entity_globs": ["light.many*"], + "entity_globs": ["light.many*", "binary_sensor.*"], }, "include": { "entity_globs": ["light.m*"], @@ -769,6 +769,7 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( hass.states.async_set("light.many_state_changes", "on") hass.states.async_set("switch.match", "on") hass.states.async_set("media_player.test", "on") + hass.states.async_set("binary_sensor.exclude", "on") await async_wait_recording_done(hass) @@ -778,10 +779,11 @@ async def test_fetch_period_api_with_entity_glob_include_and_exclude( ) assert response.status == HTTPStatus.OK response_json = await response.json() - assert len(response_json) == 3 - assert response_json[0][0]["entity_id"] == "light.match" - assert response_json[1][0]["entity_id"] == "media_player.test" - assert response_json[2][0]["entity_id"] == "switch.match" + assert len(response_json) == 4 + assert response_json[0][0]["entity_id"] == "light.many_state_changes" + assert response_json[1][0]["entity_id"] == "light.match" + assert response_json[2][0]["entity_id"] == "media_player.test" + assert response_json[3][0]["entity_id"] == "switch.match" async def test_entity_ids_limit_via_api(hass, hass_client, recorder_mock): diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 94071e849c2..27b9ac82ade 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -866,7 +866,7 @@ async def test_event_listener_filtered_allowlist( FilterTest("fake.excluded", False), FilterTest("another_fake.denied", False), FilterTest("fake.excluded_entity", False), - FilterTest("another_fake.included_entity", False), + FilterTest("another_fake.included_entity", True), ] execute_filter_test(hass, tests, handler_method, write_api, get_mock_call) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index d16b3476d84..31ca1610250 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -2153,7 +2153,7 @@ async def test_include_exclude_events_with_glob_filters( client = await hass_client() entries = await _async_fetch_logbook(client) - assert len(entries) == 6 + assert len(entries) == 7 _assert_entry( entries[0], name="Home Assistant", message="started", domain=ha.DOMAIN ) @@ -2162,6 +2162,7 @@ async def test_include_exclude_events_with_glob_filters( _assert_entry(entries[3], name="bla", entity_id=entity_id, state="20") _assert_entry(entries[4], name="blu", entity_id=entity_id2, state="20") _assert_entry(entries[5], name="included", entity_id=entity_id4, state="30") + _assert_entry(entries[6], name="included", entity_id=entity_id5, state="30") async def test_empty_config(hass, hass_client, recorder_mock): diff --git a/tests/components/recorder/test_filters_with_entityfilter.py b/tests/components/recorder/test_filters_with_entityfilter.py index ed4d4efe066..62bb1b3fa8d 100644 --- a/tests/components/recorder/test_filters_with_entityfilter.py +++ b/tests/components/recorder/test_filters_with_entityfilter.py @@ -514,3 +514,128 @@ async def test_same_entity_included_excluded_include_domain_wins(hass, recorder_ assert filtered_events_entity_ids == filter_accept assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins(hass, recorder_mock): + """Test specificlly included entity always wins.""" + filter_accept = { + "media_player.test2", + "media_player.test3", + "thermostat.test", + "binary_sensor.specific_include", + } + filter_reject = { + "binary_sensor.test2", + "binary_sensor.home", + "binary_sensor.can_cancel_this_one", + } + conf = { + CONF_INCLUDE: { + CONF_ENTITIES: ["binary_sensor.specific_include"], + }, + CONF_EXCLUDE: { + CONF_DOMAINS: ["binary_sensor"], + CONF_ENTITY_GLOBS: ["binary_sensor.*"], + }, + } + + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) + + +async def test_specificly_included_entity_always_wins_over_glob(hass, recorder_mock): + """Test specificlly included entity always wins over a glob.""" + filter_accept = { + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + "sensor.energy_x", + } + filter_reject = { + "sensor.apc900va_not_included", + } + conf = { + CONF_EXCLUDE: { + CONF_DOMAINS: [ + "updater", + "camera", + "group", + "media_player", + "script", + "sun", + "automation", + "zone", + "weblink", + "scene", + "calendar", + "weather", + "remote", + "notify", + "switch", + "shell_command", + "media_player", + ], + CONF_ENTITY_GLOBS: ["sensor.apc900va_*"], + }, + CONF_INCLUDE: { + CONF_DOMAINS: [ + "binary_sensor", + "climate", + "device_tracker", + "input_boolean", + "sensor", + ], + CONF_ENTITY_GLOBS: ["sensor.energy_*"], + CONF_ENTITIES: [ + "sensor.apc900va_status", + "sensor.apc900va_battery_charge", + "sensor.apc900va_battery_runtime", + "sensor.apc900va_load", + ], + }, + } + extracted_filter = extract_include_exclude_filter_conf(conf) + entity_filter = convert_include_exclude_filter(extracted_filter) + sqlalchemy_filter = sqlalchemy_filter_from_include_exclude_conf(extracted_filter) + assert sqlalchemy_filter is not None + + for entity_id in filter_accept: + assert entity_filter(entity_id) is True + + for entity_id in filter_reject: + assert entity_filter(entity_id) is False + + ( + filtered_states_entity_ids, + filtered_events_entity_ids, + ) = await _async_get_states_and_events_with_filter( + hass, sqlalchemy_filter, filter_accept | filter_reject + ) + + assert filtered_states_entity_ids == filter_accept + assert not filtered_states_entity_ids.intersection(filter_reject) + + assert filtered_events_entity_ids == filter_accept + assert not filtered_events_entity_ids.intersection(filter_reject) diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 043fb44a95a..f9d7ca47b4c 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -91,8 +91,8 @@ def test_excludes_only_with_glob_case_3(): assert testfilter("cover.garage_door") -def test_with_include_domain_case4a(): - """Test case 4a - include and exclude specified, with included domain.""" +def test_with_include_domain_case4(): + """Test case 4 - include and exclude specified, with included domain.""" incl_dom = {"light", "sensor"} incl_ent = {"binary_sensor.working"} excl_dom = {} @@ -108,8 +108,30 @@ def test_with_include_domain_case4a(): assert testfilter("sun.sun") is False -def test_with_include_glob_case4a(): - """Test case 4a - include and exclude specified, with included glob.""" +def test_with_include_domain_exclude_glob_case4(): + """Test case 4 - include and exclude specified, with included domain but excluded by glob.""" + incl_dom = {"light", "sensor"} + incl_ent = {"binary_sensor.working"} + incl_glob = {} + excl_dom = {} + excl_ent = {"light.ignoreme", "sensor.notworking"} + excl_glob = {"sensor.busted"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.test") + assert testfilter("sensor.busted") is False + assert testfilter("sensor.notworking") is False + assert testfilter("light.test") + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.another") is False + assert testfilter("sun.sun") is False + + +def test_with_include_glob_case4(): + """Test case 4 - include and exclude specified, with included glob.""" incl_dom = {} incl_glob = {"light.*", "sensor.*"} incl_ent = {"binary_sensor.working"} @@ -129,8 +151,8 @@ def test_with_include_glob_case4a(): assert testfilter("sun.sun") is False -def test_with_include_domain_glob_filtering_case4a(): - """Test case 4a - include and exclude specified, both have domains and globs.""" +def test_with_include_domain_glob_filtering_case4(): + """Test case 4 - include and exclude specified, both have domains and globs.""" incl_dom = {"light"} incl_glob = {"*working"} incl_ent = {} @@ -142,17 +164,64 @@ def test_with_include_domain_glob_filtering_case4a(): ) assert testfilter("sensor.working") - assert testfilter("sensor.notworking") is False + assert testfilter("sensor.notworking") is True # include is stronger assert testfilter("light.test") - assert testfilter("light.notworking") is False + assert testfilter("light.notworking") is True # include is stronger assert testfilter("light.ignoreme") is False - assert testfilter("binary_sensor.not_working") is False + assert testfilter("binary_sensor.not_working") is True # include is stronger assert testfilter("binary_sensor.another") is False assert testfilter("sun.sun") is False -def test_exclude_domain_case4b(): - """Test case 4b - include and exclude specified, with excluded domain.""" +def test_with_include_domain_glob_filtering_case4a_include_strong(): + """Test case 4 - include and exclude specified, both have domains and globs, and a specifically included entity.""" + incl_dom = {"light"} + incl_glob = {"*working"} + incl_ent = {"binary_sensor.specificly_included"} + excl_dom = {"binary_sensor"} + excl_glob = {"*notworking"} + excl_ent = {"light.ignoreme"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.working") + assert testfilter("sensor.notworking") is True # iclude is stronger + assert testfilter("light.test") + assert testfilter("light.notworking") is True # iclude is stronger + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.not_working") is True # iclude is stronger + assert testfilter("binary_sensor.another") is False + assert testfilter("binary_sensor.specificly_included") is True + assert testfilter("sun.sun") is False + + +def test_with_include_glob_filtering_case4a_include_strong(): + """Test case 4 - include and exclude specified, both have globs, and a specifically included entity.""" + incl_dom = {} + incl_glob = {"*working"} + incl_ent = {"binary_sensor.specificly_included"} + excl_dom = {} + excl_glob = {"*broken", "*notworking", "binary_sensor.*"} + excl_ent = {"light.ignoreme"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.working") is True + assert testfilter("sensor.notworking") is True # include is stronger + assert testfilter("sensor.broken") is False + assert testfilter("light.test") is False + assert testfilter("light.notworking") is True # include is stronger + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.not_working") is True # include is stronger + assert testfilter("binary_sensor.another") is False + assert testfilter("binary_sensor.specificly_included") is True + assert testfilter("sun.sun") is False + + +def test_exclude_domain_case5(): + """Test case 5 - include and exclude specified, with excluded domain.""" incl_dom = {} incl_ent = {"binary_sensor.working"} excl_dom = {"binary_sensor"} @@ -168,8 +237,8 @@ def test_exclude_domain_case4b(): assert testfilter("sun.sun") is True -def test_exclude_glob_case4b(): - """Test case 4b - include and exclude specified, with excluded glob.""" +def test_exclude_glob_case5(): + """Test case 5 - include and exclude specified, with excluded glob.""" incl_dom = {} incl_glob = {} incl_ent = {"binary_sensor.working"} @@ -189,8 +258,29 @@ def test_exclude_glob_case4b(): assert testfilter("sun.sun") is True -def test_no_domain_case4c(): - """Test case 4c - include and exclude specified, with no domains.""" +def test_exclude_glob_case5_include_strong(): + """Test case 5 - include and exclude specified, with excluded glob, and a specifically included entity.""" + incl_dom = {} + incl_glob = {} + incl_ent = {"binary_sensor.working"} + excl_dom = {"binary_sensor"} + excl_glob = {"binary_sensor.*"} + excl_ent = {"light.ignoreme", "sensor.notworking"} + testfilter = generate_filter( + incl_dom, incl_ent, excl_dom, excl_ent, incl_glob, excl_glob + ) + + assert testfilter("sensor.test") + assert testfilter("sensor.notworking") is False + assert testfilter("light.test") + assert testfilter("light.ignoreme") is False + assert testfilter("binary_sensor.working") + assert testfilter("binary_sensor.another") is False + assert testfilter("sun.sun") is True + + +def test_no_domain_case6(): + """Test case 6 - include and exclude specified, with no domains.""" incl_dom = {} incl_ent = {"binary_sensor.working"} excl_dom = {} From e0d2344db3cd805c399e3a4847f0ed0c4077ee16 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 19:31:33 +0200 Subject: [PATCH 787/947] Use attributes in manual_mqtt alarm (#74124) --- .../manual_mqtt/alarm_control_panel.py | 33 ++++++------------- 1 file changed, 10 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py index 719e85bf16c..66c75b5ed0e 100644 --- a/homeassistant/components/manual_mqtt/alarm_control_panel.py +++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py @@ -5,6 +5,7 @@ import copy import datetime import logging import re +from typing import Any import voluptuous as vol @@ -204,6 +205,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): A trigger_time of zero disables the alarm_trigger service. """ + _attr_should_poll = False _attr_supported_features = ( AlarmControlPanelEntityFeature.ARM_HOME | AlarmControlPanelEntityFeature.ARM_AWAY @@ -231,7 +233,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): """Init the manual MQTT alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass - self._name = name + self._attr_name = name if code_template: self._code = code_template self._code.hass = hass @@ -257,24 +259,14 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): self._state_topic = state_topic self._command_topic = command_topic self._qos = qos - self._code_arm_required = code_arm_required + self._attr_code_arm_required = code_arm_required self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away self._payload_arm_night = payload_arm_night @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): @@ -314,7 +306,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property - def code_format(self): + def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None @@ -322,11 +314,6 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): @@ -338,7 +325,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_HOME ): return @@ -347,7 +334,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_AWAY ): return @@ -356,7 +343,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_NIGHT ): return @@ -417,7 +404,7 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity): return check @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.state != STATE_ALARM_PENDING: return {} From c4ad5aa68ae181c1166953f921c5df631a5b85dc Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 28 Jun 2022 13:11:29 -0600 Subject: [PATCH 788/947] Bump simplisafe-python to 2022.06.1 (#74142) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 4da8f09eff8..a09c273076c 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==2022.06.0"], + "requirements": ["simplisafe-python==2022.06.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "dhcp": [ diff --git a/requirements_all.txt b/requirements_all.txt index 318511f800d..a9644498d50 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2168,7 +2168,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.06.0 +simplisafe-python==2022.06.1 # homeassistant.components.sisyphus sisyphus-control==3.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b8e17fcb21..32ff0c21c48 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1437,7 +1437,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==2022.06.0 +simplisafe-python==2022.06.1 # homeassistant.components.slack slackclient==2.5.0 From abe44a100feaf4df5fe0120fea6bba4b512da64d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 28 Jun 2022 16:02:16 -0400 Subject: [PATCH 789/947] Bump all of ZHA's zigpy dependencies (#73964) Bump zigpy and radio library versions --- homeassistant/components/zha/core/gateway.py | 2 +- homeassistant/components/zha/manifest.json | 14 +++++++------- requirements_all.txt | 14 +++++++------- requirements_test_all.txt | 14 +++++++------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index b9465d5e2aa..099abfe5e88 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -687,7 +687,7 @@ class ZHAGateway: _LOGGER.debug("Shutting down ZHA ControllerApplication") for unsubscribe in self._unsubs: unsubscribe() - await self.application_controller.pre_shutdown() + await self.application_controller.shutdown() def handle_message( self, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 71131f240a9..78aea7c9fb6 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,15 +4,15 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows==0.30.0", + "bellows==0.31.0", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.75", - "zigpy-deconz==0.16.0", - "zigpy==0.46.0", - "zigpy-xbee==0.14.0", - "zigpy-zigate==0.7.4", - "zigpy-znp==0.7.0" + "zha-quirks==0.0.77", + "zigpy-deconz==0.18.0", + "zigpy==0.47.2", + "zigpy-xbee==0.15.0", + "zigpy-zigate==0.9.0", + "zigpy-znp==0.8.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index a9644498d50..3421120eafa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -390,7 +390,7 @@ beautifulsoup4==4.11.1 # beewi_smartclim==0.0.10 # homeassistant.components.zha -bellows==0.30.0 +bellows==0.31.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.9.6 @@ -2498,7 +2498,7 @@ zengge==0.2 zeroconf==0.38.7 # homeassistant.components.zha -zha-quirks==0.0.75 +zha-quirks==0.0.77 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2507,19 +2507,19 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.16.0 +zigpy-deconz==0.18.0 # homeassistant.components.zha -zigpy-xbee==0.14.0 +zigpy-xbee==0.15.0 # homeassistant.components.zha -zigpy-zigate==0.7.4 +zigpy-zigate==0.9.0 # homeassistant.components.zha -zigpy-znp==0.7.0 +zigpy-znp==0.8.0 # homeassistant.components.zha -zigpy==0.46.0 +zigpy==0.47.2 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32ff0c21c48..b18aad6e18b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -305,7 +305,7 @@ base36==0.1.1 beautifulsoup4==4.11.1 # homeassistant.components.zha -bellows==0.30.0 +bellows==0.31.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.9.6 @@ -1659,22 +1659,22 @@ youless-api==0.16 zeroconf==0.38.7 # homeassistant.components.zha -zha-quirks==0.0.75 +zha-quirks==0.0.77 # homeassistant.components.zha -zigpy-deconz==0.16.0 +zigpy-deconz==0.18.0 # homeassistant.components.zha -zigpy-xbee==0.14.0 +zigpy-xbee==0.15.0 # homeassistant.components.zha -zigpy-zigate==0.7.4 +zigpy-zigate==0.9.0 # homeassistant.components.zha -zigpy-znp==0.7.0 +zigpy-znp==0.8.0 # homeassistant.components.zha -zigpy==0.46.0 +zigpy==0.47.2 # homeassistant.components.zwave_js zwave-js-server-python==0.39.0 From 4bfdb1433e95dfe504e376ca082def5257c23bcb Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 28 Jun 2022 15:19:27 -0500 Subject: [PATCH 790/947] Optimize Sonos unjoin behavior when using `media_player.unjoin` (#74086) * Coalesce Sonos unjoins to process together * Refactor for readability * Skip unjoin call if already ungrouped * Store unjoin data in a dedicated dataclass * Revert import adjustment --- homeassistant/components/sonos/__init__.py | 10 +++++++ .../components/sonos/media_player.py | 30 ++++++++++++++++--- homeassistant/components/sonos/speaker.py | 2 ++ 3 files changed, 38 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 2e2290dff04..e3f65d754dd 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections import OrderedDict +from dataclasses import dataclass, field import datetime from functools import partial import logging @@ -74,6 +75,14 @@ CONFIG_SCHEMA = vol.Schema( ) +@dataclass +class UnjoinData: + """Class to track data necessary for unjoin coalescing.""" + + speakers: list[SonosSpeaker] + event: asyncio.Event = field(default_factory=asyncio.Event) + + class SonosData: """Storage class for platform global data.""" @@ -89,6 +98,7 @@ class SonosData: self.boot_counts: dict[str, int] = {} self.mdns_names: dict[str, str] = {} self.entity_id_mappings: dict[str, SonosSpeaker] = {} + self.unjoin_data: dict[str, UnjoinData] = {} async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0f766f33e6f..7b18b102919 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -44,8 +44,9 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform, service from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later -from . import media_browser +from . import UnjoinData, media_browser from .const import ( DATA_SONOS, DOMAIN as SONOS_DOMAIN, @@ -777,6 +778,27 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): await self.hass.async_add_executor_job(self.speaker.join, speakers) async def async_unjoin_player(self): - """Remove this player from any group.""" - async with self.hass.data[DATA_SONOS].topology_condition: - await self.hass.async_add_executor_job(self.speaker.unjoin) + """Remove this player from any group. + + Coalesces all calls within 0.5s to allow use of SonosSpeaker.unjoin_multi() + which optimizes the order in which speakers are removed from their groups. + Removing coordinators last better preserves playqueues on the speakers. + """ + sonos_data = self.hass.data[DATA_SONOS] + household_id = self.speaker.household_id + + async def async_process_unjoin(now: datetime.datetime) -> None: + """Process the unjoin with all remove requests within the coalescing period.""" + unjoin_data = sonos_data.unjoin_data.pop(household_id) + await SonosSpeaker.unjoin_multi(self.hass, unjoin_data.speakers) + unjoin_data.event.set() + + if unjoin_data := sonos_data.unjoin_data.get(household_id): + unjoin_data.speakers.append(self.speaker) + else: + unjoin_data = sonos_data.unjoin_data[household_id] = UnjoinData( + speakers=[self.speaker] + ) + async_call_later(self.hass, 0.5, async_process_unjoin) + + await unjoin_data.event.wait() diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 729d1a1457f..f4d1d89aa2f 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -906,6 +906,8 @@ class SonosSpeaker: @soco_error() def unjoin(self) -> None: """Unjoin the player from a group.""" + if self.sonos_group == [self]: + return self.soco.unjoin() self.coordinator = None From 1d185388a9c9bb1967dd1ca091ccb6f298885fd4 Mon Sep 17 00:00:00 2001 From: Stefan Rado Date: Tue, 28 Jun 2022 22:22:53 +0200 Subject: [PATCH 791/947] Bump homeconnect to 0.7.1 (#74130) --- homeassistant/components/home_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/home_connect/manifest.json b/homeassistant/components/home_connect/manifest.json index 0a055c971c5..ca6e0f012ac 100644 --- a/homeassistant/components/home_connect/manifest.json +++ b/homeassistant/components/home_connect/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/home_connect", "dependencies": ["application_credentials"], "codeowners": ["@DavidMStraub"], - "requirements": ["homeconnect==0.7.0"], + "requirements": ["homeconnect==0.7.1"], "config_flow": true, "iot_class": "cloud_push", "loggers": ["homeconnect"] diff --git a/requirements_all.txt b/requirements_all.txt index 3421120eafa..a30fd52a2a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -831,7 +831,7 @@ holidays==0.14.2 home-assistant-frontend==20220624.0 # homeassistant.components.home_connect -homeconnect==0.7.0 +homeconnect==0.7.1 # homeassistant.components.homematicip_cloud homematicip==1.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b18aad6e18b..3be3fc1fda1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -598,7 +598,7 @@ holidays==0.14.2 home-assistant-frontend==20220624.0 # homeassistant.components.home_connect -homeconnect==0.7.0 +homeconnect==0.7.1 # homeassistant.components.homematicip_cloud homematicip==1.0.2 From 02d16763015c56a9973c7aa8f6c68a05a7e3c7a9 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 28 Jun 2022 13:29:11 -0700 Subject: [PATCH 792/947] Fix unexpected exception in Google Calendar OAuth exchange (#73963) --- homeassistant/components/google/api.py | 81 ++++++++++++------- .../components/google/config_flow.py | 8 +- tests/components/google/test_config_flow.py | 50 +++++++----- 3 files changed, 86 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index f4a4912a1b9..47aa32dcd11 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -2,7 +2,6 @@ from __future__ import annotations -from collections.abc import Awaitable, Callable import datetime import logging from typing import Any, cast @@ -19,9 +18,12 @@ from oauth2client.client import ( from homeassistant.components.application_credentials import AuthImplementation from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.event import ( + async_track_point_in_utc_time, + async_track_time_interval, +) from homeassistant.util import dt from .const import ( @@ -76,6 +78,9 @@ class DeviceFlow: self._oauth_flow = oauth_flow self._device_flow_info: DeviceFlowInfo = device_flow_info self._exchange_task_unsub: CALLBACK_TYPE | None = None + self._timeout_unsub: CALLBACK_TYPE | None = None + self._listener: CALLBACK_TYPE | None = None + self._creds: Credentials | None = None @property def verification_url(self) -> str: @@ -87,15 +92,22 @@ class DeviceFlow: """Return the code that the user should enter at the verification url.""" return self._device_flow_info.user_code # type: ignore[no-any-return] - async def start_exchange_task( - self, finished_cb: Callable[[Credentials | None], Awaitable[None]] + @callback + def async_set_listener( + self, + update_callback: CALLBACK_TYPE, ) -> None: - """Start the device auth exchange flow polling. + """Invoke the update callback when the exchange finishes or on timeout.""" + self._listener = update_callback - The callback is invoked with the valid credentials or with None on timeout. - """ + @property + def creds(self) -> Credentials | None: + """Return result of exchange step or None on timeout.""" + return self._creds + + def async_start_exchange(self) -> None: + """Start the device auth exchange flow polling.""" _LOGGER.debug("Starting exchange flow") - assert not self._exchange_task_unsub max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS) # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime # object without tzinfo. For the comparison below to work, it needs one. @@ -104,31 +116,40 @@ class DeviceFlow: ) expiration_time = min(user_code_expiry, max_timeout) - def _exchange() -> Credentials: - return self._oauth_flow.step2_exchange( - device_flow_info=self._device_flow_info - ) - - async def _poll_attempt(now: datetime.datetime) -> None: - assert self._exchange_task_unsub - _LOGGER.debug("Attempting OAuth code exchange") - # Note: The callback is invoked with None when the device code has expired - creds: Credentials | None = None - if now < expiration_time: - try: - creds = await self._hass.async_add_executor_job(_exchange) - except FlowExchangeError: - _LOGGER.debug("Token not yet ready; trying again later") - return - self._exchange_task_unsub() - self._exchange_task_unsub = None - await finished_cb(creds) - self._exchange_task_unsub = async_track_time_interval( self._hass, - _poll_attempt, + self._async_poll_attempt, datetime.timedelta(seconds=self._device_flow_info.interval), ) + self._timeout_unsub = async_track_point_in_utc_time( + self._hass, self._async_timeout, expiration_time + ) + + async def _async_poll_attempt(self, now: datetime.datetime) -> None: + _LOGGER.debug("Attempting OAuth code exchange") + try: + self._creds = await self._hass.async_add_executor_job(self._exchange) + except FlowExchangeError: + _LOGGER.debug("Token not yet ready; trying again later") + return + self._finish() + + def _exchange(self) -> Credentials: + return self._oauth_flow.step2_exchange(device_flow_info=self._device_flow_info) + + @callback + def _async_timeout(self, now: datetime.datetime) -> None: + _LOGGER.debug("OAuth token exchange timeout") + self._finish() + + @callback + def _finish(self) -> None: + if self._exchange_task_unsub: + self._exchange_task_unsub() + if self._timeout_unsub: + self._timeout_unsub() + if self._listener: + self._listener() def get_feature_access( diff --git a/homeassistant/components/google/config_flow.py b/homeassistant/components/google/config_flow.py index cbe1de69f9e..22b62094e76 100644 --- a/homeassistant/components/google/config_flow.py +++ b/homeassistant/components/google/config_flow.py @@ -7,7 +7,6 @@ from typing import Any from gcal_sync.api import GoogleCalendarService from gcal_sync.exceptions import ApiException -from oauth2client.client import Credentials import voluptuous as vol from homeassistant import config_entries @@ -97,9 +96,9 @@ class OAuth2FlowHandler( return self.async_abort(reason="oauth_error") self._device_flow = device_flow - async def _exchange_finished(creds: Credentials | None) -> None: + def _exchange_finished() -> None: self.external_data = { - DEVICE_AUTH_CREDS: creds + DEVICE_AUTH_CREDS: device_flow.creds } # is None on timeout/expiration self.hass.async_create_task( self.hass.config_entries.flow.async_configure( @@ -107,7 +106,8 @@ class OAuth2FlowHandler( ) ) - await device_flow.start_exchange_task(_exchange_finished) + device_flow.async_set_listener(_exchange_finished) + device_flow.async_start_exchange() return self.async_show_progress( step_id="auth", diff --git a/tests/components/google/test_config_flow.py b/tests/components/google/test_config_flow.py index 00f50e129e4..24ad8a7b769 100644 --- a/tests/components/google/test_config_flow.py +++ b/tests/components/google/test_config_flow.py @@ -10,6 +10,7 @@ from unittest.mock import Mock, patch from aiohttp.client_exceptions import ClientError from freezegun.api import FrozenDateTimeFactory from oauth2client.client import ( + DeviceFlowInfo, FlowExchangeError, OAuth2Credentials, OAuth2DeviceCodeError, @@ -59,10 +60,17 @@ async def mock_code_flow( ) -> YieldFixture[Mock]: """Fixture for initiating OAuth flow.""" with patch( - "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", ) as mock_flow: - mock_flow.return_value.user_code_expiry = utcnow() + code_expiration_delta - mock_flow.return_value.interval = CODE_CHECK_INTERVAL + mock_flow.return_value = DeviceFlowInfo.FromResponse( + { + "device_code": "4/4-GMMhmHCXhWEzkobqIHGG_EnNYYsAkukHspeYUk9E8", + "user_code": "GQVQ-JKEC", + "verification_url": "https://www.google.com/device", + "expires_in": code_expiration_delta.total_seconds(), + "interval": CODE_CHECK_INTERVAL, + } + ) yield mock_flow @@ -70,7 +78,8 @@ async def mock_code_flow( async def mock_exchange(creds: OAuth2Credentials) -> YieldFixture[Mock]: """Fixture for mocking out the exchange for credentials.""" with patch( - "oauth2client.client.OAuth2WebServerFlow.step2_exchange", return_value=creds + "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", + return_value=creds, ) as mock: yield mock @@ -108,7 +117,6 @@ async def fire_alarm(hass, point_in_time): await hass.async_block_till_done() -@pytest.mark.freeze_time("2022-06-03 15:19:59-00:00") async def test_full_flow_yaml_creds( hass: HomeAssistant, mock_code_flow: Mock, @@ -131,9 +139,8 @@ async def test_full_flow_yaml_creds( "homeassistant.components.google.async_setup_entry", return_value=True ) as mock_setup: # Run one tick to invoke the credential exchange check - freezer.tick(CODE_CHECK_ALARM_TIMEDELTA) - await fire_alarm(hass, datetime.datetime.utcnow()) - await hass.async_block_till_done() + now = utcnow() + await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) result = await hass.config_entries.flow.async_configure( flow_id=result["flow_id"] ) @@ -143,11 +150,12 @@ async def test_full_flow_yaml_creds( assert "data" in result data = result["data"] assert "token" in data + assert 0 < data["token"]["expires_in"] <= 60 * 60 assert ( - data["token"]["expires_in"] - == 60 * 60 - CODE_CHECK_ALARM_TIMEDELTA.total_seconds() + datetime.datetime.now().timestamp() + <= data["token"]["expires_at"] + < (datetime.datetime.now() + datetime.timedelta(days=8)).timestamp() ) - assert data["token"]["expires_at"] == 1654273199.0 data["token"].pop("expires_at") data["token"].pop("expires_in") assert data == { @@ -238,7 +246,7 @@ async def test_code_error( assert await component_setup() with patch( - "oauth2client.client.OAuth2WebServerFlow.step1_get_device_and_user_codes", + "homeassistant.components.google.api.OAuth2WebServerFlow.step1_get_device_and_user_codes", side_effect=OAuth2DeviceCodeError("Test Failure"), ): result = await hass.config_entries.flow.async_init( @@ -248,13 +256,13 @@ async def test_code_error( assert result.get("reason") == "oauth_error" -@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(minutes=-5)]) +@pytest.mark.parametrize("code_expiration_delta", [datetime.timedelta(seconds=50)]) async def test_expired_after_exchange( hass: HomeAssistant, mock_code_flow: Mock, component_setup: ComponentSetup, ) -> None: - """Test successful creds setup.""" + """Test credential exchange expires.""" assert await component_setup() result = await hass.config_entries.flow.async_init( @@ -265,10 +273,14 @@ async def test_expired_after_exchange( assert "description_placeholders" in result assert "url" in result["description_placeholders"] - # Run one tick to invoke the credential exchange check - now = utcnow() - await fire_alarm(hass, now + CODE_CHECK_ALARM_TIMEDELTA) - await hass.async_block_till_done() + # Fail first attempt then advance clock past exchange timeout + with patch( + "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", + side_effect=FlowExchangeError(), + ): + now = utcnow() + await fire_alarm(hass, now + datetime.timedelta(seconds=65)) + await hass.async_block_till_done() result = await hass.config_entries.flow.async_configure(flow_id=result["flow_id"]) assert result.get("type") == "abort" @@ -295,7 +307,7 @@ async def test_exchange_error( # Run one tick to invoke the credential exchange check now = utcnow() with patch( - "oauth2client.client.OAuth2WebServerFlow.step2_exchange", + "homeassistant.components.google.api.OAuth2WebServerFlow.step2_exchange", side_effect=FlowExchangeError(), ): now += CODE_CHECK_ALARM_TIMEDELTA From a284ebe771c7930e578678ce0ee9b44cdcd81a88 Mon Sep 17 00:00:00 2001 From: Mick Vleeshouwer Date: Tue, 28 Jun 2022 22:39:37 +0200 Subject: [PATCH 793/947] Add support for Atlantic Electrical Towel Dryer to Overkiz integration (#73788) --- .../overkiz/climate_entities/__init__.py | 2 + .../atlantic_electrical_towel_dryer.py | 122 ++++++++++++++++++ homeassistant/components/overkiz/const.py | 1 + 3 files changed, 125 insertions(+) create mode 100644 homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py diff --git a/homeassistant/components/overkiz/climate_entities/__init__.py b/homeassistant/components/overkiz/climate_entities/__init__.py index d44aa85d167..737ea342c40 100644 --- a/homeassistant/components/overkiz/climate_entities/__init__.py +++ b/homeassistant/components/overkiz/climate_entities/__init__.py @@ -2,11 +2,13 @@ from pyoverkiz.enums.ui import UIWidget from .atlantic_electrical_heater import AtlanticElectricalHeater +from .atlantic_electrical_towel_dryer import AtlanticElectricalTowelDryer from .atlantic_pass_apc_zone_control import AtlanticPassAPCZoneControl from .somfy_thermostat import SomfyThermostat WIDGET_TO_CLIMATE_ENTITY = { UIWidget.ATLANTIC_ELECTRICAL_HEATER: AtlanticElectricalHeater, + UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: AtlanticElectricalTowelDryer, UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: AtlanticPassAPCZoneControl, UIWidget.SOMFY_THERMOSTAT: SomfyThermostat, } diff --git a/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py new file mode 100644 index 00000000000..9a24a7bf1a9 --- /dev/null +++ b/homeassistant/components/overkiz/climate_entities/atlantic_electrical_towel_dryer.py @@ -0,0 +1,122 @@ +"""Support for Atlantic Electrical Towel Dryer.""" +from __future__ import annotations + +from typing import Any, cast + +from pyoverkiz.enums import OverkizCommand, OverkizCommandParam, OverkizState + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + PRESET_BOOST, + PRESET_NONE, + ClimateEntityFeature, + HVACMode, +) +from homeassistant.components.overkiz.coordinator import OverkizDataUpdateCoordinator +from homeassistant.components.overkiz.entity import OverkizEntity +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS + +PRESET_DRYING = "drying" + +OVERKIZ_TO_HVAC_MODE: dict[str, str] = { + OverkizCommandParam.EXTERNAL: HVACMode.HEAT, # manu + OverkizCommandParam.INTERNAL: HVACMode.AUTO, # prog + OverkizCommandParam.STANDBY: HVACMode.OFF, +} +HVAC_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_HVAC_MODE.items()} + +OVERKIZ_TO_PRESET_MODE: dict[str, str] = { + OverkizCommandParam.PERMANENT_HEATING: PRESET_NONE, + OverkizCommandParam.BOOST: PRESET_BOOST, + OverkizCommandParam.DRYING: PRESET_DRYING, +} + +PRESET_MODE_TO_OVERKIZ = {v: k for k, v in OVERKIZ_TO_PRESET_MODE.items()} + +TEMPERATURE_SENSOR_DEVICE_INDEX = 7 + + +class AtlanticElectricalTowelDryer(OverkizEntity, ClimateEntity): + """Representation of Atlantic Electrical Towel Dryer.""" + + _attr_hvac_modes = [*HVAC_MODE_TO_OVERKIZ] + _attr_preset_modes = [*PRESET_MODE_TO_OVERKIZ] + _attr_temperature_unit = TEMP_CELSIUS + + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Init method.""" + super().__init__(device_url, coordinator) + self.temperature_device = self.executor.linked_device( + TEMPERATURE_SENSOR_DEVICE_INDEX + ) + + self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + + # Not all AtlanticElectricalTowelDryer models support presets, thus we need to check if the command is available + if self.executor.has_command(OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE): + self._attr_supported_features += ClimateEntityFeature.PRESET_MODE + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + if OverkizState.CORE_OPERATING_MODE in self.device.states: + return OVERKIZ_TO_HVAC_MODE[ + cast(str, self.executor.select_state(OverkizState.CORE_OPERATING_MODE)) + ] + + return HVACMode.OFF + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_OPERATING_MODE, + HVAC_MODE_TO_OVERKIZ[hvac_mode], + ) + + @property + def target_temperature(self) -> None: + """Return the temperature.""" + if self.hvac_mode == HVACMode.AUTO: + self.executor.select_state(OverkizState.IO_EFFECTIVE_TEMPERATURE_SETPOINT) + else: + self.executor.select_state(OverkizState.CORE_TARGET_TEMPERATURE) + + @property + def current_temperature(self) -> float | None: + """Return the current temperature.""" + if temperature := self.temperature_device.states[OverkizState.CORE_TEMPERATURE]: + return cast(float, temperature.value) + + return None + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new temperature.""" + temperature = kwargs[ATTR_TEMPERATURE] + + if self.hvac_mode == HVACMode.AUTO: + await self.executor.async_execute_command( + OverkizCommand.SET_DEROGATED_TARGET_TEMPERATURE, temperature + ) + else: + await self.executor.async_execute_command( + OverkizCommand.SET_TARGET_TEMPERATURE, temperature + ) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode, e.g., home, away, temp.""" + return OVERKIZ_TO_PRESET_MODE[ + cast( + str, + self.executor.select_state(OverkizState.IO_TOWEL_DRYER_TEMPORARY_STATE), + ) + ] + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + await self.executor.async_execute_command( + OverkizCommand.SET_TOWEL_DRYER_TEMPORARY_STATE, + PRESET_MODE_TO_OVERKIZ[preset_mode], + ) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 447ebc5ac42..9091cd35998 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -62,6 +62,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIClass.WINDOW: Platform.COVER, UIWidget.ALARM_PANEL_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) UIWidget.ATLANTIC_ELECTRICAL_HEATER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ATLANTIC_ELECTRICAL_TOWEL_DRYER: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.MY_FOX_ALARM_CONTROLLER: Platform.ALARM_CONTROL_PANEL, # widgetName, uiClass is Alarm (not supported) From abf67c3153e971a76ee6f4752d9f524a8fc5e822 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 28 Jun 2022 22:45:25 +0200 Subject: [PATCH 794/947] Normalize deCONZ binary sensor unique IDs (#73657) --- .../components/deconz/binary_sensor.py | 30 +++++++++++-- tests/components/deconz/test_binary_sensor.py | 43 +++++++++++++------ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index d109fb8b34d..0d090751edd 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -27,8 +27,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback +import homeassistant.helpers.entity_registry as er -from .const import ATTR_DARK, ATTR_ON +from .const import ATTR_DARK, ATTR_ON, DOMAIN as DECONZ_DOMAIN from .deconz_device import DeconzDevice from .gateway import DeconzGateway, get_gateway_from_config_entry @@ -179,6 +180,27 @@ BINARY_SENSOR_DESCRIPTIONS = [ ] +@callback +def async_update_unique_id( + hass: HomeAssistant, unique_id: str, description: DeconzBinarySensorDescription +) -> None: + """Update unique ID to always have a suffix. + + Introduced with release 2022.7. + """ + ent_reg = er.async_get(hass) + + new_unique_id = f"{unique_id}-{description.key}" + if ent_reg.async_get_entity_id(DOMAIN, DECONZ_DOMAIN, new_unique_id): + return + + if description.suffix: + unique_id = f'{unique_id.split("-", 1)[0]}-{description.suffix.lower()}' + + if entity_id := ent_reg.async_get_entity_id(DOMAIN, DECONZ_DOMAIN, unique_id): + ent_reg.async_update_entity(entity_id, new_unique_id=new_unique_id) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -205,6 +227,8 @@ async def async_setup_entry( ): continue + async_update_unique_id(hass, sensor.unique_id, description) + async_add_entities([DeconzBinarySensor(sensor, gateway, description)]) gateway.register_platform_add_device_callback( @@ -255,9 +279,7 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorEntity): @property def unique_id(self) -> str: """Return a unique identifier for this device.""" - if self.entity_description.suffix: - return f"{self.serial}-{self.entity_description.suffix.lower()}" - return super().unique_id + return f"{super().unique_id}-{self.entity_description.key}" @callback def async_update_callback(self) -> None: diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index b6a21fb9fa6..ae62205c636 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDeviceClass from homeassistant.components.deconz.const import ( CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_NEW_DEVICES, @@ -63,7 +63,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.alarm_10", - "unique_id": "00:15:8d:00:02:b5:d1:80-01-0500", + "unique_id": "00:15:8d:00:02:b5:d1:80-01-0500-alarm", + "old_unique_id": "00:15:8d:00:02:b5:d1:80-01-0500", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.SAFETY, @@ -104,7 +105,8 @@ TEST_DATA = [ "entity_count": 4, "device_count": 3, "entity_id": "binary_sensor.cave_co", - "unique_id": "00:15:8d:00:02:a5:21:24-01-0101", + "unique_id": "00:15:8d:00:02:a5:21:24-01-0101-carbon_monoxide", + "old_unique_id": "00:15:8d:00:02:a5:21:24-01-0101", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.CO, @@ -139,7 +141,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "binary_sensor.sensor_kitchen_smoke", - "unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500", + "unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500-fire", + "old_unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.SMOKE, @@ -175,7 +178,8 @@ TEST_DATA = [ "entity_count": 2, "device_count": 3, "entity_id": "binary_sensor.sensor_kitchen_smoke_test_mode", - "unique_id": "00:15:8d:00:01:d9:3e:7c-test mode", + "unique_id": "00:15:8d:00:01:d9:3e:7c-01-0500-in_test_mode", + "old_unique_id": "00:15:8d:00:01:d9:3e:7c-test mode", "state": STATE_OFF, "entity_category": EntityCategory.DIAGNOSTIC, "device_class": BinarySensorDeviceClass.SMOKE, @@ -207,7 +211,8 @@ TEST_DATA = [ "entity_count": 1, "device_count": 2, "entity_id": "binary_sensor.kitchen_switch", - "unique_id": "kitchen-switch", + "unique_id": "kitchen-switch-flag", + "old_unique_id": "kitchen-switch", "state": STATE_ON, "entity_category": None, "device_class": None, @@ -244,7 +249,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.back_door", - "unique_id": "00:15:8d:00:02:2b:96:b4-01-0006", + "unique_id": "00:15:8d:00:02:2b:96:b4-01-0006-open", + "old_unique_id": "00:15:8d:00:02:2b:96:b4-01-0006", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.OPENING, @@ -290,7 +296,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.motion_sensor_4", - "unique_id": "00:17:88:01:03:28:8c:9b-02-0406", + "unique_id": "00:17:88:01:03:28:8c:9b-02-0406-presence", + "old_unique_id": "00:17:88:01:03:28:8c:9b-02-0406", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.MOTION, @@ -331,7 +338,8 @@ TEST_DATA = [ "entity_count": 5, "device_count": 3, "entity_id": "binary_sensor.water2", - "unique_id": "00:15:8d:00:02:2f:07:db-01-0500", + "unique_id": "00:15:8d:00:02:2f:07:db-01-0500-water", + "old_unique_id": "00:15:8d:00:02:2f:07:db-01-0500", "state": STATE_OFF, "entity_category": None, "device_class": BinarySensorDeviceClass.MOISTURE, @@ -376,7 +384,8 @@ TEST_DATA = [ "entity_count": 3, "device_count": 3, "entity_id": "binary_sensor.vibration_1", - "unique_id": "00:15:8d:00:02:a5:21:24-01-0101", + "unique_id": "00:15:8d:00:02:a5:21:24-01-0101-vibration", + "old_unique_id": "00:15:8d:00:02:a5:21:24-01-0101", "state": STATE_ON, "entity_category": None, "device_class": BinarySensorDeviceClass.VIBRATION, @@ -414,7 +423,8 @@ TEST_DATA = [ "entity_count": 4, "device_count": 3, "entity_id": "binary_sensor.presence_sensor_tampered", - "unique_id": "00:00:00:00:00:00:00:00-tampered", + "unique_id": "00:00:00:00:00:00:00:00-00-tampered", + "old_unique_id": "00:00:00:00:00:00:00:00-tampered", "state": STATE_OFF, "entity_category": EntityCategory.DIAGNOSTIC, "device_class": BinarySensorDeviceClass.TAMPER, @@ -447,7 +457,8 @@ TEST_DATA = [ "entity_count": 4, "device_count": 3, "entity_id": "binary_sensor.presence_sensor_low_battery", - "unique_id": "00:00:00:00:00:00:00:00-low battery", + "unique_id": "00:00:00:00:00:00:00:00-00-low_battery", + "old_unique_id": "00:00:00:00:00:00:00:00-low battery", "state": STATE_OFF, "entity_category": EntityCategory.DIAGNOSTIC, "device_class": BinarySensorDeviceClass.BATTERY, @@ -470,6 +481,14 @@ async def test_binary_sensors( ent_reg = er.async_get(hass) dev_reg = dr.async_get(hass) + # Create entity entry to migrate to new unique ID + ent_reg.async_get_or_create( + DOMAIN, + DECONZ_DOMAIN, + expected["old_unique_id"], + suggested_object_id=expected["entity_id"].replace(DOMAIN, ""), + ) + with patch.dict(DECONZ_WEB_REQUEST, {"sensors": {"1": sensor_data}}): config_entry = await setup_deconz_integration( hass, aioclient_mock, options={CONF_ALLOW_CLIP_SENSOR: True} From 146ff83a1612c8d8d3fe6641cefc1aeda439671a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 28 Jun 2022 22:53:38 +0200 Subject: [PATCH 795/947] Migrate rest binary_sensor and switch to TemplateEntity (#73307) --- .../components/rest/binary_sensor.py | 52 ++++---- homeassistant/components/rest/entity.py | 23 +--- homeassistant/components/rest/schema.py | 9 +- homeassistant/components/rest/sensor.py | 6 +- homeassistant/components/rest/switch.py | 118 +++++++----------- .../components/template/binary_sensor.py | 2 - homeassistant/helpers/template_entity.py | 4 +- tests/components/rest/test_binary_sensor.py | 39 ++++++ tests/components/rest/test_switch.py | 67 ++++++++-- 9 files changed, 178 insertions(+), 142 deletions(-) diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 2beed53522a..bc51433c3c5 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -11,18 +11,20 @@ from homeassistant.components.binary_sensor import ( from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, - CONF_NAME, CONF_RESOURCE, CONF_RESOURCE_TEMPLATE, + CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import TemplateEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import async_get_config_and_coordinator, create_rest_data_from_config +from .const import DEFAULT_BINARY_SENSOR_NAME from .entity import RestEntity from .schema import BINARY_SENSOR_SCHEMA, RESOURCE_SCHEMA @@ -57,51 +59,55 @@ async def async_setup_platform( raise PlatformNotReady from rest.last_exception raise PlatformNotReady - name = conf.get(CONF_NAME) - device_class = conf.get(CONF_DEVICE_CLASS) - value_template = conf.get(CONF_VALUE_TEMPLATE) - force_update = conf.get(CONF_FORCE_UPDATE) - resource_template = conf.get(CONF_RESOURCE_TEMPLATE) - - if value_template is not None: - value_template.hass = hass + unique_id = conf.get(CONF_UNIQUE_ID) async_add_entities( [ RestBinarySensor( + hass, coordinator, rest, - name, - device_class, - value_template, - force_update, - resource_template, + conf, + unique_id, ) ], ) -class RestBinarySensor(RestEntity, BinarySensorEntity): +class RestBinarySensor(RestEntity, TemplateEntity, BinarySensorEntity): """Representation of a REST binary sensor.""" def __init__( self, + hass, coordinator, rest, - name, - device_class, - value_template, - force_update, - resource_template, + config, + unique_id, ): """Initialize a REST binary sensor.""" - super().__init__(coordinator, rest, name, resource_template, force_update) + RestEntity.__init__( + self, + coordinator, + rest, + config.get(CONF_RESOURCE_TEMPLATE), + config.get(CONF_FORCE_UPDATE), + ) + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=DEFAULT_BINARY_SENSOR_NAME, + unique_id=unique_id, + ) self._state = False self._previous_data = None - self._value_template = value_template + self._value_template = config.get(CONF_VALUE_TEMPLATE) + if (value_template := self._value_template) is not None: + value_template.hass = hass self._is_on = None - self._attr_device_class = device_class + self._attr_device_class = config.get(CONF_DEVICE_CLASS) @property def is_on(self): diff --git a/homeassistant/components/rest/entity.py b/homeassistant/components/rest/entity.py index f0476dc7d33..5d7a65b3d48 100644 --- a/homeassistant/components/rest/entity.py +++ b/homeassistant/components/rest/entity.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .data import RestData -class BaseRestEntity(Entity): +class RestEntity(Entity): """A class for entities using DataUpdateCoordinator or rest data directly.""" def __init__( @@ -72,24 +72,3 @@ class BaseRestEntity(Entity): @abstractmethod def _update_from_rest_data(self): """Update state from the rest data.""" - - -class RestEntity(BaseRestEntity): - """A class for entities using DataUpdateCoordinator or rest data directly.""" - - def __init__( - self, - coordinator: DataUpdateCoordinator[Any], - rest: RestData, - name, - resource_template, - force_update, - ) -> None: - """Create the entity that may have a coordinator.""" - self._name = name - super().__init__(coordinator, rest, resource_template, force_update) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name diff --git a/homeassistant/components/rest/schema.py b/homeassistant/components/rest/schema.py index d25bb50167b..f881dc8b028 100644 --- a/homeassistant/components/rest/schema.py +++ b/homeassistant/components/rest/schema.py @@ -13,7 +13,6 @@ from homeassistant.const import ( CONF_FORCE_UPDATE, CONF_HEADERS, CONF_METHOD, - CONF_NAME, CONF_PARAMS, CONF_PASSWORD, CONF_PAYLOAD, @@ -28,12 +27,14 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.template_entity import TEMPLATE_SENSOR_BASE_SCHEMA +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TEMPLATE_SENSOR_BASE_SCHEMA, +) from .const import ( CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, - DEFAULT_BINARY_SENSOR_NAME, DEFAULT_FORCE_UPDATE, DEFAULT_METHOD, DEFAULT_VERIFY_SSL, @@ -67,7 +68,7 @@ SENSOR_SCHEMA = { } BINARY_SENSOR_SCHEMA = { - vol.Optional(CONF_NAME, default=DEFAULT_BINARY_SENSOR_NAME): cv.string, + **TEMPLATE_ENTITY_BASE_SCHEMA.schema, vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 93a96aeb94f..ff571b0c9dc 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -31,7 +31,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import async_get_config_and_coordinator, create_rest_data_from_config from .const import CONF_JSON_ATTRS, CONF_JSON_ATTRS_PATH, DEFAULT_SENSOR_NAME -from .entity import BaseRestEntity +from .entity import RestEntity from .schema import RESOURCE_SCHEMA, SENSOR_SCHEMA _LOGGER = logging.getLogger(__name__) @@ -82,7 +82,7 @@ async def async_setup_platform( ) -class RestSensor(BaseRestEntity, TemplateSensor): +class RestSensor(RestEntity, TemplateSensor): """Implementation of a REST sensor.""" def __init__( @@ -94,7 +94,7 @@ class RestSensor(BaseRestEntity, TemplateSensor): unique_id, ): """Initialize the REST sensor.""" - BaseRestEntity.__init__( + RestEntity.__init__( self, coordinator, rest, diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 10214970cce..c45eb581645 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -18,11 +18,11 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_HEADERS, CONF_METHOD, - CONF_NAME, CONF_PARAMS, CONF_PASSWORD, CONF_RESOURCE, CONF_TIMEOUT, + CONF_UNIQUE_ID, CONF_USERNAME, CONF_VERIFY_SSL, ) @@ -30,6 +30,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.template_entity import ( + TEMPLATE_ENTITY_BASE_SCHEMA, + TemplateEntity, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -49,6 +53,7 @@ SUPPORT_REST_METHODS = ["post", "put", "patch"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { + **TEMPLATE_ENTITY_BASE_SCHEMA.schema, vol.Required(CONF_RESOURCE): cv.url, vol.Optional(CONF_STATE_RESOURCE): cv.url, vol.Optional(CONF_HEADERS): {cv.string: cv.template}, @@ -59,7 +64,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.All( vol.Lower, vol.In(SUPPORT_REST_METHODS) ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, vol.Inclusive(CONF_USERNAME, "authentication"): cv.string, @@ -76,50 +80,11 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the RESTful switch.""" - body_off = config.get(CONF_BODY_OFF) - body_on = config.get(CONF_BODY_ON) - is_on_template = config.get(CONF_IS_ON_TEMPLATE) - method = config.get(CONF_METHOD) - headers = config.get(CONF_HEADERS) - params = config.get(CONF_PARAMS) - name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) - username = config.get(CONF_USERNAME) resource = config.get(CONF_RESOURCE) - state_resource = config.get(CONF_STATE_RESOURCE) or resource - verify_ssl = config.get(CONF_VERIFY_SSL) - - auth = None - if username: - auth = aiohttp.BasicAuth(username, password=config[CONF_PASSWORD]) - - if is_on_template is not None: - is_on_template.hass = hass - if body_on is not None: - body_on.hass = hass - if body_off is not None: - body_off.hass = hass - - template.attach(hass, headers) - template.attach(hass, params) - timeout = config.get(CONF_TIMEOUT) + unique_id = config.get(CONF_UNIQUE_ID) try: - switch = RestSwitch( - name, - device_class, - resource, - state_resource, - method, - headers, - params, - auth, - body_on, - body_off, - is_on_template, - timeout, - verify_ssl, - ) + switch = RestSwitch(hass, config, unique_id) req = await switch.get_device_state(hass) if req.status >= HTTPStatus.BAD_REQUEST: @@ -135,46 +100,53 @@ async def async_setup_platform( _LOGGER.error("No route to resource/endpoint: %s", resource) -class RestSwitch(SwitchEntity): +class RestSwitch(TemplateEntity, SwitchEntity): """Representation of a switch that can be toggled using REST.""" def __init__( self, - name, - device_class, - resource, - state_resource, - method, - headers, - params, - auth, - body_on, - body_off, - is_on_template, - timeout, - verify_ssl, + hass, + config, + unique_id, ): """Initialize the REST switch.""" + TemplateEntity.__init__( + self, + hass, + config=config, + fallback_name=DEFAULT_NAME, + unique_id=unique_id, + ) + self._state = None - self._name = name - self._resource = resource - self._state_resource = state_resource - self._method = method - self._headers = headers - self._params = params + + auth = None + if username := config.get(CONF_USERNAME): + auth = aiohttp.BasicAuth(username, password=config[CONF_PASSWORD]) + + self._resource = config.get(CONF_RESOURCE) + self._state_resource = config.get(CONF_STATE_RESOURCE) or self._resource + self._method = config.get(CONF_METHOD) + self._headers = config.get(CONF_HEADERS) + self._params = config.get(CONF_PARAMS) self._auth = auth - self._body_on = body_on - self._body_off = body_off - self._is_on_template = is_on_template - self._timeout = timeout - self._verify_ssl = verify_ssl + self._body_on = config.get(CONF_BODY_ON) + self._body_off = config.get(CONF_BODY_OFF) + self._is_on_template = config.get(CONF_IS_ON_TEMPLATE) + self._timeout = config.get(CONF_TIMEOUT) + self._verify_ssl = config.get(CONF_VERIFY_SSL) - self._attr_device_class = device_class + self._attr_device_class = config.get(CONF_DEVICE_CLASS) - @property - def name(self): - """Return the name of the switch.""" - return self._name + if (is_on_template := self._is_on_template) is not None: + is_on_template.hass = hass + if (body_on := self._body_on) is not None: + body_on.hass = hass + if (body_off := self._body_off) is not None: + body_off.hass = hass + + template.attach(hass, self._headers) + template.attach(hass, self._params) @property def is_on(self): diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index e91a2925b06..ab7c88e8b8c 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -82,9 +82,7 @@ BINARY_SENSOR_SCHEMA = vol.Schema( vol.Optional(CONF_DELAY_OFF): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DELAY_ON): vol.Any(cv.positive_time_period, cv.template), vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME): cv.template, vol.Required(CONF_STATE): cv.template, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } ).extend(TEMPLATE_ENTITY_COMMON_SCHEMA.schema) diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index e92ba233121..83d321e3fa9 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -43,16 +43,16 @@ CONF_PICTURE = "picture" TEMPLATE_ENTITY_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_PICTURE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ) TEMPLATE_SENSOR_BASE_SCHEMA = vol.Schema( { vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_NAME): cv.template, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, - vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, } ).extend(TEMPLATE_ENTITY_BASE_SCHEMA.schema) diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py index 8383d53b51f..a6655f6ddbc 100644 --- a/tests/components/rest/test_binary_sensor.py +++ b/tests/components/rest/test_binary_sensor.py @@ -19,6 +19,8 @@ from homeassistant.const import ( STATE_UNAVAILABLE, Platform, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import get_fixture_path @@ -431,3 +433,40 @@ async def test_setup_query_params(hass): ) await hass.async_block_till_done() assert len(hass.states.async_all("binary_sensor")) == 1 + + +@respx.mock +async def test_entity_config(hass: HomeAssistant) -> None: + """Test entity configuration.""" + + config = { + Platform.BINARY_SENSOR: { + # REST configuration + "platform": "rest", + "method": "GET", + "resource": "http://localhost", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "name": "{{'REST' + ' ' + 'Binary Sensor'}}", + "unique_id": "very_unique", + }, + } + + respx.get("http://localhost") % HTTPStatus.OK + assert await async_setup_component(hass, Platform.BINARY_SENSOR, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert ( + entity_registry.async_get("binary_sensor.rest_binary_sensor").unique_id + == "very_unique" + ) + + state = hass.states.get("binary_sensor.rest_binary_sensor") + assert state.state == "off" + assert state.attributes == { + "entity_picture": "blabla.png", + "friendly_name": "REST Binary Sensor", + "icon": "mdi:one_two_three", + } diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 3dbef91ffb5..a3c0f78db1c 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -8,6 +8,7 @@ from homeassistant.components.rest import DOMAIN import homeassistant.components.rest.switch as rest from homeassistant.components.switch import SwitchDeviceClass from homeassistant.const import ( + CONF_DEVICE_CLASS, CONF_HEADERS, CONF_NAME, CONF_PARAMS, @@ -16,17 +17,19 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, Platform, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.template import Template from homeassistant.setup import async_setup_component from tests.common import assert_setup_component +from tests.test_util.aiohttp import AiohttpClientMocker NAME = "foo" DEVICE_CLASS = SwitchDeviceClass.SWITCH METHOD = "post" RESOURCE = "http://localhost/" STATE_RESOURCE = RESOURCE -AUTH = None PARAMS = None @@ -187,19 +190,22 @@ def _setup_test_switch(hass): body_off = Template("off", hass) headers = {"Content-type": Template(CONTENT_TYPE_JSON, hass)} switch = rest.RestSwitch( - NAME, - DEVICE_CLASS, - RESOURCE, - STATE_RESOURCE, - METHOD, - headers, - PARAMS, - AUTH, - body_on, - body_off, + hass, + { + CONF_NAME: Template(NAME, hass), + CONF_DEVICE_CLASS: DEVICE_CLASS, + CONF_RESOURCE: RESOURCE, + rest.CONF_STATE_RESOURCE: STATE_RESOURCE, + rest.CONF_METHOD: METHOD, + rest.CONF_HEADERS: headers, + rest.CONF_PARAMS: PARAMS, + rest.CONF_BODY_ON: body_on, + rest.CONF_BODY_OFF: body_off, + rest.CONF_IS_ON_TEMPLATE: None, + rest.CONF_TIMEOUT: 10, + rest.CONF_VERIFY_SSL: True, + }, None, - 10, - True, ) switch.hass = hass return switch, body_on, body_off @@ -315,3 +321,38 @@ async def test_update_timeout(hass, aioclient_mock): await switch.async_update() assert switch.is_on is None + + +async def test_entity_config( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test entity configuration.""" + + aioclient_mock.get("http://localhost", status=HTTPStatus.OK) + config = { + Platform.SWITCH: { + # REST configuration + "platform": "rest", + "method": "POST", + "resource": "http://localhost", + # Entity configuration + "icon": "{{'mdi:one_two_three'}}", + "picture": "{{'blabla.png'}}", + "name": "{{'REST' + ' ' + 'Switch'}}", + "unique_id": "very_unique", + }, + } + + assert await async_setup_component(hass, Platform.SWITCH, config) + await hass.async_block_till_done() + + entity_registry = er.async_get(hass) + assert entity_registry.async_get("switch.rest_switch").unique_id == "very_unique" + + state = hass.states.get("switch.rest_switch") + assert state.state == "unknown" + assert state.attributes == { + "entity_picture": "blabla.png", + "friendly_name": "REST Switch", + "icon": "mdi:one_two_three", + } From 9e61c7ec49dd47dc3f7b8744c40739af0f2348c6 Mon Sep 17 00:00:00 2001 From: elBoz73 <26686772+elBoz73@users.noreply.github.com> Date: Tue, 28 Jun 2022 22:57:47 +0200 Subject: [PATCH 796/947] Add target management for the service call (#73332) --- homeassistant/components/sms/notify.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py index 1bd3c60b9b9..433144773f7 100644 --- a/homeassistant/components/sms/notify.py +++ b/homeassistant/components/sms/notify.py @@ -5,7 +5,7 @@ import gammu # pylint: disable=import-error import voluptuous as vol from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService -from homeassistant.const import CONF_NAME, CONF_RECIPIENT +from homeassistant.const import CONF_NAME, CONF_RECIPIENT, CONF_TARGET import homeassistant.helpers.config_validation as cv from .const import DOMAIN, SMS_GATEWAY @@ -44,6 +44,8 @@ class SMSNotificationService(BaseNotificationService): async def async_send_message(self, message="", **kwargs): """Send SMS message.""" + + targets = kwargs.get(CONF_TARGET, [self.number]) smsinfo = { "Class": -1, "Unicode": True, @@ -60,9 +62,11 @@ class SMSNotificationService(BaseNotificationService): for encoded_message in encoded: # Fill in numbers encoded_message["SMSC"] = {"Location": 1} - encoded_message["Number"] = self.number - try: - # Actually send the message - await self.gateway.send_sms_async(encoded_message) - except gammu.GSMError as exc: - _LOGGER.error("Sending to %s failed: %s", self.number, exc) + + for target in targets: + encoded_message["Number"] = target + try: + # Actually send the message + await self.gateway.send_sms_async(encoded_message) + except gammu.GSMError as exc: + _LOGGER.error("Sending to %s failed: %s", target, exc) From 389f1f4edac9ac40270ec046ce456762b2ba926d Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Tue, 28 Jun 2022 23:01:18 +0200 Subject: [PATCH 797/947] Add lcn_codelock event and corresponding device trigger (#73022) --- homeassistant/components/lcn/__init__.py | 2 +- .../components/lcn/device_trigger.py | 4 +- homeassistant/components/lcn/strings.json | 1 + .../components/lcn/translations/en.json | 1 + tests/components/lcn/test_device_trigger.py | 53 ++++++++++++++++++- tests/components/lcn/test_events.py | 18 +++++++ 6 files changed, 76 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py index b0b231cb9e9..d1486fe0d32 100644 --- a/homeassistant/components/lcn/__init__.py +++ b/homeassistant/components/lcn/__init__.py @@ -191,7 +191,7 @@ def async_host_input_received( def _async_fire_access_control_event( hass: HomeAssistant, device: dr.DeviceEntry, address: AddressType, inp: InputType ) -> None: - """Fire access control event (transponder, transmitter, fingerprint).""" + """Fire access control event (transponder, transmitter, fingerprint, codelock).""" event_data = { "segment_id": address[0], "module_id": address[1], diff --git a/homeassistant/components/lcn/device_trigger.py b/homeassistant/components/lcn/device_trigger.py index a6fc17759b8..8ae640cf6c2 100644 --- a/homeassistant/components/lcn/device_trigger.py +++ b/homeassistant/components/lcn/device_trigger.py @@ -16,13 +16,14 @@ from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, KEY_ACTIONS, SENDKEYS -TRIGGER_TYPES = {"transmitter", "transponder", "fingerprint", "send_keys"} +TRIGGER_TYPES = {"transmitter", "transponder", "fingerprint", "codelock", "send_keys"} LCN_DEVICE_TRIGGER_BASE_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( {vol.Required(CONF_TYPE): vol.In(TRIGGER_TYPES)} ) ACCESS_CONTROL_SCHEMA = {vol.Optional("code"): vol.All(vol.Lower, cv.string)} + TRANSMITTER_SCHEMA = { **ACCESS_CONTROL_SCHEMA, vol.Optional("level"): cv.positive_int, @@ -45,6 +46,7 @@ TYPE_SCHEMAS = { "transmitter": {"extra_fields": vol.Schema(TRANSMITTER_SCHEMA)}, "transponder": {"extra_fields": vol.Schema(ACCESS_CONTROL_SCHEMA)}, "fingerprint": {"extra_fields": vol.Schema(ACCESS_CONTROL_SCHEMA)}, + "codelock": {"extra_fields": vol.Schema(ACCESS_CONTROL_SCHEMA)}, "send_keys": {"extra_fields": vol.Schema(SENDKEYS_SCHEMA)}, } diff --git a/homeassistant/components/lcn/strings.json b/homeassistant/components/lcn/strings.json index 2ed8cb8d1c7..c0e46250c1e 100644 --- a/homeassistant/components/lcn/strings.json +++ b/homeassistant/components/lcn/strings.json @@ -4,6 +4,7 @@ "transmitter": "transmitter code received", "transponder": "transponder code received", "fingerprint": "fingerprint code received", + "codelock": "code lock code received", "send_keys": "send keys received" } } diff --git a/homeassistant/components/lcn/translations/en.json b/homeassistant/components/lcn/translations/en.json index ad42b1ffc8f..37f092cbde7 100644 --- a/homeassistant/components/lcn/translations/en.json +++ b/homeassistant/components/lcn/translations/en.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "code lock code received", "fingerprint": "fingerprint code received", "send_keys": "send keys received", "transmitter": "transmitter code received", diff --git a/tests/components/lcn/test_device_trigger.py b/tests/components/lcn/test_device_trigger.py index a52df987e5e..b908d21d5f5 100644 --- a/tests/components/lcn/test_device_trigger.py +++ b/tests/components/lcn/test_device_trigger.py @@ -29,7 +29,13 @@ async def test_get_triggers_module_device(hass, entry, lcn_connection): CONF_DEVICE_ID: device.id, "metadata": {}, } - for trigger in ["transmitter", "transponder", "fingerprint", "send_keys"] + for trigger in [ + "transmitter", + "transponder", + "fingerprint", + "codelock", + "send_keys", + ] ] triggers = await async_get_device_automations( @@ -147,6 +153,51 @@ async def test_if_fires_on_fingerprint_event(hass, calls, entry, lcn_connection) } +async def test_if_fires_on_codelock_event(hass, calls, entry, lcn_connection): + """Test for codelock event triggers firing.""" + address = (0, 7, False) + device = get_device(hass, entry, address) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device.id, + CONF_TYPE: "codelock", + }, + "action": { + "service": "test.automation", + "data_template": { + "test": "test_trigger_codelock", + "code": "{{ trigger.event.data.code }}", + }, + }, + }, + ] + }, + ) + + inp = ModStatusAccessControl( + LcnAddr(*address), + periphery=AccessControlPeriphery.CODELOCK, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + "test": "test_trigger_codelock", + "code": "aabbcc", + } + + async def test_if_fires_on_transmitter_event(hass, calls, entry, lcn_connection): """Test for transmitter event triggers firing.""" address = (0, 7, False) diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py index 38a685ad663..518af46b02a 100644 --- a/tests/components/lcn/test_events.py +++ b/tests/components/lcn/test_events.py @@ -42,6 +42,24 @@ async def test_fire_fingerprint_event(hass, lcn_connection): assert events[0].data["code"] == "aabbcc" +async def test_fire_codelock_event(hass, lcn_connection): + """Test the codelock event is fired.""" + events = async_capture_events(hass, "lcn_codelock") + + inp = ModStatusAccessControl( + LcnAddr(0, 7, False), + periphery=AccessControlPeriphery.CODELOCK, + code="aabbcc", + ) + + await lcn_connection.async_process_input(inp) + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].event_type == "lcn_codelock" + assert events[0].data["code"] == "aabbcc" + + async def test_fire_transmitter_event(hass, lcn_connection): """Test the transmitter event is fired.""" events = async_capture_events(hass, "lcn_transmitter") From efbd47c828c6c2e1cd967df2a4cefd2b00c60c25 Mon Sep 17 00:00:00 2001 From: Stefan Rado Date: Tue, 28 Jun 2022 23:02:39 +0200 Subject: [PATCH 798/947] Rewrite SoundTouch tests to use mocked payloads (#72984) --- .../components/soundtouch/media_player.py | 2 +- tests/components/soundtouch/conftest.py | 286 +++++ .../fixtures/device1_getZone_master.xml | 6 + .../soundtouch/fixtures/device1_info.xml | 32 + .../fixtures/device1_now_playing_aux.xml | 7 + .../device1_now_playing_bluetooth.xml | 16 + .../fixtures/device1_now_playing_radio.xml | 16 + .../fixtures/device1_now_playing_standby.xml | 4 + .../fixtures/device1_now_playing_upnp.xml | 13 + .../device1_now_playing_upnp_paused.xml | 13 + .../soundtouch/fixtures/device1_presets.xml | 12 + .../soundtouch/fixtures/device1_volume.xml | 6 + .../fixtures/device1_volume_muted.xml | 6 + .../fixtures/device2_getZone_slave.xml | 4 + .../soundtouch/fixtures/device2_info.xml | 32 + .../fixtures/device2_now_playing_standby.xml | 4 + .../soundtouch/fixtures/device2_volume.xml | 6 + .../soundtouch/test_media_player.py | 1140 ++++++----------- 18 files changed, 846 insertions(+), 759 deletions(-) create mode 100644 tests/components/soundtouch/conftest.py create mode 100644 tests/components/soundtouch/fixtures/device1_getZone_master.xml create mode 100644 tests/components/soundtouch/fixtures/device1_info.xml create mode 100644 tests/components/soundtouch/fixtures/device1_now_playing_aux.xml create mode 100644 tests/components/soundtouch/fixtures/device1_now_playing_bluetooth.xml create mode 100644 tests/components/soundtouch/fixtures/device1_now_playing_radio.xml create mode 100644 tests/components/soundtouch/fixtures/device1_now_playing_standby.xml create mode 100644 tests/components/soundtouch/fixtures/device1_now_playing_upnp.xml create mode 100644 tests/components/soundtouch/fixtures/device1_now_playing_upnp_paused.xml create mode 100644 tests/components/soundtouch/fixtures/device1_presets.xml create mode 100644 tests/components/soundtouch/fixtures/device1_volume.xml create mode 100644 tests/components/soundtouch/fixtures/device1_volume_muted.xml create mode 100644 tests/components/soundtouch/fixtures/device2_getZone_slave.xml create mode 100644 tests/components/soundtouch/fixtures/device2_info.xml create mode 100644 tests/components/soundtouch/fixtures/device2_now_playing_standby.xml create mode 100644 tests/components/soundtouch/fixtures/device2_volume.xml diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 7c9ade3bee1..f8a5191d9db 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -523,7 +523,7 @@ class SoundTouchDevice(MediaPlayerEntity): for slave in zone_slaves: slave_instance = self._get_instance_by_ip(slave.device_ip) - if slave_instance: + if slave_instance and slave_instance.entity_id != master: slaves.append(slave_instance.entity_id) attributes = { diff --git a/tests/components/soundtouch/conftest.py b/tests/components/soundtouch/conftest.py new file mode 100644 index 00000000000..dcac360d253 --- /dev/null +++ b/tests/components/soundtouch/conftest.py @@ -0,0 +1,286 @@ +"""Fixtures for Bose SoundTouch integration tests.""" +import pytest +from requests_mock import Mocker + +from homeassistant.components.media_player.const import DOMAIN as MEDIA_PLAYER_DOMAIN +from homeassistant.components.soundtouch.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PLATFORM + +from tests.common import load_fixture + +DEVICE_1_ID = "020000000001" +DEVICE_2_ID = "020000000002" +DEVICE_1_IP = "192.168.42.1" +DEVICE_2_IP = "192.168.42.2" +DEVICE_1_URL = f"http://{DEVICE_1_IP}:8090" +DEVICE_2_URL = f"http://{DEVICE_2_IP}:8090" +DEVICE_1_NAME = "My Soundtouch 1" +DEVICE_2_NAME = "My Soundtouch 2" +DEVICE_1_ENTITY_ID = f"{MEDIA_PLAYER_DOMAIN}.my_soundtouch_1" +DEVICE_2_ENTITY_ID = f"{MEDIA_PLAYER_DOMAIN}.my_soundtouch_2" + + +# pylint: disable=redefined-outer-name + + +@pytest.fixture +def device1_config() -> dict[str, str]: + """Mock SoundTouch device 1 config.""" + yield {CONF_PLATFORM: DOMAIN, CONF_HOST: DEVICE_1_IP, CONF_NAME: DEVICE_1_NAME} + + +@pytest.fixture +def device2_config() -> dict[str, str]: + """Mock SoundTouch device 2 config.""" + yield {CONF_PLATFORM: DOMAIN, CONF_HOST: DEVICE_2_IP, CONF_NAME: DEVICE_2_NAME} + + +@pytest.fixture(scope="session") +def device1_info() -> str: + """Load SoundTouch device 1 info response and return it.""" + return load_fixture("soundtouch/device1_info.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_aux() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_aux.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_bluetooth() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_bluetooth.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_radio() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_radio.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_standby() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_standby.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_upnp() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_upnp.xml") + + +@pytest.fixture(scope="session") +def device1_now_playing_upnp_paused() -> str: + """Load SoundTouch device 1 now_playing response and return it.""" + return load_fixture("soundtouch/device1_now_playing_upnp_paused.xml") + + +@pytest.fixture(scope="session") +def device1_presets() -> str: + """Load SoundTouch device 1 presets response and return it.""" + return load_fixture("soundtouch/device1_presets.xml") + + +@pytest.fixture(scope="session") +def device1_volume() -> str: + """Load SoundTouch device 1 volume response and return it.""" + return load_fixture("soundtouch/device1_volume.xml") + + +@pytest.fixture(scope="session") +def device1_volume_muted() -> str: + """Load SoundTouch device 1 volume response and return it.""" + return load_fixture("soundtouch/device1_volume_muted.xml") + + +@pytest.fixture(scope="session") +def device1_zone_master() -> str: + """Load SoundTouch device 1 getZone response and return it.""" + return load_fixture("soundtouch/device1_getZone_master.xml") + + +@pytest.fixture(scope="session") +def device2_info() -> str: + """Load SoundTouch device 2 info response and return it.""" + return load_fixture("soundtouch/device2_info.xml") + + +@pytest.fixture(scope="session") +def device2_volume() -> str: + """Load SoundTouch device 2 volume response and return it.""" + return load_fixture("soundtouch/device2_volume.xml") + + +@pytest.fixture(scope="session") +def device2_now_playing_standby() -> str: + """Load SoundTouch device 2 now_playing response and return it.""" + return load_fixture("soundtouch/device2_now_playing_standby.xml") + + +@pytest.fixture(scope="session") +def device2_zone_slave() -> str: + """Load SoundTouch device 2 getZone response and return it.""" + return load_fixture("soundtouch/device2_getZone_slave.xml") + + +@pytest.fixture(scope="session") +def zone_empty() -> str: + """Load empty SoundTouch getZone response and return it.""" + return load_fixture("soundtouch/getZone_empty.xml") + + +@pytest.fixture +def device1_requests_mock( + requests_mock: Mocker, + device1_info: str, + device1_volume: str, + device1_presets: str, + device1_zone_master: str, +) -> Mocker: + """Mock SoundTouch device 1 API - base URLs.""" + requests_mock.get(f"{DEVICE_1_URL}/info", text=device1_info) + requests_mock.get(f"{DEVICE_1_URL}/volume", text=device1_volume) + requests_mock.get(f"{DEVICE_1_URL}/presets", text=device1_presets) + requests_mock.get(f"{DEVICE_1_URL}/getZone", text=device1_zone_master) + yield requests_mock + + +@pytest.fixture +def device1_requests_mock_standby( + device1_requests_mock: Mocker, + device1_now_playing_standby: str, +): + """Mock SoundTouch device 1 API - standby.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_standby + ) + + +@pytest.fixture +def device1_requests_mock_aux( + device1_requests_mock: Mocker, + device1_now_playing_aux: str, +): + """Mock SoundTouch device 1 API - playing AUX.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_aux + ) + + +@pytest.fixture +def device1_requests_mock_bluetooth( + device1_requests_mock: Mocker, + device1_now_playing_bluetooth: str, +): + """Mock SoundTouch device 1 API - playing bluetooth.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_bluetooth + ) + + +@pytest.fixture +def device1_requests_mock_radio( + device1_requests_mock: Mocker, + device1_now_playing_radio: str, +): + """Mock SoundTouch device 1 API - playing radio.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_radio + ) + + +@pytest.fixture +def device1_requests_mock_upnp( + device1_requests_mock: Mocker, + device1_now_playing_upnp: str, +): + """Mock SoundTouch device 1 API - playing UPNP.""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_upnp + ) + + +@pytest.fixture +def device1_requests_mock_upnp_paused( + device1_requests_mock: Mocker, + device1_now_playing_upnp_paused: str, +): + """Mock SoundTouch device 1 API - playing UPNP (paused).""" + device1_requests_mock.get( + f"{DEVICE_1_URL}/now_playing", text=device1_now_playing_upnp_paused + ) + + +@pytest.fixture +def device1_requests_mock_key( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - key endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/key") + + +@pytest.fixture +def device1_requests_mock_volume( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - volume endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/volume") + + +@pytest.fixture +def device1_requests_mock_select( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - select endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/select") + + +@pytest.fixture +def device1_requests_mock_set_zone( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - setZone endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/setZone") + + +@pytest.fixture +def device1_requests_mock_add_zone_slave( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - addZoneSlave endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/addZoneSlave") + + +@pytest.fixture +def device1_requests_mock_remove_zone_slave( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - removeZoneSlave endpoint.""" + yield device1_requests_mock.post(f"{DEVICE_1_URL}/removeZoneSlave") + + +@pytest.fixture +def device1_requests_mock_dlna( + device1_requests_mock: Mocker, +): + """Mock SoundTouch device 1 API - DLNA endpoint.""" + yield device1_requests_mock.post(f"http://{DEVICE_1_IP}:8091/AVTransport/Control") + + +@pytest.fixture +def device2_requests_mock_standby( + requests_mock: Mocker, + device2_info: str, + device2_volume: str, + device2_now_playing_standby: str, + device2_zone_slave: str, +) -> Mocker: + """Mock SoundTouch device 2 API.""" + requests_mock.get(f"{DEVICE_2_URL}/info", text=device2_info) + requests_mock.get(f"{DEVICE_2_URL}/volume", text=device2_volume) + requests_mock.get(f"{DEVICE_2_URL}/now_playing", text=device2_now_playing_standby) + requests_mock.get(f"{DEVICE_2_URL}/getZone", text=device2_zone_slave) + + yield requests_mock diff --git a/tests/components/soundtouch/fixtures/device1_getZone_master.xml b/tests/components/soundtouch/fixtures/device1_getZone_master.xml new file mode 100644 index 00000000000..f4b0fd05a51 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_getZone_master.xml @@ -0,0 +1,6 @@ + + + 020000000001 + 020000000002 + 020000000003 + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_info.xml b/tests/components/soundtouch/fixtures/device1_info.xml new file mode 100644 index 00000000000..27878969ca0 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_info.xml @@ -0,0 +1,32 @@ + + + My SoundTouch 1 + SoundTouch 10 + 0 + + + SCM + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + P0000000000000000000001 + + + PackagedProduct + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + 000000P00000001AE + + + https://streaming.bose.com + + 020000000001 + 192.168.42.1 + + + 060000000001 + 192.168.42.1 + + sm2 + rhino + normal + US + US + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_aux.xml b/tests/components/soundtouch/fixtures/device1_now_playing_aux.xml new file mode 100644 index 00000000000..e19dc1dd954 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_aux.xml @@ -0,0 +1,7 @@ + + + + AUX IN + + PLAY_STATE + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_bluetooth.xml b/tests/components/soundtouch/fixtures/device1_now_playing_bluetooth.xml new file mode 100644 index 00000000000..c43fe187f2f --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_bluetooth.xml @@ -0,0 +1,16 @@ + + + + MockPairedBluetoothDevice + + MockTrack + MockArtist + MockAlbum + MockPairedBluetoothDevice + + + PLAY_STATE + + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_radio.xml b/tests/components/soundtouch/fixtures/device1_now_playing_radio.xml new file mode 100644 index 00000000000..b9d47216b3a --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_radio.xml @@ -0,0 +1,16 @@ + + + + MockStation + http://cdn-profiles.tunein.com/sXXXXX/images/logoq.png + + MockTrack + MockArtist + MockAlbum + MockStation + http://cdn-profiles.tunein.com/sXXXXX/images/logoq.png + + PLAY_STATE + RADIO_STREAMING + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_standby.xml b/tests/components/soundtouch/fixtures/device1_now_playing_standby.xml new file mode 100644 index 00000000000..67acae6a0ef --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_standby.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_upnp.xml b/tests/components/soundtouch/fixtures/device1_now_playing_upnp.xml new file mode 100644 index 00000000000..e58e62072ce --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_upnp.xml @@ -0,0 +1,13 @@ + + + + MockTrack + MockArtist + MockAlbum + + + + PLAY_STATE + + TRACK_ONDEMAND + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_now_playing_upnp_paused.xml b/tests/components/soundtouch/fixtures/device1_now_playing_upnp_paused.xml new file mode 100644 index 00000000000..6275ada6e4b --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_now_playing_upnp_paused.xml @@ -0,0 +1,13 @@ + + + + MockTrack + MockArtist + MockAlbum + + + + PAUSE_STATE + + TRACK_ONDEMAND + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_presets.xml b/tests/components/soundtouch/fixtures/device1_presets.xml new file mode 100644 index 00000000000..6bacfa48732 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_presets.xml @@ -0,0 +1,12 @@ + + + + + + + + MockStation + http://cdn-profiles.tunein.com/sXXXXX/images/logoq.png + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_volume.xml b/tests/components/soundtouch/fixtures/device1_volume.xml new file mode 100644 index 00000000000..cef90efa37d --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_volume.xml @@ -0,0 +1,6 @@ + + + 12 + 12 + false + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device1_volume_muted.xml b/tests/components/soundtouch/fixtures/device1_volume_muted.xml new file mode 100644 index 00000000000..e26fbd55e08 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device1_volume_muted.xml @@ -0,0 +1,6 @@ + + + 12 + 12 + true + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_getZone_slave.xml b/tests/components/soundtouch/fixtures/device2_getZone_slave.xml new file mode 100644 index 00000000000..fa9db0bf748 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_getZone_slave.xml @@ -0,0 +1,4 @@ + + + 020000000002 + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_info.xml b/tests/components/soundtouch/fixtures/device2_info.xml new file mode 100644 index 00000000000..a93a19fb52a --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_info.xml @@ -0,0 +1,32 @@ + + + My SoundTouch 2 + SoundTouch 10 + 0 + + + SCM + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + P0000000000000000000002 + + + PackagedProduct + 27.0.3.46298.4608935 epdbuild.trunk.hepdswbld04.2021-10-06T16:35:02 + 000000P00000002AE + + + https://streaming.bose.com + + 020000000002 + 192.168.42.2 + + + 060000000002 + 192.168.42.2 + + sm2 + rhino + normal + US + US + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_now_playing_standby.xml b/tests/components/soundtouch/fixtures/device2_now_playing_standby.xml new file mode 100644 index 00000000000..1b8bf8a5a3c --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_now_playing_standby.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/tests/components/soundtouch/fixtures/device2_volume.xml b/tests/components/soundtouch/fixtures/device2_volume.xml new file mode 100644 index 00000000000..436bd888980 --- /dev/null +++ b/tests/components/soundtouch/fixtures/device2_volume.xml @@ -0,0 +1,6 @@ + + + 10 + 10 + false + \ No newline at end of file diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index 797b5b440d1..1b16508bb88 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -1,1062 +1,686 @@ -"""Test the Soundtouch component.""" -from unittest.mock import call, patch +"""Test the SoundTouch component.""" +from typing import Any -from libsoundtouch.device import ( - Config, - Preset, - SoundTouchDevice as STD, - Status, - Volume, - ZoneSlave, - ZoneStatus, -) -import pytest +from requests_mock import Mocker from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_TITLE, + ATTR_MEDIA_TRACK, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MEDIA_PLAYER_DOMAIN, +) +from homeassistant.components.soundtouch.const import ( + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, + SERVICE_CREATE_ZONE, + SERVICE_PLAY_EVERYWHERE, + SERVICE_REMOVE_ZONE_SLAVE, ) -from homeassistant.components.soundtouch import media_player as soundtouch -from homeassistant.components.soundtouch.const import DOMAIN from homeassistant.components.soundtouch.media_player import ( ATTR_SOUNDTOUCH_GROUP, ATTR_SOUNDTOUCH_ZONE, DATA_SOUNDTOUCH, ) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING -from homeassistant.helpers.discovery import async_load_platform +from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -# pylint: disable=super-init-not-called +from .conftest import DEVICE_1_ENTITY_ID, DEVICE_2_ENTITY_ID -DEVICE_1_IP = "192.168.0.1" -DEVICE_2_IP = "192.168.0.2" -DEVICE_1_ID = 1 -DEVICE_2_ID = 2 - - -def get_config(host=DEVICE_1_IP, port=8090, name="soundtouch"): - """Return a default component.""" - return {"platform": DOMAIN, "host": host, "port": port, "name": name} - - -DEVICE_1_CONFIG = {**get_config(), "name": "soundtouch_1"} -DEVICE_2_CONFIG = {**get_config(), "host": DEVICE_2_IP, "name": "soundtouch_2"} - - -@pytest.fixture(name="one_device") -def one_device_fixture(): - """Mock one master device.""" - device_1 = MockDevice() - device_patch = patch( - "homeassistant.components.soundtouch.media_player.soundtouch_device", - return_value=device_1, +async def setup_soundtouch(hass: HomeAssistant, *configs: dict[str, str]): + """Initialize media_player for tests.""" + assert await async_setup_component( + hass, MEDIA_PLAYER_DOMAIN, {MEDIA_PLAYER_DOMAIN: list(configs)} ) - with device_patch as device: - yield device - - -@pytest.fixture(name="two_zones") -def two_zones_fixture(): - """Mock one master and one slave.""" - device_1 = MockDevice( - DEVICE_1_ID, - MockZoneStatus( - is_master=True, - master_id=DEVICE_1_ID, - master_ip=DEVICE_1_IP, - slaves=[MockZoneSlave(DEVICE_2_IP)], - ), - ) - device_2 = MockDevice( - DEVICE_2_ID, - MockZoneStatus( - is_master=False, - master_id=DEVICE_1_ID, - master_ip=DEVICE_1_IP, - slaves=[MockZoneSlave(DEVICE_2_IP)], - ), - ) - devices = {DEVICE_1_IP: device_1, DEVICE_2_IP: device_2} - device_patch = patch( - "homeassistant.components.soundtouch.media_player.soundtouch_device", - side_effect=lambda host, _: devices[host], - ) - with device_patch as device: - yield device - - -@pytest.fixture(name="mocked_status") -def status_fixture(): - """Mock the device status.""" - status_patch = patch( - "libsoundtouch.device.SoundTouchDevice.status", side_effect=MockStatusPlaying - ) - with status_patch as status: - yield status - - -@pytest.fixture(name="mocked_volume") -def volume_fixture(): - """Mock the device volume.""" - volume_patch = patch("libsoundtouch.device.SoundTouchDevice.volume") - with volume_patch as volume: - yield volume - - -async def setup_soundtouch(hass, config): - """Set up soundtouch integration.""" - assert await async_setup_component(hass, "media_player", {"media_player": config}) await hass.async_block_till_done() await hass.async_start() -class MockDevice(STD): - """Mock device.""" - - def __init__(self, id=None, zone_status=None): - """Init the class.""" - self._config = MockConfig(id) - self._zone_status = zone_status or MockZoneStatus() - - def zone_status(self, refresh=True): - """Zone status mock object.""" - return self._zone_status - - -class MockConfig(Config): - """Mock config.""" - - def __init__(self, id=None): - """Init class.""" - self._name = "name" - self._id = id or DEVICE_1_ID - - -class MockZoneStatus(ZoneStatus): - """Mock zone status.""" - - def __init__(self, is_master=True, master_id=None, master_ip=None, slaves=None): - """Init the class.""" - self._is_master = is_master - self._master_id = master_id - self._master_ip = master_ip - self._slaves = slaves or [] - - -class MockZoneSlave(ZoneSlave): - """Mock zone slave.""" - - def __init__(self, device_ip=None, role=None): - """Init the class.""" - self._ip = device_ip - self._role = role - - -def _mocked_presets(*args, **kwargs): - """Return a list of mocked presets.""" - return [MockPreset("1")] - - -class MockPreset(Preset): - """Mock preset.""" - - def __init__(self, id_): - """Init the class.""" - self._id = id_ - self._name = "preset" - - -class MockVolume(Volume): - """Mock volume with value.""" - - def __init__(self): - """Init class.""" - self._actual = 12 - self._muted = False - - -class MockVolumeMuted(Volume): - """Mock volume muted.""" - - def __init__(self): - """Init the class.""" - self._actual = 12 - self._muted = True - - -class MockStatusStandby(Status): - """Mock status standby.""" - - def __init__(self): - """Init the class.""" - self._source = "STANDBY" - - -class MockStatusPlaying(Status): - """Mock status playing media.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = "artist" - self._track = "track" - self._album = "album" - self._duration = 1 - self._station_name = None - - -class MockStatusPlayingRadio(Status): - """Mock status radio.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = "station" - - -class MockStatusUnknown(Status): - """Mock status unknown media.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = None - - -class MockStatusPause(Status): - """Mock status pause.""" - - def __init__(self): - """Init the class.""" - self._source = "" - self._play_status = "PAUSE_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = None - - -class MockStatusPlayingAux(Status): - """Mock status AUX.""" - - def __init__(self): - """Init the class.""" - self._source = "AUX" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = None - self._track = None - self._album = None - self._duration = None - self._station_name = None - - -class MockStatusPlayingBluetooth(Status): - """Mock status Bluetooth.""" - - def __init__(self): - """Init the class.""" - self._source = "BLUETOOTH" - self._play_status = "PLAY_STATE" - self._image = "image.url" - self._artist = "artist" - self._track = "track" - self._album = "album" - self._duration = None - self._station_name = None - - -async def test_ensure_setup_config(mocked_status, mocked_volume, hass, one_device): - """Test setup OK with custom config.""" - await setup_soundtouch( - hass, get_config(host="192.168.1.44", port=8888, name="custom_sound") - ) - - assert one_device.call_count == 1 - assert one_device.call_args == call("192.168.1.44", 8888) - assert len(hass.states.async_all()) == 1 - state = hass.states.get("media_player.custom_sound") - assert state.name == "custom_sound" - - -async def test_ensure_setup_discovery(mocked_status, mocked_volume, hass, one_device): - """Test setup with discovery.""" - new_device = { - "port": "8090", - "host": "192.168.1.1", - "properties": {}, - "hostname": "hostname.local", - } - await async_load_platform( - hass, "media_player", DOMAIN, new_device, {"media_player": {}} - ) - await hass.async_block_till_done() - - assert one_device.call_count == 1 - assert one_device.call_args == call("192.168.1.1", 8090) - assert len(hass.states.async_all()) == 1 - - -async def test_ensure_setup_discovery_no_duplicate( - mocked_status, mocked_volume, hass, one_device +async def _test_key_service( + hass: HomeAssistant, + requests_mock_key, + service: str, + service_data: dict[str, Any], + key_name: str, ): - """Test setup OK if device already exists.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert len(hass.states.async_all()) == 1 - - new_device = { - "port": "8090", - "host": "192.168.1.1", - "properties": {}, - "hostname": "hostname.local", - } - await async_load_platform( - hass, "media_player", DOMAIN, new_device, {"media_player": DEVICE_1_CONFIG} - ) - await hass.async_block_till_done() - assert one_device.call_count == 2 - assert len(hass.states.async_all()) == 2 - - existing_device = { - "port": "8090", - "host": "192.168.0.1", - "properties": {}, - "hostname": "hostname.local", - } - await async_load_platform( - hass, "media_player", DOMAIN, existing_device, {"media_player": DEVICE_1_CONFIG} - ) - await hass.async_block_till_done() - assert one_device.call_count == 2 - assert len(hass.states.async_all()) == 2 + """Test API calls that use the /key endpoint to emulate physical button clicks.""" + requests_mock_key.reset() + await hass.services.async_call("media_player", service, service_data, True) + assert requests_mock_key.call_count == 2 + assert f">{key_name}" in requests_mock_key.last_request.text -async def test_playing_media(mocked_status, mocked_volume, hass, one_device): +async def test_playing_media( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, +): """Test playing media info.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["media_title"] == "artist - track" - assert entity_1_state.attributes["media_track"] == "track" - assert entity_1_state.attributes["media_artist"] == "artist" - assert entity_1_state.attributes["media_album_name"] == "album" - assert entity_1_state.attributes["media_duration"] == 1 + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_MEDIA_TITLE] == "MockArtist - MockTrack" + assert entity_state.attributes[ATTR_MEDIA_TRACK] == "MockTrack" + assert entity_state.attributes[ATTR_MEDIA_ARTIST] == "MockArtist" + assert entity_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "MockAlbum" + assert entity_state.attributes[ATTR_MEDIA_DURATION] == 42 -async def test_playing_unknown_media(mocked_status, mocked_volume, hass, one_device): - """Test playing media info.""" - mocked_status.side_effect = MockStatusUnknown - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - - -async def test_playing_radio(mocked_status, mocked_volume, hass, one_device): +async def test_playing_radio( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_radio, +): """Test playing radio info.""" - mocked_status.side_effect = MockStatusPlayingRadio - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["media_title"] == "station" + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_MEDIA_TITLE] == "MockStation" -async def test_playing_aux(mocked_status, mocked_volume, hass, one_device): +async def test_playing_aux( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_aux, +): """Test playing AUX info.""" - mocked_status.side_effect = MockStatusPlayingAux - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["source"] == "AUX" + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_INPUT_SOURCE] == "AUX" -async def test_playing_bluetooth(mocked_status, mocked_volume, hass, one_device): +async def test_playing_bluetooth( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_bluetooth, +): """Test playing Bluetooth info.""" - mocked_status.side_effect = MockStatusPlayingBluetooth - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PLAYING - assert entity_1_state.attributes["source"] == "BLUETOOTH" - assert entity_1_state.attributes["media_track"] == "track" - assert entity_1_state.attributes["media_artist"] == "artist" - assert entity_1_state.attributes["media_album_name"] == "album" + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PLAYING + assert entity_state.attributes[ATTR_INPUT_SOURCE] == "BLUETOOTH" + assert entity_state.attributes[ATTR_MEDIA_TRACK] == "MockTrack" + assert entity_state.attributes[ATTR_MEDIA_ARTIST] == "MockArtist" + assert entity_state.attributes[ATTR_MEDIA_ALBUM_NAME] == "MockAlbum" -async def test_get_volume_level(mocked_status, mocked_volume, hass, one_device): +async def test_get_volume_level( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, +): """Test volume level.""" - mocked_volume.side_effect = MockVolume - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.attributes["volume_level"] == 0.12 + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.attributes["volume_level"] == 0.12 -async def test_get_state_off(mocked_status, mocked_volume, hass, one_device): +async def test_get_state_off( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, +): """Test state device is off.""" - mocked_status.side_effect = MockStatusStandby - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_OFF + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_OFF -async def test_get_state_pause(mocked_status, mocked_volume, hass, one_device): +async def test_get_state_pause( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp_paused, +): """Test state device is paused.""" - mocked_status.side_effect = MockStatusPause - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.state == STATE_PAUSED + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.state == STATE_PAUSED -async def test_is_muted(mocked_status, mocked_volume, hass, one_device): +async def test_is_muted( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_volume_muted: str, +): """Test device volume is muted.""" - mocked_volume.side_effect = MockVolumeMuted - await setup_soundtouch(hass, DEVICE_1_CONFIG) + with Mocker(real_http=True) as mocker: + mocker.get("/volume", text=device1_volume_muted) - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.attributes["is_volume_muted"] + entity_state = hass.states.get(DEVICE_1_ENTITY_ID) + assert entity_state.attributes[ATTR_MEDIA_VOLUME_MUTED] -async def test_media_commands(mocked_status, mocked_volume, hass, one_device): - """Test supported media commands.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - entity_1_state = hass.states.get("media_player.soundtouch_1") - assert entity_1_state.attributes["supported_features"] == 151485 - - -@patch("libsoundtouch.device.SoundTouchDevice.power_off") async def test_should_turn_off( - mocked_power_off, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test device is turned off.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "turn_off", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "POWER", ) - assert mocked_status.call_count == 3 - assert mocked_power_off.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.power_on") async def test_should_turn_on( - mocked_power_on, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_key, ): """Test device is turned on.""" - mocked_status.side_effect = MockStatusStandby - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "turn_on", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "POWER", ) - assert mocked_status.call_count == 3 - assert mocked_power_on.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.volume_up") async def test_volume_up( - mocked_volume_up, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test volume up.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "volume_up", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "VOLUME_UP", ) - assert mocked_volume.call_count == 3 - assert mocked_volume_up.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.volume_down") async def test_volume_down( - mocked_volume_down, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test volume down.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "volume_down", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "VOLUME_DOWN", ) - assert mocked_volume.call_count == 3 - assert mocked_volume_down.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.set_volume") async def test_set_volume_level( - mocked_set_volume, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_volume, ): """Test set volume level.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) + assert device1_requests_mock_volume.call_count == 0 await hass.services.async_call( "media_player", "volume_set", - {"entity_id": "media_player.soundtouch_1", "volume_level": 0.17}, + {"entity_id": DEVICE_1_ENTITY_ID, "volume_level": 0.17}, True, ) - assert mocked_volume.call_count == 3 - mocked_set_volume.assert_called_with(17) + assert device1_requests_mock_volume.call_count == 1 + assert "17" in device1_requests_mock_volume.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.mute") -async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device): +async def test_mute( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, +): """Test mute volume.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "volume_mute", - {"entity_id": "media_player.soundtouch_1", "is_volume_muted": True}, - True, + {"entity_id": DEVICE_1_ENTITY_ID, "is_volume_muted": True}, + "MUTE", ) - assert mocked_volume.call_count == 3 - assert mocked_mute.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.play") -async def test_play(mocked_play, mocked_status, mocked_volume, hass, one_device): +async def test_play( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp_paused, + device1_requests_mock_key, +): """Test play command.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_play", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PLAY", ) - assert mocked_status.call_count == 3 - assert mocked_play.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.pause") -async def test_pause(mocked_pause, mocked_status, mocked_volume, hass, one_device): +async def test_pause( + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, +): """Test pause command.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_pause", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PAUSE", ) - assert mocked_status.call_count == 3 - assert mocked_pause.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.play_pause") async def test_play_pause( - mocked_play_pause, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test play/pause.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_play_pause", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PLAY_PAUSE", ) - assert mocked_status.call_count == 3 - assert mocked_play_pause.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.previous_track") -@patch("libsoundtouch.device.SoundTouchDevice.next_track") async def test_next_previous_track( - mocked_next_track, - mocked_previous_track, - mocked_status, - mocked_volume, - hass, - one_device, + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_upnp, + device1_requests_mock_key, ): """Test next/previous track.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 - - await hass.services.async_call( - "media_player", + await setup_soundtouch(hass, device1_config) + await _test_key_service( + hass, + device1_requests_mock_key, "media_next_track", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "NEXT_TRACK", ) - assert mocked_status.call_count == 3 - assert mocked_next_track.call_count == 1 - await hass.services.async_call( - "media_player", + await _test_key_service( + hass, + device1_requests_mock_key, "media_previous_track", - {"entity_id": "media_player.soundtouch_1"}, - True, + {"entity_id": DEVICE_1_ENTITY_ID}, + "PREV_TRACK", ) - assert mocked_status.call_count == 4 - assert mocked_previous_track.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.select_preset") -@patch("libsoundtouch.device.SoundTouchDevice.presets", side_effect=_mocked_presets) async def test_play_media( - mocked_presets, mocked_select_preset, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test play preset 1.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) + assert device1_requests_mock_select.call_count == 0 await hass.services.async_call( "media_player", "play_media", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_MEDIA_CONTENT_TYPE: "PLAYLIST", ATTR_MEDIA_CONTENT_ID: 1, }, True, ) - assert mocked_presets.call_count == 1 - assert mocked_select_preset.call_count == 1 + assert device1_requests_mock_select.call_count == 1 + assert ( + 'location="http://homeassistant:8123/media/local/test.mp3"' + in device1_requests_mock_select.last_request.text + ) await hass.services.async_call( "media_player", "play_media", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_MEDIA_CONTENT_TYPE: "PLAYLIST", ATTR_MEDIA_CONTENT_ID: 2, }, True, ) - assert mocked_presets.call_count == 2 - assert mocked_select_preset.call_count == 1 + assert device1_requests_mock_select.call_count == 2 + assert "MockStation" in device1_requests_mock_select.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.play_url") async def test_play_media_url( - mocked_play_url, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_dlna, ): """Test play preset 1.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert one_device.call_count == 1 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + await setup_soundtouch(hass, device1_config) + assert device1_requests_mock_dlna.call_count == 0 await hass.services.async_call( "media_player", "play_media", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_MEDIA_CONTENT_TYPE: "MUSIC", ATTR_MEDIA_CONTENT_ID: "http://fqdn/file.mp3", }, True, ) - mocked_play_url.assert_called_with("http://fqdn/file.mp3") + assert device1_requests_mock_dlna.call_count == 1 + assert "http://fqdn/file.mp3" in device1_requests_mock_dlna.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.select_source_aux") async def test_select_source_aux( - mocked_select_source_aux, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test select AUX.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert mocked_select_source_aux.call_count == 0 + assert device1_requests_mock_select.call_count == 0 await hass.services.async_call( "media_player", "select_source", - {"entity_id": "media_player.soundtouch_1", ATTR_INPUT_SOURCE: "AUX"}, + {"entity_id": DEVICE_1_ENTITY_ID, ATTR_INPUT_SOURCE: "AUX"}, True, ) - - assert mocked_select_source_aux.call_count == 1 + assert device1_requests_mock_select.call_count == 1 + assert "AUX" in device1_requests_mock_select.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.select_source_bluetooth") async def test_select_source_bluetooth( - mocked_select_source_bluetooth, mocked_status, mocked_volume, hass, one_device + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test select Bluetooth.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) + await setup_soundtouch(hass, device1_config) - assert mocked_select_source_bluetooth.call_count == 0 + assert device1_requests_mock_select.call_count == 0 await hass.services.async_call( "media_player", "select_source", - {"entity_id": "media_player.soundtouch_1", ATTR_INPUT_SOURCE: "BLUETOOTH"}, + {"entity_id": DEVICE_1_ENTITY_ID, ATTR_INPUT_SOURCE: "BLUETOOTH"}, True, ) - - assert mocked_select_source_bluetooth.call_count == 1 + assert device1_requests_mock_select.call_count == 1 + assert "BLUETOOTH" in device1_requests_mock_select.last_request.text -@patch("libsoundtouch.device.SoundTouchDevice.select_source_bluetooth") -@patch("libsoundtouch.device.SoundTouchDevice.select_source_aux") async def test_select_source_invalid_source( - mocked_select_source_aux, - mocked_select_source_bluetooth, - mocked_status, - mocked_volume, - hass, - one_device, + hass: HomeAssistant, + device1_config: dict[str, str], + device1_requests_mock_standby, + device1_requests_mock_select, ): """Test select unsupported source.""" - await setup_soundtouch(hass, DEVICE_1_CONFIG) - - assert mocked_select_source_aux.call_count == 0 - assert mocked_select_source_bluetooth.call_count == 0 + await setup_soundtouch(hass, device1_config) + assert not device1_requests_mock_select.called await hass.services.async_call( "media_player", "select_source", { - "entity_id": "media_player.soundtouch_1", + "entity_id": DEVICE_1_ENTITY_ID, ATTR_INPUT_SOURCE: "SOMETHING_UNSUPPORTED", }, True, ) - - assert mocked_select_source_aux.call_count == 0 - assert mocked_select_source_bluetooth.call_count == 0 + assert not device1_requests_mock_select.called -@patch("libsoundtouch.device.SoundTouchDevice.create_zone") async def test_play_everywhere( - mocked_create_zone, mocked_status, mocked_volume, hass, two_zones + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_set_zone, ): """Test play everywhere.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + await setup_soundtouch(hass, device1_config, device2_config) - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 - - # one master, one slave => create zone + # one master, one slave => set zone await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_PLAY_EVERYWHERE, - {"master": "media_player.soundtouch_1"}, + DOMAIN, + SERVICE_PLAY_EVERYWHERE, + {"master": DEVICE_1_ENTITY_ID}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # unknown master, create zone must not be called + # unknown master, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_PLAY_EVERYWHERE, + DOMAIN, + SERVICE_PLAY_EVERYWHERE, {"master": "media_player.entity_X"}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # no slaves, create zone must not be called + # remove second device for entity in list(hass.data[DATA_SOUNDTOUCH]): - if entity.entity_id == "media_player.soundtouch_1": + if entity.entity_id == DEVICE_1_ENTITY_ID: continue hass.data[DATA_SOUNDTOUCH].remove(entity) await entity.async_remove() + + # no slaves, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_PLAY_EVERYWHERE, - {"master": "media_player.soundtouch_1"}, + DOMAIN, + SERVICE_PLAY_EVERYWHERE, + {"master": DEVICE_1_ENTITY_ID}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.create_zone") async def test_create_zone( - mocked_create_zone, mocked_status, mocked_volume, hass, two_zones + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_set_zone, ): """Test creating a zone.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + await setup_soundtouch(hass, device1_config, device2_config) - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 + assert device1_requests_mock_set_zone.call_count == 0 - # one master, one slave => create zone + # one master, one slave => set zone await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_CREATE_ZONE, + DOMAIN, + SERVICE_CREATE_ZONE, { - "master": "media_player.soundtouch_1", - "slaves": ["media_player.soundtouch_2"], + "master": DEVICE_1_ENTITY_ID, + "slaves": [DEVICE_2_ENTITY_ID], }, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # unknown master, create zone must not be called + # unknown master, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_CREATE_ZONE, - {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]}, + DOMAIN, + SERVICE_CREATE_ZONE, + {"master": "media_player.entity_X", "slaves": [DEVICE_2_ENTITY_ID]}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 - # no slaves, create zone must not be called + # no slaves, set zone must not be called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_CREATE_ZONE, - {"master": "media_player.soundtouch_1", "slaves": []}, + DOMAIN, + SERVICE_CREATE_ZONE, + {"master": DEVICE_1_ENTITY_ID, "slaves": []}, True, ) - assert mocked_create_zone.call_count == 1 + assert device1_requests_mock_set_zone.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.remove_zone_slave") async def test_remove_zone_slave( - mocked_remove_zone_slave, mocked_status, mocked_volume, hass, two_zones + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_remove_zone_slave, ): - """Test adding a slave to an existing zone.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) - - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 + """Test removing a slave from an existing zone.""" + await setup_soundtouch(hass, device1_config, device2_config) # remove one slave await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_REMOVE_ZONE_SLAVE, + DOMAIN, + SERVICE_REMOVE_ZONE_SLAVE, { - "master": "media_player.soundtouch_1", - "slaves": ["media_player.soundtouch_2"], + "master": DEVICE_1_ENTITY_ID, + "slaves": [DEVICE_2_ENTITY_ID], }, True, ) - assert mocked_remove_zone_slave.call_count == 1 + assert device1_requests_mock_remove_zone_slave.call_count == 1 - # unknown master. add zone slave is not called + # unknown master, remove zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_REMOVE_ZONE_SLAVE, - {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]}, + DOMAIN, + SERVICE_REMOVE_ZONE_SLAVE, + {"master": "media_player.entity_X", "slaves": [DEVICE_2_ENTITY_ID]}, True, ) - assert mocked_remove_zone_slave.call_count == 1 + assert device1_requests_mock_remove_zone_slave.call_count == 1 - # no slave to add, add zone slave is not called + # no slave to remove, remove zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_REMOVE_ZONE_SLAVE, - {"master": "media_player.soundtouch_1", "slaves": []}, + DOMAIN, + SERVICE_REMOVE_ZONE_SLAVE, + {"master": DEVICE_1_ENTITY_ID, "slaves": []}, True, ) - assert mocked_remove_zone_slave.call_count == 1 + assert device1_requests_mock_remove_zone_slave.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.add_zone_slave") async def test_add_zone_slave( - mocked_add_zone_slave, - mocked_status, - mocked_volume, - hass, - two_zones, + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, + device1_requests_mock_add_zone_slave, ): - """Test removing a slave from a zone.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) - - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 + """Test adding a slave to a zone.""" + await setup_soundtouch(hass, device1_config, device2_config) # add one slave await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_ADD_ZONE_SLAVE, + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, { - "master": "media_player.soundtouch_1", - "slaves": ["media_player.soundtouch_2"], + "master": DEVICE_1_ENTITY_ID, + "slaves": [DEVICE_2_ENTITY_ID], }, True, ) - assert mocked_add_zone_slave.call_count == 1 + assert device1_requests_mock_add_zone_slave.call_count == 1 # unknown master, add zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_ADD_ZONE_SLAVE, - {"master": "media_player.entity_X", "slaves": ["media_player.soundtouch_2"]}, + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, + {"master": "media_player.entity_X", "slaves": [DEVICE_2_ENTITY_ID]}, True, ) - assert mocked_add_zone_slave.call_count == 1 + assert device1_requests_mock_add_zone_slave.call_count == 1 # no slave to add, add zone slave is not called await hass.services.async_call( - soundtouch.DOMAIN, - soundtouch.SERVICE_ADD_ZONE_SLAVE, - {"master": "media_player.soundtouch_1", "slaves": ["media_player.entity_X"]}, + DOMAIN, + SERVICE_ADD_ZONE_SLAVE, + {"master": DEVICE_1_ENTITY_ID, "slaves": ["media_player.entity_X"]}, True, ) - assert mocked_add_zone_slave.call_count == 1 + assert device1_requests_mock_add_zone_slave.call_count == 1 -@patch("libsoundtouch.device.SoundTouchDevice.create_zone") async def test_zone_attributes( - mocked_create_zone, - mocked_status, - mocked_volume, - hass, - two_zones, + hass: HomeAssistant, + device1_config: dict[str, str], + device2_config: dict[str, str], + device1_requests_mock_standby, + device2_requests_mock_standby, ): - """Test play everywhere.""" - mocked_device = two_zones - await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + """Test zone attributes.""" + await setup_soundtouch(hass, device1_config, device2_config) - assert mocked_device.call_count == 2 - assert mocked_status.call_count == 4 - assert mocked_volume.call_count == 4 - - entity_1_state = hass.states.get("media_player.soundtouch_1") + entity_1_state = hass.states.get(DEVICE_1_ENTITY_ID) assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] assert ( - entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] - == "media_player.soundtouch_1" + entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] == DEVICE_1_ENTITY_ID ) assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ - "media_player.soundtouch_2" + DEVICE_2_ENTITY_ID ] assert entity_1_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ - "media_player.soundtouch_1", - "media_player.soundtouch_2", + DEVICE_1_ENTITY_ID, + DEVICE_2_ENTITY_ID, ] - entity_2_state = hass.states.get("media_player.soundtouch_2") + + entity_2_state = hass.states.get(DEVICE_2_ENTITY_ID) assert not entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] assert ( - entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] - == "media_player.soundtouch_1" + entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] == DEVICE_1_ENTITY_ID ) assert entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ - "media_player.soundtouch_2" + DEVICE_2_ENTITY_ID ] assert entity_2_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ - "media_player.soundtouch_1", - "media_player.soundtouch_2", + DEVICE_1_ENTITY_ID, + DEVICE_2_ENTITY_ID, ] From 1a55c7db34c87513eb75de7eff7a1154bbc4326e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 29 Jun 2022 00:13:26 +0300 Subject: [PATCH 799/947] Take Huawei LTE XML parse errors to mean unsupported endpoint (#72781) --- homeassistant/components/huawei_lte/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 601a3b9af8d..f4e2cb209db 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -9,6 +9,7 @@ from datetime import timedelta import logging import time from typing import Any, NamedTuple, cast +from xml.parsers.expat import ExpatError from huawei_lte_api.Client import Client from huawei_lte_api.Connection import Connection @@ -204,14 +205,13 @@ class Router: "%s requires authorization, excluding from future updates", key ) self.subscriptions.pop(key) - except ResponseErrorException as exc: + except (ResponseErrorException, ExpatError) as exc: + # Take ResponseErrorNotSupportedException, ExpatError, and generic + # ResponseErrorException with a few select codes to mean the endpoint is + # not supported. if not isinstance( - exc, ResponseErrorNotSupportedException - ) and exc.code not in ( - # additional codes treated as unusupported - -1, - 100006, - ): + exc, (ResponseErrorNotSupportedException, ExpatError) + ) and exc.code not in (-1, 100006): raise _LOGGER.info( "%s apparently not supported by device, excluding from future updates", From 7d74301045c5567f7f597928c936416f4f4cc21d Mon Sep 17 00:00:00 2001 From: Thijs W Date: Tue, 28 Jun 2022 23:13:43 +0200 Subject: [PATCH 800/947] Add sound mode to frontier silicon (#72760) --- .../components/frontier_silicon/media_player.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 555e0517d4c..61dc6e69726 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -100,6 +100,7 @@ class AFSAPIDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.TURN_ON | MediaPlayerEntityFeature.TURN_OFF | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) def __init__(self, name: str | None, afsapi: AFSAPI) -> None: @@ -115,6 +116,7 @@ class AFSAPIDevice(MediaPlayerEntity): self._max_volume = None self.__modes_by_label = None + self.__sound_modes_by_label = None async def async_update(self): """Get the latest date and update device state.""" @@ -156,6 +158,13 @@ class AFSAPIDevice(MediaPlayerEntity): } self._attr_source_list = list(self.__modes_by_label) + if not self._attr_sound_mode_list: + self.__sound_modes_by_label = { + sound_mode.label: sound_mode.key + for sound_mode in await afsapi.get_equalisers() + } + self._attr_sound_mode_list = list(self.__sound_modes_by_label) + # The API seems to include 'zero' in the number of steps (e.g. if the range is # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. # If call to get_volume fails set to 0 and try again next time. @@ -174,6 +183,7 @@ class AFSAPIDevice(MediaPlayerEntity): self._attr_is_volume_muted = await afsapi.get_mute() self._attr_media_image_url = await afsapi.get_play_graphic() + self._attr_sound_mode = (await afsapi.get_eq_preset()).label volume = await self.fs_device.get_volume() @@ -188,6 +198,7 @@ class AFSAPIDevice(MediaPlayerEntity): self._attr_is_volume_muted = None self._attr_media_image_url = None + self._attr_sound_mode = None self._attr_volume_level = None @@ -255,3 +266,7 @@ class AFSAPIDevice(MediaPlayerEntity): """Select input source.""" await self.fs_device.set_power(True) await self.fs_device.set_mode(self.__modes_by_label.get(source)) + + async def async_select_sound_mode(self, sound_mode): + """Select EQ Preset.""" + await self.fs_device.set_eq_preset(self.__sound_modes_by_label[sound_mode]) From c3a2fce5ccdfe2c777156ce481f8075838cffcc3 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 28 Jun 2022 17:22:18 -0400 Subject: [PATCH 801/947] Move to async for aladdin connect integration (#73954) * Moved to AIOAladdinConnect API * Added callback logic for door status * close unused connections * Close connection after verification * Matched to current version * Matched __init__.py to current release * Matched cover.py to existing version * added missing awaits * Moved callback * Bumped AIOAladdinConnect to 0.1.3 * Removed await from callback config * Finished tests * Added callback test * Bumped AIOAladdinConnect to 0.1.4 * Finished tests * Callback correct call to update HA * Modified calls to state machine * Modified update path * Removed unused status * Bumped AIOAladdinConnect to 0.1.7 * Revised test_cover cover tests and bumped AIOAladdinConnect to 0.1.10 * Bumped AIOAladdinConnect to 0.1.11 * Bumped AIOAladdinConenct to 0.1.12 * Bumped AIOAladdinConnect to 0.1.13 * Bumped AIOAladdinConnect to 0.1.14 * Added ability to handle multiple doors * Added timout errors to config flow * asyncio timout error added to setup retry * Cleanup added to hass proceedure * Bumped AIOAladdinConnect to 0.1.16 * Bumped AIOAladdinConnect to 0.1.18 * Bumped AIOAladdinConnect to 0.1.19 * Bumped AIOAladdinConnect to 0.1.20 * Addressed recommended changes: SCAN_INTERVAL and spelling * Moved to async_get_clientsession and bumped AIOAladdinConnect to 0.1.21 * Missing test for new code structure * removed extra call to write_ha_state, callback decorator, cleaned up tests * Update tests/components/aladdin_connect/test_init.py Co-authored-by: Martin Hjelmare * Removed extra_attributes. * Added typing to variable acc Co-authored-by: Martin Hjelmare --- .../components/aladdin_connect/__init__.py | 17 +- .../components/aladdin_connect/config_flow.py | 21 +- .../components/aladdin_connect/cover.py | 70 ++++-- .../components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/aladdin_connect/conftest.py | 39 +++ .../aladdin_connect/test_config_flow.py | 155 ++++++++---- .../components/aladdin_connect/test_cover.py | 238 +++++++++--------- tests/components/aladdin_connect/test_init.py | 113 +++++++-- 10 files changed, 458 insertions(+), 209 deletions(-) create mode 100644 tests/components/aladdin_connect/conftest.py diff --git a/homeassistant/components/aladdin_connect/__init__.py b/homeassistant/components/aladdin_connect/__init__.py index 048624641bd..af996c9f5b2 100644 --- a/homeassistant/components/aladdin_connect/__init__.py +++ b/homeassistant/components/aladdin_connect/__init__.py @@ -1,13 +1,16 @@ """The aladdin_connect component.""" +import asyncio import logging from typing import Final -from aladdin_connect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient +from aiohttp import ClientConnectionError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -20,9 +23,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up platform from a ConfigEntry.""" username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] - acc = AladdinConnectClient(username, password) - if not await hass.async_add_executor_job(acc.login): - raise ConfigEntryAuthFailed("Incorrect Password") + acc = AladdinConnectClient(username, password, async_get_clientsession(hass)) + try: + if not await acc.login(): + raise ConfigEntryAuthFailed("Incorrect Password") + except (ClientConnectionError, asyncio.TimeoutError) as ex: + raise ConfigEntryNotReady("Can not connect to host") from ex + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = acc hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index 0b928e9d423..0d45ea9a8ef 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -1,11 +1,14 @@ """Config flow for Aladdin Connect cover integration.""" from __future__ import annotations +import asyncio from collections.abc import Mapping import logging from typing import Any -from aladdin_connect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient +from aiohttp import ClientError +from aiohttp.client_exceptions import ClientConnectionError import voluptuous as vol from homeassistant import config_entries @@ -13,6 +16,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN @@ -33,8 +37,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - acc = AladdinConnectClient(data[CONF_USERNAME], data[CONF_PASSWORD]) - login = await hass.async_add_executor_job(acc.login) + acc = AladdinConnectClient( + data[CONF_USERNAME], data[CONF_PASSWORD], async_get_clientsession(hass) + ) + login = await acc.login() + await acc.close() if not login: raise InvalidAuth @@ -67,8 +74,13 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await validate_input(self.hass, data) + except InvalidAuth: errors["base"] = "invalid_auth" + + except (ClientConnectionError, asyncio.TimeoutError, ClientError): + errors["base"] = "cannot_connect" + else: self.hass.config_entries.async_update_entry( @@ -103,6 +115,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except InvalidAuth: errors["base"] = "invalid_auth" + except (ClientConnectionError, asyncio.TimeoutError, ClientError): + errors["base"] = "cannot_connect" + else: await self.async_set_unique_id( user_input["username"].lower(), raise_on_progress=False diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index da3e6b81663..9c03cd322b6 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -1,10 +1,11 @@ """Platform for the Aladdin Connect cover component.""" from __future__ import annotations +from datetime import timedelta import logging from typing import Any, Final -from aladdin_connect import AladdinConnectClient +from AIOAladdinConnect import AladdinConnectClient import voluptuous as vol from homeassistant.components.cover import ( @@ -34,6 +35,7 @@ _LOGGER: Final = logging.getLogger(__name__) PLATFORM_SCHEMA: Final = BASE_PLATFORM_SCHEMA.extend( {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} ) +SCAN_INTERVAL = timedelta(seconds=300) async def async_setup_platform( @@ -62,14 +64,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Aladdin Connect platform.""" - acc = hass.data[DOMAIN][config_entry.entry_id] - doors = await hass.async_add_executor_job(acc.get_doors) - + acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] + doors = await acc.get_doors() if doors is None: raise PlatformNotReady("Error from Aladdin Connect getting doors") async_add_entities( - (AladdinDevice(acc, door) for door in doors), - update_before_add=True, + (AladdinDevice(acc, door, config_entry) for door in doors), ) @@ -79,27 +79,63 @@ class AladdinDevice(CoverEntity): _attr_device_class = CoverDeviceClass.GARAGE _attr_supported_features = SUPPORTED_FEATURES - def __init__(self, acc: AladdinConnectClient, device: DoorDevice) -> None: + def __init__( + self, acc: AladdinConnectClient, device: DoorDevice, entry: ConfigEntry + ) -> None: """Initialize the Aladdin Connect cover.""" self._acc = acc + self._device_id = device["device_id"] self._number = device["door_number"] self._attr_name = device["name"] self._attr_unique_id = f"{self._device_id}-{self._number}" - def close_cover(self, **kwargs: Any) -> None: + async def async_added_to_hass(self) -> None: + """Connect Aladdin Connect to the cloud.""" + + async def update_callback() -> None: + """Schedule a state update.""" + self.async_write_ha_state() + + self._acc.register_callback(update_callback, self._number) + await self._acc.get_doors(self._number) + + async def async_will_remove_from_hass(self) -> None: + """Close Aladdin Connect before removing.""" + await self._acc.close() + + async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" - self._acc.close_door(self._device_id, self._number) + await self._acc.close_door(self._device_id, self._number) - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - self._acc.open_door(self._device_id, self._number) + await self._acc.open_door(self._device_id, self._number) - def update(self) -> None: + async def async_update(self) -> None: """Update status of cover.""" - status = STATES_MAP.get( - self._acc.get_door_status(self._device_id, self._number) + await self._acc.get_doors(self._number) + + @property + def is_closed(self) -> bool | None: + """Update is closed attribute.""" + value = STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + if value is None: + return None + return value == STATE_CLOSED + + @property + def is_closing(self) -> bool: + """Update is closing attribute.""" + return ( + STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + == STATE_CLOSING + ) + + @property + def is_opening(self) -> bool: + """Update is opening attribute.""" + return ( + STATES_MAP.get(self._acc.get_door_status(self._device_id, self._number)) + == STATE_OPENING ) - self._attr_is_opening = status == STATE_OPENING - self._attr_is_closing = status == STATE_CLOSING - self._attr_is_closed = None if status is None else status == STATE_CLOSED diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index b9ea214d996..3a9e295a08f 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -2,7 +2,7 @@ "domain": "aladdin_connect", "name": "Aladdin Connect", "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", - "requirements": ["aladdin_connect==0.4"], + "requirements": ["AIOAladdinConnect==0.1.21"], "codeowners": ["@mkmer"], "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], diff --git a/requirements_all.txt b/requirements_all.txt index a30fd52a2a1..f8aad0172a3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -4,6 +4,9 @@ # homeassistant.components.aemet AEMET-OpenData==0.2.1 +# homeassistant.components.aladdin_connect +AIOAladdinConnect==0.1.21 + # homeassistant.components.adax Adax-local==0.1.4 @@ -282,9 +285,6 @@ airthings_cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 -# homeassistant.components.aladdin_connect -aladdin_connect==0.4 - # homeassistant.components.alpha_vantage alpha_vantage==2.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3be3fc1fda1..cb3fa2af723 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,6 +6,9 @@ # homeassistant.components.aemet AEMET-OpenData==0.2.1 +# homeassistant.components.aladdin_connect +AIOAladdinConnect==0.1.21 + # homeassistant.components.adax Adax-local==0.1.4 @@ -251,9 +254,6 @@ airthings_cloud==0.1.0 # homeassistant.components.airtouch4 airtouch4pyapi==1.0.5 -# homeassistant.components.aladdin_connect -aladdin_connect==0.4 - # homeassistant.components.ambee ambee==0.4.0 diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py new file mode 100644 index 00000000000..ee68d207361 --- /dev/null +++ b/tests/components/aladdin_connect/conftest.py @@ -0,0 +1,39 @@ +"""Fixtures for the Aladdin Connect integration tests.""" +from unittest import mock +from unittest.mock import AsyncMock + +import pytest + +DEVICE_CONFIG_OPEN = { + "device_id": 533255, + "door_number": 1, + "name": "home", + "status": "open", + "link_status": "Connected", +} + + +@pytest.fixture(name="mock_aladdinconnect_api") +def fixture_mock_aladdinconnect_api(): + """Set up aladdin connect API fixture.""" + with mock.patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient" + ) as mock_opener: + mock_opener.login = AsyncMock(return_value=True) + mock_opener.close = AsyncMock(return_value=True) + + mock_opener.async_get_door_status = AsyncMock(return_value="open") + mock_opener.get_door_status.return_value = "open" + mock_opener.async_get_door_link_status = AsyncMock(return_value="connected") + mock_opener.get_door_link_status.return_value = "connected" + mock_opener.async_get_battery_status = AsyncMock(return_value="99") + mock_opener.get_battery_status.return_value = "99" + mock_opener.async_get_rssi_status = AsyncMock(return_value="-55") + mock_opener.get_rssi_status.return_value = "-55" + mock_opener.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) + + mock_opener.register_callback = mock.Mock(return_value=True) + mock_opener.open_door = AsyncMock(return_value=True) + mock_opener.close_door = AsyncMock(return_value=True) + + yield mock_opener diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index 899aa0a7e55..33117c64110 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -1,5 +1,7 @@ """Test the Aladdin Connect config flow.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +from aiohttp.client_exceptions import ClientConnectionError from homeassistant import config_entries from homeassistant.components.aladdin_connect.const import DOMAIN @@ -14,8 +16,9 @@ from homeassistant.data_entry_flow import ( from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, mock_aladdinconnect_api: MagicMock) -> None: """Test we get the form.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -23,11 +26,10 @@ async def test_form(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ), patch( - "homeassistant.components.aladdin_connect.async_setup_entry", - return_value=True, + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -44,33 +46,21 @@ async def test_form(hass: HomeAssistant) -> None: CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password", } + assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_failed_auth(hass: HomeAssistant) -> None: +async def test_form_failed_auth( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test we handle failed authentication error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - + mock_aladdinconnect_api.login.return_value = False with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=False, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_USERNAME: "test-username", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == RESULT_TYPE_FORM - assert result2["errors"] == {"base": "invalid_auth"} - - with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=False, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -84,7 +74,33 @@ async def test_form_failed_auth(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "invalid_auth"} -async def test_form_already_configured(hass): +async def test_form_connection_timeout( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test we handle http timeout error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + mock_aladdinconnect_api.login.side_effect = ClientConnectionError + with patch( + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_already_configured( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +): """Test we handle already configured error.""" mock_entry = MockConfigEntry( domain=DOMAIN, @@ -101,8 +117,8 @@ async def test_form_already_configured(hass): assert result["step_id"] == config_entries.SOURCE_USER with patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -117,18 +133,15 @@ async def test_form_already_configured(hass): assert result2["reason"] == "already_configured" -async def test_import_flow_success(hass: HomeAssistant) -> None: +async def test_import_flow_success( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test a successful import of yaml.""" - with patch( - "homeassistant.components.aladdin_connect.cover.async_setup_platform", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ), patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.async_setup_entry", - return_value=True, + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_init( DOMAIN, @@ -149,7 +162,9 @@ async def test_import_flow_success(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_reauth_flow(hass: HomeAssistant) -> None: +async def test_reauth_flow( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test a successful reauth flow.""" mock_entry = MockConfigEntry( @@ -174,14 +189,11 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.aladdin_connect.cover.async_setup_platform", + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True, ), patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.async_setup_entry", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -197,7 +209,9 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: } -async def test_reauth_flow_auth_error(hass: HomeAssistant) -> None: +async def test_reauth_flow_auth_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: """Test an authorization error reauth flow.""" mock_entry = MockConfigEntry( @@ -220,13 +234,13 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant) -> None: assert result["step_id"] == "reauth_confirm" assert result["type"] == RESULT_TYPE_FORM assert result["errors"] == {} - + mock_aladdinconnect_api.login.return_value = False with patch( - "homeassistant.components.aladdin_connect.cover.async_setup_platform", - return_value=True, + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ), patch( - "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient.login", - return_value=False, + "homeassistant.components.aladdin_connect.cover.async_setup_entry", + return_value=True, ), patch( "homeassistant.components.aladdin_connect.cover.async_setup_entry", return_value=True, @@ -239,3 +253,44 @@ async def test_reauth_flow_auth_error(hass: HomeAssistant) -> None: assert result2["type"] == RESULT_TYPE_FORM assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_reauth_flow_connnection_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test a connection error reauth flow.""" + + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-username", "password": "test-password"}, + unique_id="test-username", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "unique_id": mock_entry.unique_id, + "entry_id": mock_entry.entry_id, + }, + data={"username": "test-username", "password": "new-password"}, + ) + + assert result["step_id"] == "reauth_confirm" + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + mock_aladdinconnect_api.login.side_effect = ClientConnectionError + + with patch( + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_PASSWORD: "new-password"}, + ) + await hass.async_block_till_done() + + assert result2["type"] == RESULT_TYPE_FORM + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index c1571ed9fa2..54ec4ee5de1 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -1,23 +1,29 @@ """Test the Aladdin Connect Cover.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.aladdin_connect.const import DOMAIN +from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL from homeassistant.components.cover import DOMAIN as COVER_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed YAML_CONFIG = {"username": "test-user", "password": "test-password"} @@ -76,63 +82,11 @@ DEVICE_CONFIG_BAD_NO_DOOR = { } -async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: - """Test component setup Get Doors Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=None, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is True - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 0 - - -async def test_setup_login_error(hass: HomeAssistant) -> None: - """Test component setup Login Errors.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=False, - ): - assert await hass.config_entries.async_setup(config_entry.entry_id) is False - - -async def test_setup_component_noerror(hass: HomeAssistant) -> None: - """Test component setup No Error.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data=YAML_CONFIG, - unique_id="test-id", - ) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ): - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - assert config_entry.state == ConfigEntryState.LOADED - assert len(hass.config_entries.async_entries(DOMAIN)) == 1 - - -async def test_cover_operation(hass: HomeAssistant) -> None: - """Test component setup open cover, close cover.""" +async def test_cover_operation( + hass: HomeAssistant, + mock_aladdinconnect_api: MagicMock, +) -> None: + """Test Cover Operation states (open,close,opening,closing) cover.""" config_entry = MockConfigEntry( domain=DOMAIN, data=YAML_CONFIG, @@ -142,92 +96,116 @@ async def test_cover_operation(hass: HomeAssistant) -> None: assert await async_setup_component(hass, "homeassistant", {}) await hass.async_block_till_done() - + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_OPEN) + mock_aladdinconnect_api.get_door_status.return_value = STATE_OPEN with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_OPEN], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state == ConfigEntryState.LOADED assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert COVER_DOMAIN in hass.config.components - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.open_door", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_OPEN], - ): - await hass.services.async_call( - "cover", "open_cover", {"entity_id": "cover.home"}, blocking=True - ) + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_OPEN + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) + mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.close_door", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_CLOSED], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): + await hass.services.async_call( - "cover", "close_cover", {"entity_id": "cover.home"}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, ) + await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + assert hass.states.get("cover.home").state == STATE_CLOSED - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_OPENING], - ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True - ) - assert hass.states.get("cover.home").state == STATE_OPENING + mock_aladdinconnect_api.async_get_door_status = AsyncMock( + return_value=STATE_CLOSING + ) + mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_CLOSING], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, ) + await hass.async_block_till_done() assert hass.states.get("cover.home").state == STATE_CLOSING - with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_BAD], - ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True - ) - assert hass.states.get("cover.home").state + mock_aladdinconnect_api.async_get_door_status = AsyncMock( + return_value=STATE_OPENING + ) + mock_aladdinconnect_api.get_door_status.return_value = STATE_OPENING with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_BAD_NO_DOOR], + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - await hass.services.async_call( - "homeassistant", "update_entity", {"entity_id": "cover.home"}, blocking=True + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, ) - assert hass.states.get("cover.home").state + await hass.async_block_till_done() + assert hass.states.get("cover.home").state == STATE_OPENING + + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=None) + mock_aladdinconnect_api.get_door_status.return_value = None + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + await hass.async_block_till_done() + async_fire_time_changed( + hass, + utcnow() + SCAN_INTERVAL, + ) + await hass.async_block_till_done() + + assert hass.states.get("cover.home").state == STATE_UNKNOWN -async def test_yaml_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture): +async def test_yaml_import( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_aladdinconnect_api: MagicMock, +): """Test setup YAML import.""" assert COVER_DOMAIN not in hass.config.components with patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=True, - ), patch( - "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", - return_value=[DEVICE_CONFIG_CLOSED], + "homeassistant.components.aladdin_connect.config_flow.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): await async_setup_component( hass, @@ -248,3 +226,37 @@ async def test_yaml_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture config_data = hass.config_entries.async_entries(DOMAIN)[0].data assert config_data[CONF_USERNAME] == "test-user" assert config_data[CONF_PASSWORD] == "test-password" + + +async def test_callback( + hass: HomeAssistant, + mock_aladdinconnect_api: MagicMock, +): + """Test callback from Aladdin Connect API.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_aladdinconnect_api.async_get_door_status.return_value = STATE_CLOSING + mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSING + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ), patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient._call_back", + AsyncMock(), + ): + callback = mock_aladdinconnect_api.register_callback.call_args[0][0] + await callback() + assert hass.states.get("cover.home").state == STATE_CLOSING diff --git a/tests/components/aladdin_connect/test_init.py b/tests/components/aladdin_connect/test_init.py index 0ba9b317dfb..4c422ae29ba 100644 --- a/tests/components/aladdin_connect/test_init.py +++ b/tests/components/aladdin_connect/test_init.py @@ -1,35 +1,77 @@ """Test for Aladdin Connect init logic.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch + +from aiohttp import ClientConnectionError from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from tests.common import MockConfigEntry +from tests.common import AsyncMock, MockConfigEntry YAML_CONFIG = {"username": "test-user", "password": "test-password"} -async def test_entry_password_fail(hass: HomeAssistant): - """Test password fail during entry.""" - entry = MockConfigEntry( +async def test_setup_get_doors_errors(hass: HomeAssistant) -> None: + """Test component setup Get Doors Errors.""" + config_entry = MockConfigEntry( domain=DOMAIN, - data={"username": "test-user", "password": "test-password"}, + data=YAML_CONFIG, + unique_id="test-id", ) - entry.add_to_hass(hass) - + config_entry.add_to_hass(hass) with patch( "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.login", - return_value=False, + return_value=True, + ), patch( + "homeassistant.components.aladdin_connect.cover.AladdinConnectClient.get_doors", + return_value=None, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) is True + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + +async def test_setup_login_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test component setup Login Errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + mock_aladdinconnect_api.login.return_value = False + with patch( + "homeassistant.components.aladdin_connect.cover.AladdinConnectClient", + return_value=mock_aladdinconnect_api, ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert entry.state is ConfigEntryState.SETUP_ERROR + assert await hass.config_entries.async_setup(config_entry.entry_id) is False -async def test_load_and_unload(hass: HomeAssistant) -> None: - """Test loading and unloading Aladdin Connect entry.""" +async def test_setup_connection_error( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test component setup Login Errors.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + mock_aladdinconnect_api.login.side_effect = ClientConnectionError + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + + assert await hass.config_entries.async_setup(config_entry.entry_id) is False + + +async def test_setup_component_no_error(hass: HomeAssistant) -> None: + """Test component setup No Error.""" config_entry = MockConfigEntry( domain=DOMAIN, data=YAML_CONFIG, @@ -41,6 +83,49 @@ async def test_load_and_unload(hass: HomeAssistant) -> None: return_value=True, ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state == ConfigEntryState.LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + +async def test_entry_password_fail( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +): + """Test password fail during entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "test-user", "password": "test-password"}, + ) + entry.add_to_hass(hass) + mock_aladdinconnect_api.login = AsyncMock(return_value=False) + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_load_and_unload( + hass: HomeAssistant, mock_aladdinconnect_api: MagicMock +) -> None: + """Test loading and unloading Aladdin Connect entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() From 48c7e414f68b814dcf5b6dc9e7360deeb2861369 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 28 Jun 2022 23:23:17 +0200 Subject: [PATCH 802/947] Update xknx to 0.21.5 - Fix discovery of IP-Secure interfaces (#74147) --- homeassistant/components/knx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index b8f9bdcbd30..0c34428f0a1 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -3,7 +3,7 @@ "name": "KNX", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/knx", - "requirements": ["xknx==0.21.3"], + "requirements": ["xknx==0.21.5"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "quality_scale": "platinum", "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index f8aad0172a3..04c48d20c70 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2457,7 +2457,7 @@ xbox-webapi==2.0.11 xboxapi==2.0.1 # homeassistant.components.knx -xknx==0.21.3 +xknx==0.21.5 # homeassistant.components.bluesound # homeassistant.components.fritz diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cb3fa2af723..8520fb665fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1630,7 +1630,7 @@ wolf_smartset==0.1.11 xbox-webapi==2.0.11 # homeassistant.components.knx -xknx==0.21.3 +xknx==0.21.5 # homeassistant.components.bluesound # homeassistant.components.fritz From ef5fccad9ed80c7e2e08bedf48186e6acb399804 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 28 Jun 2022 23:23:32 +0200 Subject: [PATCH 803/947] Use standard argument name in async_step_reauth (#74139) --- homeassistant/components/abode/config_flow.py | 4 ++-- homeassistant/components/airvisual/config_flow.py | 6 +++--- .../components/aussie_broadband/config_flow.py | 4 ++-- homeassistant/components/axis/config_flow.py | 12 ++++++------ homeassistant/components/deconz/config_flow.py | 8 ++++---- .../components/devolo_home_control/config_flow.py | 6 +++--- homeassistant/components/fritz/config_flow.py | 10 +++++----- homeassistant/components/fritzbox/config_flow.py | 8 ++++---- homeassistant/components/meater/config_flow.py | 4 ++-- homeassistant/components/nam/config_flow.py | 4 ++-- homeassistant/components/nanoleaf/config_flow.py | 6 ++++-- homeassistant/components/nest/config_flow.py | 4 ++-- homeassistant/components/notion/config_flow.py | 4 ++-- homeassistant/components/overkiz/config_flow.py | 4 ++-- homeassistant/components/renault/config_flow.py | 4 ++-- homeassistant/components/ridwell/config_flow.py | 4 ++-- homeassistant/components/simplisafe/config_flow.py | 6 +++--- homeassistant/components/sleepiq/config_flow.py | 4 ++-- homeassistant/components/surepetcare/config_flow.py | 4 ++-- homeassistant/components/synology_dsm/config_flow.py | 4 ++-- homeassistant/components/tile/config_flow.py | 4 ++-- homeassistant/components/watttime/config_flow.py | 4 ++-- homeassistant/components/xiaomi_miio/config_flow.py | 10 +++++----- 23 files changed, 65 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index 1cbab2bdbe4..4c3d44bebbe 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -150,9 +150,9 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self._async_abode_mfa_login() - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauthorization request from Abode.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 516b8906092..f97616c38fc 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -221,10 +221,10 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={**user_input, CONF_INTEGRATION_TYPE: INTEGRATION_TYPE_NODE_PRO}, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._entry_data_for_reauth = data - self._geo_id = async_get_geography_id(data) + self._entry_data_for_reauth = entry_data + self._geo_id = async_get_geography_id(entry_data) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/aussie_broadband/config_flow.py b/homeassistant/components/aussie_broadband/config_flow.py index 37de59d4767..c71570b73fb 100644 --- a/homeassistant/components/aussie_broadband/config_flow.py +++ b/homeassistant/components/aussie_broadband/config_flow.py @@ -77,9 +77,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth on credential failure.""" - self._reauth_username = data[CONF_USERNAME] + self._reauth_username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 2c5239a8fb3..f94c27dc2ac 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -139,18 +139,18 @@ class AxisFlowHandler(config_entries.ConfigFlow, domain=AXIS_DOMAIN): title = f"{model} - {serial}" return self.async_create_entry(title=title, data=self.device_config) - async def async_step_reauth(self, device_config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" self.context["title_placeholders"] = { - CONF_NAME: device_config[CONF_NAME], - CONF_HOST: device_config[CONF_HOST], + CONF_NAME: entry_data[CONF_NAME], + CONF_HOST: entry_data[CONF_HOST], } self.discovery_schema = { - vol.Required(CONF_HOST, default=device_config[CONF_HOST]): str, - vol.Required(CONF_USERNAME, default=device_config[CONF_USERNAME]): str, + vol.Required(CONF_HOST, default=entry_data[CONF_HOST]): str, + vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, - vol.Required(CONF_PORT, default=device_config[CONF_PORT]): int, + vol.Required(CONF_PORT, default=entry_data[CONF_PORT]): int, } return await self.async_step_user() diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 6e2a286c168..d94b40e8525 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -205,12 +205,12 @@ class DeconzFlowHandler(ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" - self.context["title_placeholders"] = {CONF_HOST: config[CONF_HOST]} + self.context["title_placeholders"] = {CONF_HOST: entry_data[CONF_HOST]} - self.host = config[CONF_HOST] - self.port = config[CONF_PORT] + self.host = entry_data[CONF_HOST] + self.port = entry_data[CONF_PORT] return await self.async_step_link() diff --git a/homeassistant/components/devolo_home_control/config_flow.py b/homeassistant/components/devolo_home_control/config_flow.py index fc1689e0742..aef9592d2e9 100644 --- a/homeassistant/components/devolo_home_control/config_flow.py +++ b/homeassistant/components/devolo_home_control/config_flow.py @@ -68,14 +68,14 @@ class DevoloHomeControlFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="zeroconf_confirm", errors={"base": "invalid_auth"} ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauthentication.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - self._url = data[CONF_MYDEVOLO] + self._url = entry_data[CONF_MYDEVOLO] self.data_schema = { - vol.Required(CONF_USERNAME, default=data[CONF_USERNAME]): str, + vol.Required(CONF_USERNAME, default=entry_data[CONF_USERNAME]): str, vol.Required(CONF_PASSWORD): str, } return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/fritz/config_flow.py b/homeassistant/components/fritz/config_flow.py index afb1708cac1..ddc09cb73a9 100644 --- a/homeassistant/components/fritz/config_flow.py +++ b/homeassistant/components/fritz/config_flow.py @@ -231,13 +231,13 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN): return self._async_create_entry() - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle flow upon an API authentication error.""" self._entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) - self._host = data[CONF_HOST] - self._port = data[CONF_PORT] - self._username = data[CONF_USERNAME] - self._password = data[CONF_PASSWORD] + self._host = entry_data[CONF_HOST] + self._port = entry_data[CONF_PORT] + self._username = entry_data[CONF_USERNAME] + self._password = entry_data[CONF_PASSWORD] return await self.async_step_reauth_confirm() def _show_setup_form_reauth_confirm( diff --git a/homeassistant/components/fritzbox/config_flow.py b/homeassistant/components/fritzbox/config_flow.py index d183c4a8d5e..b4e86b92568 100644 --- a/homeassistant/components/fritzbox/config_flow.py +++ b/homeassistant/components/fritzbox/config_flow.py @@ -176,14 +176,14 @@ class FritzboxConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Trigger a reauthentication flow.""" entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) assert entry is not None self._entry = entry - self._host = data[CONF_HOST] - self._name = str(data[CONF_HOST]) - self._username = data[CONF_USERNAME] + self._host = entry_data[CONF_HOST] + self._name = str(entry_data[CONF_HOST]) + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/meater/config_flow.py b/homeassistant/components/meater/config_flow.py index 8d5e459fbce..91a927a5fb2 100644 --- a/homeassistant/components/meater/config_flow.py +++ b/homeassistant/components/meater/config_flow.py @@ -45,10 +45,10 @@ class MeaterConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self._try_connect_meater("user", None, username, password) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._data_schema = REAUTH_SCHEMA - self._username = data[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/nam/config_flow.py b/homeassistant/components/nam/config_flow.py index 451148c22fe..3dc2d7f0ba0 100644 --- a/homeassistant/components/nam/config_flow.py +++ b/homeassistant/components/nam/config_flow.py @@ -190,11 +190,11 @@ class NAMFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" if entry := self.hass.config_entries.async_get_entry(self.context["entry_id"]): self.entry = entry - self.host = data[CONF_HOST] + self.host = entry_data[CONF_HOST] self.context["title_placeholders"] = {"host": self.host} return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/nanoleaf/config_flow.py b/homeassistant/components/nanoleaf/config_flow.py index eb8bbb1ec66..cab6c8003d0 100644 --- a/homeassistant/components/nanoleaf/config_flow.py +++ b/homeassistant/components/nanoleaf/config_flow.py @@ -78,13 +78,15 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_link() - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle Nanoleaf reauth flow if token is invalid.""" self.reauth_entry = cast( config_entries.ConfigEntry, self.hass.config_entries.async_get_entry(self.context["entry_id"]), ) - self.nanoleaf = Nanoleaf(async_get_clientsession(self.hass), data[CONF_HOST]) + self.nanoleaf = Nanoleaf( + async_get_clientsession(self.hass), entry_data[CONF_HOST] + ) self.context["title_placeholders"] = {"name": self.reauth_entry.title} return await self.async_step_link() diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index 2e89f7970fa..1288592be74 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -218,10 +218,10 @@ class NestFlowHandler( return await self.async_step_finish() return await self.async_step_pubsub() - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - self._data.update(user_input) + self._data.update(entry_data) return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 56093067711..917c0f8ebb9 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -74,9 +74,9 @@ class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title=self._username, data=data) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/overkiz/config_flow.py b/homeassistant/components/overkiz/config_flow.py index c70a551f4c7..2808c309938 100644 --- a/homeassistant/components/overkiz/config_flow.py +++ b/homeassistant/components/overkiz/config_flow.py @@ -155,7 +155,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._config_entry = cast( ConfigEntry, @@ -169,4 +169,4 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._default_user = self._config_entry.data[CONF_USERNAME] self._default_hub = self._config_entry.data[CONF_HUB] - return await self.async_step_user(dict(data)) + return await self.async_step_user(dict(entry_data)) diff --git a/homeassistant/components/renault/config_flow.py b/homeassistant/components/renault/config_flow.py index 539ba7549b3..8f5b99972d1 100644 --- a/homeassistant/components/renault/config_flow.py +++ b/homeassistant/components/renault/config_flow.py @@ -93,9 +93,9 @@ class RenaultFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ), ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._original_data = data + self._original_data = entry_data return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/ridwell/config_flow.py b/homeassistant/components/ridwell/config_flow.py index 2d6444fede9..722c20336d4 100644 --- a/homeassistant/components/ridwell/config_flow.py +++ b/homeassistant/components/ridwell/config_flow.py @@ -81,9 +81,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data={CONF_USERNAME: self._username, CONF_PASSWORD: self._password}, ) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 3fcab1f3966..0b95de2c186 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -98,16 +98,16 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Define the config flow to handle options.""" return SimpliSafeOptionsFlowHandler(config_entry) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth = True - if CONF_USERNAME not in config: + if CONF_USERNAME not in entry_data: # Old versions of the config flow may not have the username by this point; # in that case, we reauth them by making them go through the user flow: return await self.async_step_user() - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def _async_get_email_2fa(self) -> None: diff --git a/homeassistant/components/sleepiq/config_flow.py b/homeassistant/components/sleepiq/config_flow.py index c78daa76fbc..16034b64e8b 100644 --- a/homeassistant/components/sleepiq/config_flow.py +++ b/homeassistant/components/sleepiq/config_flow.py @@ -80,12 +80,12 @@ class SleepIQFlowHandler(ConfigFlow, domain=DOMAIN): last_step=True, ) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - return await self.async_step_reauth_confirm(dict(data)) + return await self.async_step_reauth_confirm(dict(entry_data)) async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/surepetcare/config_flow.py b/homeassistant/components/surepetcare/config_flow.py index b8b3d690a8b..7c4509259ad 100644 --- a/homeassistant/components/surepetcare/config_flow.py +++ b/homeassistant/components/surepetcare/config_flow.py @@ -87,9 +87,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index bc35caf300e..89bbc4ae8c2 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -300,9 +300,9 @@ class SynologyDSMFlowHandler(ConfigFlow, domain=DOMAIN): user_input = {**self.discovered_conf, **user_input} return await self.async_validate_input_create_entry(user_input, step_id=step) - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self.reauth_conf = data + self.reauth_conf = entry_data return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/tile/config_flow.py b/homeassistant/components/tile/config_flow.py index c47a46b3b10..3ba1dc411ae 100644 --- a/homeassistant/components/tile/config_flow.py +++ b/homeassistant/components/tile/config_flow.py @@ -75,9 +75,9 @@ class TileFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._username = config[CONF_USERNAME] + self._username = entry_data[CONF_USERNAME] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/watttime/config_flow.py b/homeassistant/components/watttime/config_flow.py index 4d6985ec616..a5d9c6925c8 100644 --- a/homeassistant/components/watttime/config_flow.py +++ b/homeassistant/components/watttime/config_flow.py @@ -190,9 +190,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) return await self.async_step_coordinates() - async def async_step_reauth(self, config: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._data = {**config} + self._data = {**entry_data} return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py index e5b5275757c..4e2ba24bc05 100644 --- a/homeassistant/components/xiaomi_miio/config_flow.py +++ b/homeassistant/components/xiaomi_miio/config_flow.py @@ -129,12 +129,12 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow.""" return OptionsFlowHandler(config_entry) - async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an authentication error or missing cloud credentials.""" - self.host = user_input[CONF_HOST] - self.token = user_input[CONF_TOKEN] - self.mac = user_input[CONF_MAC] - self.model = user_input.get(CONF_MODEL) + self.host = entry_data[CONF_HOST] + self.token = entry_data[CONF_TOKEN] + self.mac = entry_data[CONF_MAC] + self.model = entry_data.get(CONF_MODEL) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( From 4aa8570107a37f77cdb17cbc5f14ce42c7ab4b4e Mon Sep 17 00:00:00 2001 From: Felipe Santos Date: Tue, 28 Jun 2022 18:26:25 -0300 Subject: [PATCH 804/947] Set Google Cast audio devices as speakers (#73832) --- homeassistant/components/cast/media_player.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index ea21259ccc4..958c53ae394 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -30,6 +30,7 @@ from homeassistant.components import media_source, zeroconf from homeassistant.components.media_player import ( BrowseError, BrowseMedia, + MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, async_process_play_media_url, @@ -300,6 +301,12 @@ class CastMediaPlayerEntity(CastDevice, MediaPlayerEntity): name=str(cast_info.friendly_name), ) + if cast_info.cast_info.cast_type in [ + pychromecast.const.CAST_TYPE_AUDIO, + pychromecast.const.CAST_TYPE_GROUP, + ]: + self._attr_device_class = MediaPlayerDeviceClass.SPEAKER + async def async_added_to_hass(self): """Create chromecast object when added to hass.""" self._async_setup(self.entity_id) From ef76073d8336717c865e2560076f2b0379d2f456 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 28 Jun 2022 23:31:15 +0200 Subject: [PATCH 805/947] Add Netgear ethernet link status (#72582) --- homeassistant/components/netgear/__init__.py | 14 ++++++++++++++ homeassistant/components/netgear/const.py | 1 + homeassistant/components/netgear/router.py | 5 +++++ homeassistant/components/netgear/sensor.py | 16 ++++++++++++++++ 4 files changed, 36 insertions(+) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 679a93f8da1..953008ae9f5 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( DOMAIN, KEY_COORDINATOR, + KEY_COORDINATOR_LINK, KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, KEY_ROUTER, @@ -83,6 +84,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from the router.""" return await router.async_get_speed_test() + async def async_check_link_status() -> dict[str, Any] | None: + """Fetch data from the router.""" + return await router.async_get_link_status() + # Create update coordinators coordinator = DataUpdateCoordinator( hass, @@ -105,16 +110,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=async_update_speed_test, update_interval=SPEED_TEST_INTERVAL, ) + coordinator_link = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{router.device_name} Ethernet Link Status", + update_method=async_check_link_status, + update_interval=SCAN_INTERVAL, + ) if router.track_devices: await coordinator.async_config_entry_first_refresh() await coordinator_traffic_meter.async_config_entry_first_refresh() + await coordinator_link.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = { KEY_ROUTER: router, KEY_COORDINATOR: coordinator, KEY_COORDINATOR_TRAFFIC: coordinator_traffic_meter, KEY_COORDINATOR_SPEED: coordinator_speed_test, + KEY_COORDINATOR_LINK: coordinator_link, } hass.config_entries.async_setup_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index 936777a7961..c8939208047 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -13,6 +13,7 @@ KEY_ROUTER = "router" KEY_COORDINATOR = "coordinator" KEY_COORDINATOR_TRAFFIC = "coordinator_traffic" KEY_COORDINATOR_SPEED = "coordinator_speed" +KEY_COORDINATOR_LINK = "coordinator_link" DEFAULT_CONSIDER_HOME = timedelta(seconds=180) DEFAULT_NAME = "Netgear router" diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 67e573d0e92..6284c6f4ac2 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -228,6 +228,11 @@ class NetgearRouter: self._api.get_new_speed_test_result ) + async def async_get_link_status(self) -> dict[str, Any] | None: + """Check the ethernet link status of the router.""" + async with self._api_lock: + return await self.hass.async_add_executor_job(self._api.check_ethernet_link) + async def async_allow_block_device(self, mac: str, allow_block: str) -> None: """Allow or block a device connected to the router.""" async with self._api_lock: diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index a1cf134beda..f860b65e10f 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -29,6 +29,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( DOMAIN, KEY_COORDINATOR, + KEY_COORDINATOR_LINK, KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, KEY_ROUTER, @@ -244,6 +245,15 @@ SENSOR_SPEED_TYPES = [ ), ] +SENSOR_LINK_TYPES = [ + NetgearSensorEntityDescription( + key="NewEthernetLinkStatus", + name="Ethernet Link Status", + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:ethernet", + ), +] + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -253,6 +263,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] coordinator_traffic = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_TRAFFIC] coordinator_speed = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_SPEED] + coordinator_link = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_LINK] # Router entities router_entities = [] @@ -267,6 +278,11 @@ async def async_setup_entry( NetgearRouterSensorEntity(coordinator_speed, router, description) ) + for description in SENSOR_LINK_TYPES: + router_entities.append( + NetgearRouterSensorEntity(coordinator_link, router, description) + ) + async_add_entities(router_entities) # Entities per network device From f5fe210eca9286bed3bdaeff2274e0ea74565ba1 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Wed, 29 Jun 2022 00:23:03 +0000 Subject: [PATCH 806/947] [ci skip] Translation update --- .../components/aemet/translations/bg.json | 3 +++ .../components/agent_dvr/translations/bg.json | 3 +++ .../components/airvisual/translations/bg.json | 3 ++- .../ambient_station/translations/bg.json | 3 +++ .../components/asuswrt/translations/bg.json | 2 ++ .../components/atag/translations/bg.json | 3 +++ .../components/august/translations/bg.json | 14 +++++++++++++ .../components/awair/translations/ca.json | 7 +++++++ .../components/awair/translations/de.json | 9 +++++++- .../components/awair/translations/et.json | 7 +++++++ .../components/awair/translations/fr.json | 7 +++++++ .../components/awair/translations/hu.json | 7 +++++++ .../components/awair/translations/it.json | 7 +++++++ .../components/awair/translations/nl.json | 6 ++++++ .../components/awair/translations/no.json | 7 +++++++ .../components/awair/translations/pt-BR.json | 7 +++++++ .../azure_devops/translations/bg.json | 3 ++- .../components/cast/translations/bg.json | 3 +++ .../components/directv/translations/bg.json | 1 + .../components/econet/translations/bg.json | 4 ++++ .../components/emonitor/translations/bg.json | 5 +++++ .../enphase_envoy/translations/bg.json | 3 +++ .../components/ezviz/translations/bg.json | 1 + .../flunearyou/translations/bg.json | 14 +++++++++++++ .../components/freebox/translations/bg.json | 3 ++- .../components/fritz/translations/bg.json | 1 + .../fritzbox_callmonitor/translations/bg.json | 5 +++++ .../geonetnz_quakes/translations/bg.json | 3 +++ .../components/google/translations/bg.json | 1 + .../components/google/translations/nl.json | 1 + .../components/group/translations/bg.json | 9 +++++++- .../components/hangouts/translations/de.json | 1 + .../components/harmony/translations/bg.json | 8 +++++++ .../components/heos/translations/bg.json | 3 +++ .../components/hive/translations/bg.json | 21 +++++++++++++++++++ .../components/hive/translations/nl.json | 7 +++++++ .../components/icloud/translations/bg.json | 5 +++++ .../integration/translations/bg.json | 1 + .../components/ipp/translations/bg.json | 3 +++ .../keenetic_ndms2/translations/bg.json | 3 +++ .../kostal_plenticore/translations/bg.json | 8 +++++++ .../components/lcn/translations/de.json | 1 + .../components/lcn/translations/it.json | 1 + .../components/lcn/translations/pt-BR.json | 1 + .../components/life360/translations/bg.json | 3 ++- .../components/litejet/translations/bg.json | 3 +++ .../litterrobot/translations/bg.json | 3 +++ .../components/mazda/translations/bg.json | 2 ++ .../met_eireann/translations/bg.json | 1 + .../motion_blinds/translations/bg.json | 1 + .../components/motioneye/translations/bg.json | 4 ++++ .../components/mullvad/translations/bg.json | 4 ++++ .../components/myq/translations/bg.json | 17 +++++++++++++++ .../components/mysensors/translations/bg.json | 4 ++++ .../components/nest/translations/nl.json | 1 + .../components/nexia/translations/bg.json | 6 ++++++ .../components/nuheat/translations/bg.json | 1 + .../components/nut/translations/bg.json | 3 +++ .../overkiz/translations/sensor.nl.json | 5 +++++ .../panasonic_viera/translations/bg.json | 3 ++- .../components/picnic/translations/bg.json | 7 +++++++ .../components/rfxtrx/translations/bg.json | 1 + .../components/risco/translations/bg.json | 9 ++++++++ .../translations/bg.json | 3 +++ .../components/roomba/translations/bg.json | 3 +++ .../ruckus_unleashed/translations/bg.json | 4 ++++ .../screenlogic/translations/bg.json | 1 + .../components/sense/translations/bg.json | 3 +++ .../simplepush/translations/nl.json | 11 ++++++++++ .../components/sma/translations/bg.json | 11 ++++++++++ .../components/smappee/translations/bg.json | 1 + .../components/smarttub/translations/bg.json | 3 +++ .../somfy_mylink/translations/bg.json | 3 +++ .../components/subaru/translations/bg.json | 4 ++++ .../components/syncthru/translations/bg.json | 3 ++- .../transmission/translations/nl.json | 10 ++++++++- .../twentemilieu/translations/bg.json | 1 + .../components/vacuum/translations/no.json | 2 +- .../components/verisure/translations/bg.json | 10 +++++++++ .../components/vulcan/translations/bg.json | 3 ++- .../wolflink/translations/sensor.bg.json | 3 +++ 81 files changed, 361 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/august/translations/bg.json create mode 100644 homeassistant/components/emonitor/translations/bg.json create mode 100644 homeassistant/components/flunearyou/translations/bg.json create mode 100644 homeassistant/components/harmony/translations/bg.json create mode 100644 homeassistant/components/kostal_plenticore/translations/bg.json create mode 100644 homeassistant/components/myq/translations/bg.json create mode 100644 homeassistant/components/simplepush/translations/nl.json create mode 100644 homeassistant/components/sma/translations/bg.json create mode 100644 homeassistant/components/verisure/translations/bg.json diff --git a/homeassistant/components/aemet/translations/bg.json b/homeassistant/components/aemet/translations/bg.json index 62d0a34441a..4823a30fd97 100644 --- a/homeassistant/components/aemet/translations/bg.json +++ b/homeassistant/components/aemet/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { "invalid_api_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447" }, diff --git a/homeassistant/components/agent_dvr/translations/bg.json b/homeassistant/components/agent_dvr/translations/bg.json index 527adb67bf7..cc5f200ef95 100644 --- a/homeassistant/components/agent_dvr/translations/bg.json +++ b/homeassistant/components/agent_dvr/translations/bg.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/airvisual/translations/bg.json b/homeassistant/components/airvisual/translations/bg.json index 114a1547549..807b9556240 100644 --- a/homeassistant/components/airvisual/translations/bg.json +++ b/homeassistant/components/airvisual/translations/bg.json @@ -11,7 +11,8 @@ "step": { "geography_by_coords": { "data": { - "api_key": "API \u043a\u043b\u044e\u0447" + "api_key": "API \u043a\u043b\u044e\u0447", + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430" } }, "geography_by_name": { diff --git a/homeassistant/components/ambient_station/translations/bg.json b/homeassistant/components/ambient_station/translations/bg.json index 173b1c39c5f..9e55323228f 100644 --- a/homeassistant/components/ambient_station/translations/bg.json +++ b/homeassistant/components/ambient_station/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, "error": { "invalid_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447 \u0438/\u0438\u043b\u0438 Application \u043a\u043b\u044e\u0447", "no_devices": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043f\u0440\u043e\u0444\u0438\u043b\u0430" diff --git a/homeassistant/components/asuswrt/translations/bg.json b/homeassistant/components/asuswrt/translations/bg.json index dbb5f415f92..df452e48980 100644 --- a/homeassistant/components/asuswrt/translations/bg.json +++ b/homeassistant/components/asuswrt/translations/bg.json @@ -1,11 +1,13 @@ { "config": { "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { "data": { + "mode": "\u0420\u0435\u0436\u0438\u043c", "name": "\u0418\u043c\u0435", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "port": "\u041f\u043e\u0440\u0442", diff --git a/homeassistant/components/atag/translations/bg.json b/homeassistant/components/atag/translations/bg.json index 2dd2ff1750c..0d30d7c1e16 100644 --- a/homeassistant/components/atag/translations/bg.json +++ b/homeassistant/components/atag/translations/bg.json @@ -3,6 +3,9 @@ "abort": { "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/august/translations/bg.json b/homeassistant/components/august/translations/bg.json new file mode 100644 index 00000000000..224e3324cb6 --- /dev/null +++ b/homeassistant/components/august/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, + "step": { + "user_validate": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/awair/translations/ca.json b/homeassistant/components/awair/translations/ca.json index 12384b088bb..3510d3a3a8b 100644 --- a/homeassistant/components/awair/translations/ca.json +++ b/homeassistant/components/awair/translations/ca.json @@ -17,6 +17,13 @@ }, "description": "Torna a introduir el token d'acc\u00e9s de desenvolupador d'Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Token d'acc\u00e9s", + "email": "Correu electr\u00f2nic" + }, + "description": "Torna a introduir el 'token' d'acc\u00e9s de desenvolupador d'Awair." + }, "user": { "data": { "access_token": "Token d'acc\u00e9s", diff --git a/homeassistant/components/awair/translations/de.json b/homeassistant/components/awair/translations/de.json index 1dacaf099dc..c28ee6bc016 100644 --- a/homeassistant/components/awair/translations/de.json +++ b/homeassistant/components/awair/translations/de.json @@ -15,7 +15,14 @@ "access_token": "Zugangstoken", "email": "E-Mail" }, - "description": "Bitte gib dein Awair-Entwicklerzugriffstoken erneut ein." + "description": "Bitte gib deinen Awair-Entwicklerzugriffstoken erneut ein." + }, + "reauth_confirm": { + "data": { + "access_token": "Zugangstoken", + "email": "E-Mail" + }, + "description": "Bitte gib deinen Awair-Entwicklerzugriffstoken erneut ein." }, "user": { "data": { diff --git a/homeassistant/components/awair/translations/et.json b/homeassistant/components/awair/translations/et.json index 374db23e18e..70632b292e4 100644 --- a/homeassistant/components/awair/translations/et.json +++ b/homeassistant/components/awair/translations/et.json @@ -17,6 +17,13 @@ }, "description": "Taassisesta oma Awairi arendaja juurdep\u00e4\u00e4suluba." }, + "reauth_confirm": { + "data": { + "access_token": "Juurdep\u00e4\u00e4sut\u00f5end", + "email": "Meiliaadress" + }, + "description": "Taassisesta oma Awairi arendaja juurdep\u00e4\u00e4suluba." + }, "user": { "data": { "access_token": "Juurdep\u00e4\u00e4sut\u00f5end", diff --git a/homeassistant/components/awair/translations/fr.json b/homeassistant/components/awair/translations/fr.json index 7182117fa53..fd915507762 100644 --- a/homeassistant/components/awair/translations/fr.json +++ b/homeassistant/components/awair/translations/fr.json @@ -17,6 +17,13 @@ }, "description": "Veuillez ressaisir votre jeton d'acc\u00e8s d\u00e9veloppeur Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Jeton d'acc\u00e8s", + "email": "Courriel" + }, + "description": "Veuillez ressaisir votre jeton d'acc\u00e8s d\u00e9veloppeur Awair." + }, "user": { "data": { "access_token": "Jeton d'acc\u00e8s", diff --git a/homeassistant/components/awair/translations/hu.json b/homeassistant/components/awair/translations/hu.json index e3994430a8b..2e81b31f187 100644 --- a/homeassistant/components/awair/translations/hu.json +++ b/homeassistant/components/awair/translations/hu.json @@ -17,6 +17,13 @@ }, "description": "Adja meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." }, + "reauth_confirm": { + "data": { + "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", + "email": "E-mail" + }, + "description": "Adja meg \u00fajra az Awair fejleszt\u0151i hozz\u00e1f\u00e9r\u00e9si tokent." + }, "user": { "data": { "access_token": "Hozz\u00e1f\u00e9r\u00e9si token", diff --git a/homeassistant/components/awair/translations/it.json b/homeassistant/components/awair/translations/it.json index c9480ecaaa0..27ec006fb06 100644 --- a/homeassistant/components/awair/translations/it.json +++ b/homeassistant/components/awair/translations/it.json @@ -17,6 +17,13 @@ }, "description": "Inserisci nuovamente il tuo token di accesso per sviluppatori Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Token di accesso", + "email": "Email" + }, + "description": "Inserisci nuovamente il tuo token di accesso sviluppatore Awair." + }, "user": { "data": { "access_token": "Token di accesso", diff --git a/homeassistant/components/awair/translations/nl.json b/homeassistant/components/awair/translations/nl.json index 1b58405af32..ff270b6084f 100644 --- a/homeassistant/components/awair/translations/nl.json +++ b/homeassistant/components/awair/translations/nl.json @@ -17,6 +17,12 @@ }, "description": "Voer uw Awair-ontwikkelaarstoegangstoken opnieuw in." }, + "reauth_confirm": { + "data": { + "access_token": "Toegangstoken", + "email": "E-mail" + } + }, "user": { "data": { "access_token": "Toegangstoken", diff --git a/homeassistant/components/awair/translations/no.json b/homeassistant/components/awair/translations/no.json index 98486a28b09..13232ca37df 100644 --- a/homeassistant/components/awair/translations/no.json +++ b/homeassistant/components/awair/translations/no.json @@ -17,6 +17,13 @@ }, "description": "Skriv inn tilgangstokenet for Awair-utviklere p\u00e5 nytt." }, + "reauth_confirm": { + "data": { + "access_token": "Tilgangstoken", + "email": "E-post" + }, + "description": "Skriv inn Awair-utviklertilgangstokenet ditt p\u00e5 nytt." + }, "user": { "data": { "access_token": "Tilgangstoken", diff --git a/homeassistant/components/awair/translations/pt-BR.json b/homeassistant/components/awair/translations/pt-BR.json index 635a7373b75..7406bdf3ee0 100644 --- a/homeassistant/components/awair/translations/pt-BR.json +++ b/homeassistant/components/awair/translations/pt-BR.json @@ -17,6 +17,13 @@ }, "description": "Insira novamente seu token de acesso de desenvolvedor Awair." }, + "reauth_confirm": { + "data": { + "access_token": "Token de acesso", + "email": "E-mail" + }, + "description": "Insira novamente seu token de acesso de desenvolvedor Awair." + }, "user": { "data": { "access_token": "Token de acesso", diff --git a/homeassistant/components/azure_devops/translations/bg.json b/homeassistant/components/azure_devops/translations/bg.json index 60c6d07d013..28af7ef6e00 100644 --- a/homeassistant/components/azure_devops/translations/bg.json +++ b/homeassistant/components/azure_devops/translations/bg.json @@ -12,7 +12,8 @@ "step": { "user": { "data": { - "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f" + "organization": "\u041e\u0440\u0433\u0430\u043d\u0438\u0437\u0430\u0446\u0438\u044f", + "project": "\u041f\u0440\u043e\u0435\u043a\u0442" } } } diff --git a/homeassistant/components/cast/translations/bg.json b/homeassistant/components/cast/translations/bg.json index 0ab9d863eff..d5103f596e8 100644 --- a/homeassistant/components/cast/translations/bg.json +++ b/homeassistant/components/cast/translations/bg.json @@ -4,6 +4,9 @@ "single_instance_allowed": "\u0420\u0430\u0437\u0440\u0435\u0448\u0435\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Google Cast." }, "step": { + "config": { + "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Google Cast" + }, "confirm": { "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Google Cast?" } diff --git a/homeassistant/components/directv/translations/bg.json b/homeassistant/components/directv/translations/bg.json index ffb69776060..b43da9ecb18 100644 --- a/homeassistant/components/directv/translations/bg.json +++ b/homeassistant/components/directv/translations/bg.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/econet/translations/bg.json b/homeassistant/components/econet/translations/bg.json index cef3726d759..3468d506903 100644 --- a/homeassistant/components/econet/translations/bg.json +++ b/homeassistant/components/econet/translations/bg.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/emonitor/translations/bg.json b/homeassistant/components/emonitor/translations/bg.json new file mode 100644 index 00000000000..e8940bef26a --- /dev/null +++ b/homeassistant/components/emonitor/translations/bg.json @@ -0,0 +1,5 @@ +{ + "config": { + "flow_title": "{name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/enphase_envoy/translations/bg.json b/homeassistant/components/enphase_envoy/translations/bg.json index c0ccf23f5b5..ffb593eb287 100644 --- a/homeassistant/components/enphase_envoy/translations/bg.json +++ b/homeassistant/components/enphase_envoy/translations/bg.json @@ -2,6 +2,9 @@ "config": { "abort": { "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" } } } \ No newline at end of file diff --git a/homeassistant/components/ezviz/translations/bg.json b/homeassistant/components/ezviz/translations/bg.json index e198a4e53f4..702e3b80001 100644 --- a/homeassistant/components/ezviz/translations/bg.json +++ b/homeassistant/components/ezviz/translations/bg.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{serial}", "step": { "user": { "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 Ezviz Cloud" diff --git a/homeassistant/components/flunearyou/translations/bg.json b/homeassistant/components/flunearyou/translations/bg.json new file mode 100644 index 00000000000..360abac2642 --- /dev/null +++ b/homeassistant/components/flunearyou/translations/bg.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "step": { + "user": { + "data": { + "latitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0448\u0438\u0440\u0438\u043d\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/freebox/translations/bg.json b/homeassistant/components/freebox/translations/bg.json index c8526b8367d..9a63019cd8a 100644 --- a/homeassistant/components/freebox/translations/bg.json +++ b/homeassistant/components/freebox/translations/bg.json @@ -1,7 +1,8 @@ { "config": { "error": { - "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e" + "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { diff --git a/homeassistant/components/fritz/translations/bg.json b/homeassistant/components/fritz/translations/bg.json index 43f9aaf5357..b699cec829c 100644 --- a/homeassistant/components/fritz/translations/bg.json +++ b/homeassistant/components/fritz/translations/bg.json @@ -3,6 +3,7 @@ "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, + "flow_title": "{name}", "step": { "user": { "data": { diff --git a/homeassistant/components/fritzbox_callmonitor/translations/bg.json b/homeassistant/components/fritzbox_callmonitor/translations/bg.json index fc2115d9ca0..ed2dd868df7 100644 --- a/homeassistant/components/fritzbox_callmonitor/translations/bg.json +++ b/homeassistant/components/fritzbox_callmonitor/translations/bg.json @@ -1,6 +1,11 @@ { "config": { "step": { + "phonebook": { + "data": { + "phonebook": "\u0422\u0435\u043b\u0435\u0444\u043e\u043d\u0435\u043d \u0443\u043a\u0430\u0437\u0430\u0442\u0435\u043b" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/geonetnz_quakes/translations/bg.json b/homeassistant/components/geonetnz_quakes/translations/bg.json index 8b4d3e91f2c..23e5c0241d9 100644 --- a/homeassistant/components/geonetnz_quakes/translations/bg.json +++ b/homeassistant/components/geonetnz_quakes/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/google/translations/bg.json b/homeassistant/components/google/translations/bg.json index 0d82b088635..cd81f011d5a 100644 --- a/homeassistant/components/google/translations/bg.json +++ b/homeassistant/components/google/translations/bg.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430.", "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, diff --git a/homeassistant/components/google/translations/nl.json b/homeassistant/components/google/translations/nl.json index 419259d66f8..2f8d67af1e2 100644 --- a/homeassistant/components/google/translations/nl.json +++ b/homeassistant/components/google/translations/nl.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Account is al geconfigureerd", "already_in_progress": "De configuratie is momenteel al bezig", + "cannot_connect": "Kan geen verbinding maken", "code_expired": "De authenticatiecode is verlopen of de instelling van de inloggegevens is ongeldig, probeer het opnieuw.", "invalid_access_token": "Ongeldig toegangstoken", "missing_configuration": "Integratie niet geconfigureerd. Raadpleeg de documentatie.", diff --git a/homeassistant/components/group/translations/bg.json b/homeassistant/components/group/translations/bg.json index 5ecb4d66328..dea9870e1b7 100644 --- a/homeassistant/components/group/translations/bg.json +++ b/homeassistant/components/group/translations/bg.json @@ -11,7 +11,14 @@ "fan": { "data": { "name": "\u0418\u043c\u0435" - } + }, + "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0430" + }, + "light": { + "data": { + "name": "\u0418\u043c\u0435" + }, + "title": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0433\u0440\u0443\u043f\u0430" }, "lock": { "data": { diff --git a/homeassistant/components/hangouts/translations/de.json b/homeassistant/components/hangouts/translations/de.json index b26618940be..53225644a2d 100644 --- a/homeassistant/components/hangouts/translations/de.json +++ b/homeassistant/components/hangouts/translations/de.json @@ -14,6 +14,7 @@ "data": { "2fa": "2FA PIN" }, + "description": "Leer", "title": "2-Faktor-Authentifizierung" }, "user": { diff --git a/homeassistant/components/harmony/translations/bg.json b/homeassistant/components/harmony/translations/bg.json new file mode 100644 index 00000000000..b961225fc1d --- /dev/null +++ b/homeassistant/components/harmony/translations/bg.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, + "flow_title": "{name}" + } +} \ No newline at end of file diff --git a/homeassistant/components/heos/translations/bg.json b/homeassistant/components/heos/translations/bg.json index ced8d049372..aa967795627 100644 --- a/homeassistant/components/heos/translations/bg.json +++ b/homeassistant/components/heos/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/hive/translations/bg.json b/homeassistant/components/hive/translations/bg.json index ac28082fbed..082fb940fca 100644 --- a/homeassistant/components/hive/translations/bg.json +++ b/homeassistant/components/hive/translations/bg.json @@ -2,6 +2,27 @@ "config": { "error": { "no_internet_available": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e\u0441\u0442 \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0441 Hive." + }, + "step": { + "reauth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + }, + "options": { + "step": { + "user": { + "data": { + "scan_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043d\u0430 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435 (\u0441\u0435\u043a\u0443\u043d\u0434\u0438)" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/hive/translations/nl.json b/homeassistant/components/hive/translations/nl.json index 2cbc7c94fc6..af0c9bcbfb2 100644 --- a/homeassistant/components/hive/translations/nl.json +++ b/homeassistant/components/hive/translations/nl.json @@ -20,6 +20,13 @@ "description": "Voer uw Hive-verificatiecode in. \n \n Voer code 0000 in om een andere code aan te vragen.", "title": "Hive tweefactorauthenticatie" }, + "configuration": { + "data": { + "device_name": "Apparaatnaam" + }, + "description": "Voer uw Hive-configuratie in", + "title": "Hive-configuratie." + }, "reauth": { "data": { "password": "Wachtwoord", diff --git a/homeassistant/components/icloud/translations/bg.json b/homeassistant/components/icloud/translations/bg.json index 52c6ed9b018..4ee0a45d19d 100644 --- a/homeassistant/components/icloud/translations/bg.json +++ b/homeassistant/components/icloud/translations/bg.json @@ -8,6 +8,11 @@ "validate_verification_code": "\u041f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0430\u0432\u0430\u043d\u0435\u0442\u043e \u043d\u0430 \u043a\u043e\u0434\u0430 \u0437\u0430 \u043f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0435\u043d\u0438\u0435 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e" }, "step": { + "reauth": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + }, "user": { "data": { "password": "\u041f\u0430\u0440\u043e\u043b\u0430", diff --git a/homeassistant/components/integration/translations/bg.json b/homeassistant/components/integration/translations/bg.json index 35cfa0ad1d7..ac35010224f 100644 --- a/homeassistant/components/integration/translations/bg.json +++ b/homeassistant/components/integration/translations/bg.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "method": "\u041c\u0435\u0442\u043e\u0434 \u043d\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435", "name": "\u0418\u043c\u0435" } } diff --git a/homeassistant/components/ipp/translations/bg.json b/homeassistant/components/ipp/translations/bg.json index d454fe68170..19680bdcfb4 100644 --- a/homeassistant/components/ipp/translations/bg.json +++ b/homeassistant/components/ipp/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/keenetic_ndms2/translations/bg.json b/homeassistant/components/keenetic_ndms2/translations/bg.json index 3bebf2d185e..42c3174a4c4 100644 --- a/homeassistant/components/keenetic_ndms2/translations/bg.json +++ b/homeassistant/components/keenetic_ndms2/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "flow_title": "{name} ({host})", "step": { "user": { diff --git a/homeassistant/components/kostal_plenticore/translations/bg.json b/homeassistant/components/kostal_plenticore/translations/bg.json new file mode 100644 index 00000000000..23968d0a06a --- /dev/null +++ b/homeassistant/components/kostal_plenticore/translations/bg.json @@ -0,0 +1,8 @@ +{ + "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/lcn/translations/de.json b/homeassistant/components/lcn/translations/de.json index b4a731fc1f6..f74b3d25dcf 100644 --- a/homeassistant/components/lcn/translations/de.json +++ b/homeassistant/components/lcn/translations/de.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "Codeschloss Code erhalten", "fingerprint": "Fingerabdruckcode empfangen", "send_keys": "Sende Tasten empfangen", "transmitter": "Sendercode empfangen", diff --git a/homeassistant/components/lcn/translations/it.json b/homeassistant/components/lcn/translations/it.json index e42f52b9c62..a39ef71ffd7 100644 --- a/homeassistant/components/lcn/translations/it.json +++ b/homeassistant/components/lcn/translations/it.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "codice di blocco ricevuto", "fingerprint": "codice impronta digitale ricevuto", "send_keys": "invia chiavi ricevute", "transmitter": "codice trasmettitore ricevuto", diff --git a/homeassistant/components/lcn/translations/pt-BR.json b/homeassistant/components/lcn/translations/pt-BR.json index 9898533ea72..7a062fe9578 100644 --- a/homeassistant/components/lcn/translations/pt-BR.json +++ b/homeassistant/components/lcn/translations/pt-BR.json @@ -1,6 +1,7 @@ { "device_automation": { "trigger_type": { + "codelock": "c\u00f3digo de bloqueio de c\u00f3digo recebido", "fingerprint": "c\u00f3digo de impress\u00e3o digital recebido", "send_keys": "enviar chaves recebidas", "transmitter": "c\u00f3digo do transmissor recebido", diff --git a/homeassistant/components/life360/translations/bg.json b/homeassistant/components/life360/translations/bg.json index 5436cfcf718..d206a606b89 100644 --- a/homeassistant/components/life360/translations/bg.json +++ b/homeassistant/components/life360/translations/bg.json @@ -8,7 +8,8 @@ }, "error": { "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", - "invalid_username": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + "invalid_username": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { diff --git a/homeassistant/components/litejet/translations/bg.json b/homeassistant/components/litejet/translations/bg.json index 8c1b2bfb218..c4ccfa52041 100644 --- a/homeassistant/components/litejet/translations/bg.json +++ b/homeassistant/components/litejet/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "\u0412\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e. \u0412\u044a\u0437\u043c\u043e\u0436\u043d\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/litterrobot/translations/bg.json b/homeassistant/components/litterrobot/translations/bg.json index 67a484573aa..bad1fba5a87 100644 --- a/homeassistant/components/litterrobot/translations/bg.json +++ b/homeassistant/components/litterrobot/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/mazda/translations/bg.json b/homeassistant/components/mazda/translations/bg.json index e51e1112202..6e9ce8d9a6a 100644 --- a/homeassistant/components/mazda/translations/bg.json +++ b/homeassistant/components/mazda/translations/bg.json @@ -1,11 +1,13 @@ { "config": { "error": { + "account_locked": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0435 \u0437\u0430\u043a\u043b\u044e\u0447\u0435\u043d. \u041c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e.", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "user": { "data": { + "email": "Email", "password": "\u041f\u0430\u0440\u043e\u043b\u0430", "region": "\u0420\u0435\u0433\u0438\u043e\u043d" } diff --git a/homeassistant/components/met_eireann/translations/bg.json b/homeassistant/components/met_eireann/translations/bg.json index 35cfa0ad1d7..2c39cd06b7d 100644 --- a/homeassistant/components/met_eireann/translations/bg.json +++ b/homeassistant/components/met_eireann/translations/bg.json @@ -3,6 +3,7 @@ "step": { "user": { "data": { + "longitude": "\u0413\u0435\u043e\u0433\u0440\u0430\u0444\u0441\u043a\u0430 \u0434\u044a\u043b\u0436\u0438\u043d\u0430", "name": "\u0418\u043c\u0435" } } diff --git a/homeassistant/components/motion_blinds/translations/bg.json b/homeassistant/components/motion_blinds/translations/bg.json index e78d7032040..1629a8d981c 100644 --- a/homeassistant/components/motion_blinds/translations/bg.json +++ b/homeassistant/components/motion_blinds/translations/bg.json @@ -4,6 +4,7 @@ "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e", "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, + "flow_title": "{short_mac} ({ip_address})", "step": { "connect": { "data": { diff --git a/homeassistant/components/motioneye/translations/bg.json b/homeassistant/components/motioneye/translations/bg.json index 02c83a6e916..ef56ca3f2df 100644 --- a/homeassistant/components/motioneye/translations/bg.json +++ b/homeassistant/components/motioneye/translations/bg.json @@ -1,8 +1,12 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { + "admin_password": "\u0410\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0441\u043a\u0430 \u043f\u0430\u0440\u043e\u043b\u0430", "url": "URL" } } diff --git a/homeassistant/components/mullvad/translations/bg.json b/homeassistant/components/mullvad/translations/bg.json index 5d274ec2b73..9862b6b3a2a 100644 --- a/homeassistant/components/mullvad/translations/bg.json +++ b/homeassistant/components/mullvad/translations/bg.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" } } diff --git a/homeassistant/components/myq/translations/bg.json b/homeassistant/components/myq/translations/bg.json new file mode 100644 index 00000000000..9c1d3ecccb8 --- /dev/null +++ b/homeassistant/components/myq/translations/bg.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "already_configured": "\u0423\u0441\u043b\u0443\u0433\u0430\u0442\u0430 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mysensors/translations/bg.json b/homeassistant/components/mysensors/translations/bg.json index 69f11b05ce4..7c8e0080bc2 100644 --- a/homeassistant/components/mysensors/translations/bg.json +++ b/homeassistant/components/mysensors/translations/bg.json @@ -1,12 +1,15 @@ { "config": { "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "invalid_port": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043d\u043e\u043c\u0435\u0440 \u043d\u0430 \u043f\u043e\u0440\u0442", "invalid_serial": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u0441\u0435\u0440\u0438\u0435\u043d \u043f\u043e\u0440\u0442", "port_out_of_range": "\u041d\u043e\u043c\u0435\u0440\u044a\u0442 \u043d\u0430 \u043f\u043e\u0440\u0442\u0430 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u0439-\u043c\u0430\u043b\u043a\u043e 1 \u0438 \u043d\u0430\u0439-\u043c\u043d\u043e\u0433\u043e 65535", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "invalid_ip": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d IP \u0430\u0434\u0440\u0435\u0441", "invalid_port": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043d\u043e\u043c\u0435\u0440 \u043d\u0430 \u043f\u043e\u0440\u0442", "invalid_serial": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u0441\u0435\u0440\u0438\u0435\u043d \u043f\u043e\u0440\u0442", "port_out_of_range": "\u041d\u043e\u043c\u0435\u0440\u044a\u0442 \u043d\u0430 \u043f\u043e\u0440\u0442\u0430 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043d\u0430\u0439-\u043c\u0430\u043b\u043a\u043e 1 \u0438 \u043d\u0430\u0439-\u043c\u043d\u043e\u0433\u043e 65535", @@ -20,6 +23,7 @@ }, "gw_tcp": { "data": { + "device": "IP \u0430\u0434\u0440\u0435\u0441 \u043d\u0430 \u0448\u043b\u044e\u0437\u0430", "tcp_port": "\u043f\u043e\u0440\u0442" } } diff --git a/homeassistant/components/nest/translations/nl.json b/homeassistant/components/nest/translations/nl.json index a2f8ba77d78..8f5e0db9900 100644 --- a/homeassistant/components/nest/translations/nl.json +++ b/homeassistant/components/nest/translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Account is al geconfigureerd", "authorize_url_timeout": "Time-out bij het genereren van autorisatie-URL.", "invalid_access_token": "Ongeldig toegangstoken", "missing_configuration": "Integratie niet geconfigureerd. Raadpleeg de documentatie.", diff --git a/homeassistant/components/nexia/translations/bg.json b/homeassistant/components/nexia/translations/bg.json index 78264e2adbd..7aa8fb275ea 100644 --- a/homeassistant/components/nexia/translations/bg.json +++ b/homeassistant/components/nexia/translations/bg.json @@ -1,5 +1,11 @@ { "config": { + "abort": { + "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e" + }, + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/nuheat/translations/bg.json b/homeassistant/components/nuheat/translations/bg.json index 5d274ec2b73..03ace4428b1 100644 --- a/homeassistant/components/nuheat/translations/bg.json +++ b/homeassistant/components/nuheat/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435", "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" } } diff --git a/homeassistant/components/nut/translations/bg.json b/homeassistant/components/nut/translations/bg.json index 09f0ff26e5d..0ea2b4d6cb3 100644 --- a/homeassistant/components/nut/translations/bg.json +++ b/homeassistant/components/nut/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/overkiz/translations/sensor.nl.json b/homeassistant/components/overkiz/translations/sensor.nl.json index aef0b1e0394..8254719cf59 100644 --- a/homeassistant/components/overkiz/translations/sensor.nl.json +++ b/homeassistant/components/overkiz/translations/sensor.nl.json @@ -36,6 +36,11 @@ "overkiz__sensor_room": { "clean": "Schoon", "dirty": "Vuil" + }, + "overkiz__three_way_handle_direction": { + "closed": "Gesloten", + "open": "Open", + "tilt": "Kantelen" } } } \ No newline at end of file diff --git a/homeassistant/components/panasonic_viera/translations/bg.json b/homeassistant/components/panasonic_viera/translations/bg.json index 6433e60193d..2ceff63752c 100644 --- a/homeassistant/components/panasonic_viera/translations/bg.json +++ b/homeassistant/components/panasonic_viera/translations/bg.json @@ -19,7 +19,8 @@ "data": { "host": "IP \u0430\u0434\u0440\u0435\u0441", "name": "\u0418\u043c\u0435" - } + }, + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0432\u0430\u0448\u0438\u044f \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440" } } } diff --git a/homeassistant/components/picnic/translations/bg.json b/homeassistant/components/picnic/translations/bg.json index ffb593eb287..32ea4287182 100644 --- a/homeassistant/components/picnic/translations/bg.json +++ b/homeassistant/components/picnic/translations/bg.json @@ -5,6 +5,13 @@ }, "error": { "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, + "step": { + "user": { + "data": { + "country_code": "\u041a\u043e\u0434 \u043d\u0430 \u0434\u044a\u0440\u0436\u0430\u0432\u0430\u0442\u0430" + } + } } } } \ No newline at end of file diff --git a/homeassistant/components/rfxtrx/translations/bg.json b/homeassistant/components/rfxtrx/translations/bg.json index dec08bcfaa5..21a6c42913d 100644 --- a/homeassistant/components/rfxtrx/translations/bg.json +++ b/homeassistant/components/rfxtrx/translations/bg.json @@ -33,6 +33,7 @@ "step": { "prompt_options": { "data": { + "automatic_add": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e\u0442\u043e \u0434\u043e\u0431\u0430\u0432\u044f\u043d\u0435", "protocols": "\u041f\u0440\u043e\u0442\u043e\u043a\u043e\u043b\u0438" } }, diff --git a/homeassistant/components/risco/translations/bg.json b/homeassistant/components/risco/translations/bg.json index b9092f75d6c..805d72102aa 100644 --- a/homeassistant/components/risco/translations/bg.json +++ b/homeassistant/components/risco/translations/bg.json @@ -17,5 +17,14 @@ } } } + }, + "options": { + "step": { + "risco_to_ha": { + "data": { + "A": "\u0413\u0440\u0443\u043f\u0430 \u0410" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/rituals_perfume_genie/translations/bg.json b/homeassistant/components/rituals_perfume_genie/translations/bg.json index cef3726d759..05ef3ed780e 100644 --- a/homeassistant/components/rituals_perfume_genie/translations/bg.json +++ b/homeassistant/components/rituals_perfume_genie/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/roomba/translations/bg.json b/homeassistant/components/roomba/translations/bg.json index 3da613d9394..948c4afd258 100644 --- a/homeassistant/components/roomba/translations/bg.json +++ b/homeassistant/components/roomba/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "error": { "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0445 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" }, diff --git a/homeassistant/components/ruckus_unleashed/translations/bg.json b/homeassistant/components/ruckus_unleashed/translations/bg.json index ffb69776060..dcdcdcfc186 100644 --- a/homeassistant/components/ruckus_unleashed/translations/bg.json +++ b/homeassistant/components/ruckus_unleashed/translations/bg.json @@ -1,5 +1,9 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/screenlogic/translations/bg.json b/homeassistant/components/screenlogic/translations/bg.json index 1c611d756fd..b8fccb94a47 100644 --- a/homeassistant/components/screenlogic/translations/bg.json +++ b/homeassistant/components/screenlogic/translations/bg.json @@ -1,5 +1,6 @@ { "config": { + "flow_title": "{name}", "step": { "gateway_entry": { "data": { diff --git a/homeassistant/components/sense/translations/bg.json b/homeassistant/components/sense/translations/bg.json index d42d6dba5c1..2be0802eef9 100644 --- a/homeassistant/components/sense/translations/bg.json +++ b/homeassistant/components/sense/translations/bg.json @@ -3,6 +3,9 @@ "abort": { "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0431\u0435\u0448\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "reauth_validate": { "data": { diff --git a/homeassistant/components/simplepush/translations/nl.json b/homeassistant/components/simplepush/translations/nl.json new file mode 100644 index 00000000000..900bac61bc5 --- /dev/null +++ b/homeassistant/components/simplepush/translations/nl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Naam" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sma/translations/bg.json b/homeassistant/components/sma/translations/bg.json new file mode 100644 index 00000000000..cef3726d759 --- /dev/null +++ b/homeassistant/components/sma/translations/bg.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smappee/translations/bg.json b/homeassistant/components/smappee/translations/bg.json index 9173bdc0bc7..7b8f03499a6 100644 --- a/homeassistant/components/smappee/translations/bg.json +++ b/homeassistant/components/smappee/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "missing_configuration": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u044a\u0442 \u043d\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d. \u041c\u043e\u043b\u044f, \u0441\u043b\u0435\u0434\u0432\u0430\u0439\u0442\u0435 \u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430." }, "flow_title": "{name}", diff --git a/homeassistant/components/smarttub/translations/bg.json b/homeassistant/components/smarttub/translations/bg.json index cef3726d759..ebfcda2158d 100644 --- a/homeassistant/components/smarttub/translations/bg.json +++ b/homeassistant/components/smarttub/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "invalid_auth": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/somfy_mylink/translations/bg.json b/homeassistant/components/somfy_mylink/translations/bg.json index 4983c9a14b2..ca0ed419f99 100644 --- a/homeassistant/components/somfy_mylink/translations/bg.json +++ b/homeassistant/components/somfy_mylink/translations/bg.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/subaru/translations/bg.json b/homeassistant/components/subaru/translations/bg.json index a3c6d55e3e9..9031d8b47ce 100644 --- a/homeassistant/components/subaru/translations/bg.json +++ b/homeassistant/components/subaru/translations/bg.json @@ -1,6 +1,10 @@ { "config": { + "abort": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435" + }, "error": { + "bad_pin_format": "\u041f\u0418\u041d \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 4 \u0446\u0438\u0444\u0440\u0438", "incorrect_validation_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d \u043a\u043e\u0434 \u0437\u0430 \u0432\u0430\u043b\u0438\u0434\u0438\u0440\u0430\u043d\u0435" }, "step": { diff --git a/homeassistant/components/syncthru/translations/bg.json b/homeassistant/components/syncthru/translations/bg.json index bd8d6d4bf88..f957aba1fe6 100644 --- a/homeassistant/components/syncthru/translations/bg.json +++ b/homeassistant/components/syncthru/translations/bg.json @@ -10,7 +10,8 @@ "step": { "confirm": { "data": { - "name": "\u0418\u043c\u0435" + "name": "\u0418\u043c\u0435", + "url": "URL \u043d\u0430 \u0443\u0435\u0431 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430" } }, "user": { diff --git a/homeassistant/components/transmission/translations/nl.json b/homeassistant/components/transmission/translations/nl.json index fcc1e05e7ab..e80cda5ac8e 100644 --- a/homeassistant/components/transmission/translations/nl.json +++ b/homeassistant/components/transmission/translations/nl.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Apparaat is al geconfigureerd" + "already_configured": "Apparaat is al geconfigureerd", + "reauth_successful": "Herauthenticatie geslaagd" }, "error": { "cannot_connect": "Kan geen verbinding maken", @@ -9,6 +10,13 @@ "name_exists": "Naam bestaat al" }, "step": { + "reauth_confirm": { + "data": { + "password": "Wachtwoord" + }, + "description": "Het wachtwoord voor {username} is onjuist.", + "title": "Integratie herauthenticeren" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/twentemilieu/translations/bg.json b/homeassistant/components/twentemilieu/translations/bg.json index 6ddc25a367e..7aa70f6d7f5 100644 --- a/homeassistant/components/twentemilieu/translations/bg.json +++ b/homeassistant/components/twentemilieu/translations/bg.json @@ -1,6 +1,7 @@ { "config": { "error": { + "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435", "invalid_address": "\u0410\u0434\u0440\u0435\u0441\u044a\u0442 \u043d\u0435 \u0435 \u043d\u0430\u043c\u0435\u0440\u0435\u043d \u0432 \u0437\u043e\u043d\u0430 \u0437\u0430 \u043e\u0431\u0441\u043b\u0443\u0436\u0432\u0430\u043d\u0435 \u043d\u0430 Twente Milieu." }, "step": { diff --git a/homeassistant/components/vacuum/translations/no.json b/homeassistant/components/vacuum/translations/no.json index 3d722c0927c..c467018b249 100644 --- a/homeassistant/components/vacuum/translations/no.json +++ b/homeassistant/components/vacuum/translations/no.json @@ -15,7 +15,7 @@ }, "state": { "_": { - "cleaning": "Rengj\u00f8ring", + "cleaning": "Rengj\u00f8r", "docked": "Dokket", "error": "Feil", "idle": "Inaktiv", diff --git a/homeassistant/components/verisure/translations/bg.json b/homeassistant/components/verisure/translations/bg.json new file mode 100644 index 00000000000..0f10e122185 --- /dev/null +++ b/homeassistant/components/verisure/translations/bg.json @@ -0,0 +1,10 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0430\u0443\u043d\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d" + }, + "error": { + "unknown": "\u041d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vulcan/translations/bg.json b/homeassistant/components/vulcan/translations/bg.json index 187ad8cff4b..f99cd3cca14 100644 --- a/homeassistant/components/vulcan/translations/bg.json +++ b/homeassistant/components/vulcan/translations/bg.json @@ -4,7 +4,8 @@ "reauth_successful": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0442\u043e \u0443\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e" }, "error": { - "cannot_connect": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e - \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0432\u0440\u044a\u0437\u043a\u0430" + "cannot_connect": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e - \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0432\u0440\u044a\u0437\u043a\u0430", + "unknown": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430" }, "step": { "select_saved_credentials": { diff --git a/homeassistant/components/wolflink/translations/sensor.bg.json b/homeassistant/components/wolflink/translations/sensor.bg.json index 4a402cfe75b..8d0335dcc31 100644 --- a/homeassistant/components/wolflink/translations/sensor.bg.json +++ b/homeassistant/components/wolflink/translations/sensor.bg.json @@ -3,6 +3,9 @@ "wolflink__state": { "1_x_warmwasser": "1 x DHW", "auto": "\u0410\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u043d", + "dhw_prior": "DHWPrior", + "gasdruck": "\u041d\u0430\u043b\u044f\u0433\u0430\u043d\u0435 \u043d\u0430 \u0433\u0430\u0437\u0430", + "kalibration": "\u041a\u0430\u043b\u0438\u0431\u0440\u0438\u0440\u0430\u043d\u0435", "test": "\u0422\u0435\u0441\u0442", "tpw": "TPW", "urlaubsmodus": "\u0412\u0430\u043a\u0430\u043d\u0446\u0438\u043e\u043d\u0435\u043d \u0440\u0435\u0436\u0438\u043c", From ee6866b8a31e4ab94b29a1afb2fca4406d39a79a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jun 2022 19:48:58 -0500 Subject: [PATCH 807/947] Bump nexia to 2.0.1 (#74148) --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index 1cb410ad1a8..cc5e6de8641 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia/American Standard/Trane", - "requirements": ["nexia==2.0.0"], + "requirements": ["nexia==2.0.1"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 04c48d20c70..104e01db741 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1086,7 +1086,7 @@ nettigo-air-monitor==1.3.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==2.0.0 +nexia==2.0.1 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8520fb665fe..c9675da97f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -751,7 +751,7 @@ netmap==0.7.0.2 nettigo-air-monitor==1.3.0 # homeassistant.components.nexia -nexia==2.0.0 +nexia==2.0.1 # homeassistant.components.discord nextcord==2.0.0a8 From 629c68221e1756635e202242fffe51d77dd2c61a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jun 2022 19:54:27 -0500 Subject: [PATCH 808/947] Avoid retriggering HomeKit doorbells on forced updates (#74141) --- .../components/homekit/type_cameras.py | 14 +++-- homeassistant/components/homekit/util.py | 10 +++- tests/components/homekit/test_type_cameras.py | 55 ++++++++++++++++++- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 3a09e3a48ff..e612c8248be 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -14,7 +14,7 @@ from pyhap.const import CATEGORY_CAMERA from homeassistant.components import camera from homeassistant.components.ffmpeg import get_ffmpeg_manager from homeassistant.const import STATE_ON -from homeassistant.core import callback +from homeassistant.core import Event, callback from homeassistant.helpers.event import ( async_track_state_change_event, async_track_time_interval, @@ -56,7 +56,7 @@ from .const import ( SERV_SPEAKER, SERV_STATELESS_PROGRAMMABLE_SWITCH, ) -from .util import pid_is_alive +from .util import pid_is_alive, state_changed_event_is_same_state _LOGGER = logging.getLogger(__name__) @@ -265,9 +265,10 @@ class Camera(HomeAccessory, PyhapCamera): await super().run() @callback - def _async_update_motion_state_event(self, event): + def _async_update_motion_state_event(self, event: Event) -> None: """Handle state change event listener callback.""" - self._async_update_motion_state(event.data.get("new_state")) + if not state_changed_event_is_same_state(event): + self._async_update_motion_state(event.data.get("new_state")) @callback def _async_update_motion_state(self, new_state): @@ -288,9 +289,10 @@ class Camera(HomeAccessory, PyhapCamera): ) @callback - def _async_update_doorbell_state_event(self, event): + def _async_update_doorbell_state_event(self, event: Event) -> None: """Handle state change event listener callback.""" - self._async_update_doorbell_state(event.data.get("new_state")) + if not state_changed_event_is_same_state(event): + self._async_update_doorbell_state(event.data.get("new_state")) @callback def _async_update_doorbell_state(self, new_state): diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 8b010f85fb6..34df1008e76 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -37,7 +37,7 @@ from homeassistant.const import ( CONF_TYPE, TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import Event, HomeAssistant, State, callback, split_entity_id import homeassistant.helpers.config_validation as cv from homeassistant.helpers.storage import STORAGE_DIR import homeassistant.util.temperature as temp_util @@ -572,3 +572,11 @@ def state_needs_accessory_mode(state: State) -> bool: and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & RemoteEntityFeature.ACTIVITY ) + + +def state_changed_event_is_same_state(event: Event) -> bool: + """Check if a state changed event is the same state.""" + event_data = event.data + old_state: State | None = event_data.get("old_state") + new_state: State | None = event_data.get("new_state") + return bool(new_state and old_state and new_state.state == old_state.state) diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9a8b284b97e..83afcedd839 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -642,11 +642,15 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): assert char assert char.value is True + broker = MagicMock() + char.broker = broker hass.states.async_set( motion_entity_id, STATE_OFF, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() + assert len(broker.mock_calls) == 2 + broker.reset_mock() assert char.value is False char.set_value(True) @@ -654,8 +658,28 @@ async def test_camera_with_linked_motion_sensor(hass, run_driver, events): motion_entity_id, STATE_ON, {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION} ) await hass.async_block_till_done() + assert len(broker.mock_calls) == 2 + broker.reset_mock() assert char.value is True + hass.states.async_set( + motion_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION}, + force_update=True, + ) + await hass.async_block_till_done() + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + hass.states.async_set( + motion_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.MOTION, "other": "attr"}, + ) + await hass.async_block_till_done() + assert len(broker.mock_calls) == 0 + broker.reset_mock() # Ensure we do not throw when the linked # motion sensor is removed hass.states.async_remove(motion_entity_id) @@ -747,7 +771,8 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): assert service2 char2 = service.get_characteristic(CHAR_PROGRAMMABLE_SWITCH_EVENT) assert char2 - + broker = MagicMock() + char2.broker = broker assert char2.value is None hass.states.async_set( @@ -758,9 +783,12 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): await hass.async_block_till_done() assert char.value is None assert char2.value is None + assert len(broker.mock_calls) == 0 char.set_value(True) char2.set_value(True) + broker.reset_mock() + hass.states.async_set( doorbell_entity_id, STATE_ON, @@ -769,6 +797,31 @@ async def test_camera_with_linked_doorbell_sensor(hass, run_driver, events): await hass.async_block_till_done() assert char.value is None assert char2.value is None + assert len(broker.mock_calls) == 2 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY}, + force_update=True, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() + + hass.states.async_set( + doorbell_entity_id, + STATE_ON, + {ATTR_DEVICE_CLASS: BinarySensorDeviceClass.OCCUPANCY, "other": "attr"}, + ) + await hass.async_block_till_done() + assert char.value is None + assert char2.value is None + assert len(broker.mock_calls) == 0 + broker.reset_mock() # Ensure we do not throw when the linked # doorbell sensor is removed From 309cf030b0efb5248c257670f88442b548b99711 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 28 Jun 2022 19:57:17 -0500 Subject: [PATCH 809/947] Fix typo in enphase doc string (#74155) --- homeassistant/components/enphase_envoy/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py index 61bf9b64bcf..0c6c893df64 100644 --- a/homeassistant/components/enphase_envoy/__init__.py +++ b/homeassistant/components/enphase_envoy/__init__.py @@ -102,7 +102,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_remove_config_entry_device( hass: HomeAssistant, config_entry: ConfigEntry, device_entry: dr.DeviceEntry ) -> bool: - """Remove a enphase_envoy config entry from a device.""" + """Remove an enphase_envoy config entry from a device.""" dev_ids = {dev_id[1] for dev_id in device_entry.identifiers if dev_id[0] == DOMAIN} data: dict = hass.data[DOMAIN][config_entry.entry_id] coordinator: DataUpdateCoordinator = data[COORDINATOR] From 54320ff1340a15bdb4bc6dc9f7c234b6a0252c82 Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Tue, 28 Jun 2022 23:00:26 -0400 Subject: [PATCH 810/947] UniFi Protect bugfixes (#74156) --- .../components/unifiprotect/binary_sensor.py | 6 ++-- .../components/unifiprotect/button.py | 4 ++- .../components/unifiprotect/camera.py | 8 +++-- homeassistant/components/unifiprotect/data.py | 34 +++++++++---------- .../components/unifiprotect/entity.py | 2 +- .../components/unifiprotect/light.py | 4 ++- homeassistant/components/unifiprotect/lock.py | 4 ++- .../components/unifiprotect/media_player.py | 4 ++- .../components/unifiprotect/number.py | 4 ++- .../components/unifiprotect/select.py | 4 ++- .../components/unifiprotect/sensor.py | 4 ++- .../components/unifiprotect/switch.py | 4 ++- tests/components/unifiprotect/test_init.py | 5 ++- 13 files changed, 54 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 598e0632fbb..d3bf71a4274 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -271,7 +271,7 @@ SENSE_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( - key="motion", + key="motion_enabled", name="Motion Detection", icon="mdi:walk", entity_category=EntityCategory.DIAGNOSTIC, @@ -383,7 +383,9 @@ async def async_setup_entry( entities += _async_motion_entities(data, ufp_device=device) async_add_entities(entities) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities: list[ProtectDeviceEntity] = async_all_device_entities( data, diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 901139109d3..9440e46b936 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -92,7 +92,9 @@ async def async_setup_entry( ) async_add_entities(entities) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities: list[ProtectDeviceEntity] = async_all_device_entities( data, diff --git a/homeassistant/components/unifiprotect/camera.py b/homeassistant/components/unifiprotect/camera.py index 336a5ae9187..76bfc72408d 100644 --- a/homeassistant/components/unifiprotect/camera.py +++ b/homeassistant/components/unifiprotect/camera.py @@ -120,8 +120,12 @@ async def async_setup_entry( entities = _async_camera_entities(data, ufp_device=device) async_add_entities(entities) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_CHANNELS), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_CHANNELS), _add_new_device) + ) entities = _async_camera_entities(data) async_add_entities(entities) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 30887f04235..9e0783a99b1 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -4,17 +4,17 @@ from __future__ import annotations from collections.abc import Callable, Generator, Iterable from datetime import timedelta import logging -from typing import Any +from typing import Any, Union from pyunifiprotect import ProtectApiClient from pyunifiprotect.data import ( + NVR, Bootstrap, Event, EventType, Liveview, ModelType, ProtectAdoptableDeviceModel, - ProtectModelWithId, WSSubscriptionMessage, ) from pyunifiprotect.exceptions import ClientError, NotAuthorized @@ -27,7 +27,6 @@ from homeassistant.helpers.event import async_track_time_interval from .const import ( CONF_DISABLE_RTSP, DEVICES_THAT_ADOPT, - DEVICES_WITH_ENTITIES, DISPATCH_ADOPT, DISPATCH_CHANNELS, DOMAIN, @@ -39,6 +38,7 @@ from .utils import ( ) _LOGGER = logging.getLogger(__name__) +ProtectDeviceType = Union[ProtectAdoptableDeviceModel, NVR] @callback @@ -68,7 +68,7 @@ class ProtectData: self._entry = entry self._hass = hass self._update_interval = update_interval - self._subscriptions: dict[str, list[Callable[[ProtectModelWithId], None]]] = {} + self._subscriptions: dict[str, list[Callable[[ProtectDeviceType], None]]] = {} self._pending_camera_ids: set[str] = set() self._unsub_interval: CALLBACK_TYPE | None = None self._unsub_websocket: CALLBACK_TYPE | None = None @@ -152,7 +152,7 @@ class ProtectData: return obj = message.new_obj - if obj.model in DEVICES_WITH_ENTITIES: + if isinstance(obj, (ProtectAdoptableDeviceModel, NVR)): self._async_signal_device_update(obj) if ( obj.model == ModelType.CAMERA @@ -211,41 +211,41 @@ class ProtectData: @callback def async_subscribe_device_id( - self, device_id: str, update_callback: Callable[[ProtectModelWithId], None] + self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> CALLBACK_TYPE: """Add an callback subscriber.""" if not self._subscriptions: self._unsub_interval = async_track_time_interval( self._hass, self.async_refresh, self._update_interval ) - self._subscriptions.setdefault(device_id, []).append(update_callback) + self._subscriptions.setdefault(mac, []).append(update_callback) def _unsubscribe() -> None: - self.async_unsubscribe_device_id(device_id, update_callback) + self.async_unsubscribe_device_id(mac, update_callback) return _unsubscribe @callback def async_unsubscribe_device_id( - self, device_id: str, update_callback: Callable[[ProtectModelWithId], None] + self, mac: str, update_callback: Callable[[ProtectDeviceType], None] ) -> None: """Remove a callback subscriber.""" - self._subscriptions[device_id].remove(update_callback) - if not self._subscriptions[device_id]: - del self._subscriptions[device_id] + self._subscriptions[mac].remove(update_callback) + if not self._subscriptions[mac]: + del self._subscriptions[mac] if not self._subscriptions and self._unsub_interval: self._unsub_interval() self._unsub_interval = None @callback - def _async_signal_device_update(self, device: ProtectModelWithId) -> None: + def _async_signal_device_update(self, device: ProtectDeviceType) -> None: """Call the callbacks for a device_id.""" - device_id = device.id - if not self._subscriptions.get(device_id): + + if not self._subscriptions.get(device.mac): return - _LOGGER.debug("Updating device: %s", device_id) - for update_callback in self._subscriptions[device_id]: + _LOGGER.debug("Updating device: %s (%s)", device.name, device.mac) + for update_callback in self._subscriptions[device.mac]: update_callback(device) diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index b7419d0a41e..e68e5cfb81d 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -215,7 +215,7 @@ class ProtectDeviceEntity(Entity): await super().async_added_to_hass() self.async_on_remove( self.data.async_subscribe_device_id( - self.device.id, self._async_updated_event + self.device.mac, self._async_updated_event ) ) diff --git a/homeassistant/components/unifiprotect/light.py b/homeassistant/components/unifiprotect/light.py index fdfe41bca3c..588b99b38d7 100644 --- a/homeassistant/components/unifiprotect/light.py +++ b/homeassistant/components/unifiprotect/light.py @@ -42,7 +42,9 @@ async def async_setup_entry( ): async_add_entities([ProtectLight(data, device)]) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities = [] for device in data.api.bootstrap.lights.values(): diff --git a/homeassistant/components/unifiprotect/lock.py b/homeassistant/components/unifiprotect/lock.py index 400d463050e..0a203308d1e 100644 --- a/homeassistant/components/unifiprotect/lock.py +++ b/homeassistant/components/unifiprotect/lock.py @@ -40,7 +40,9 @@ async def async_setup_entry( if isinstance(device, Doorlock): async_add_entities([ProtectLock(data, device)]) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities = [] for device in data.api.bootstrap.doorlocks.values(): diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index 41109c053f6..d4046e4b8b7 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -49,7 +49,9 @@ async def async_setup_entry( if isinstance(device, Camera) and device.feature_flags.has_speaker: async_add_entities([ProtectMediaPlayer(data, device)]) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities = [] for device in data.api.bootstrap.cameras.values(): diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index a017d2330b6..ed9faf4da40 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -206,7 +206,9 @@ async def async_setup_entry( ) async_add_entities(entities) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities: list[ProtectDeviceEntity] = async_all_device_entities( data, diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 5ea956ca603..e398e6692b0 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -336,7 +336,9 @@ async def async_setup_entry( ) async_add_entities(entities) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities: list[ProtectDeviceEntity] = async_all_device_entities( data, diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index a46e4c790b7..7a9f4652a2e 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -613,7 +613,9 @@ async def async_setup_entry( entities += _async_motion_entities(data, ufp_device=device) async_add_entities(entities) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities: list[ProtectDeviceEntity] = async_all_device_entities( data, diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index 71812459b95..5bc4e1f17eb 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -318,7 +318,9 @@ async def async_setup_entry( ) async_add_entities(entities) - async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + entry.async_on_unload( + async_dispatcher_connect(hass, _ufpd(entry, DISPATCH_ADOPT), _add_new_device) + ) entities: list[ProtectDeviceEntity] = async_all_device_entities( data, diff --git a/tests/components/unifiprotect/test_init.py b/tests/components/unifiprotect/test_init.py index c0ad30ad115..f6f0645df18 100644 --- a/tests/components/unifiprotect/test_init.py +++ b/tests/components/unifiprotect/test_init.py @@ -109,11 +109,10 @@ async def test_reload(hass: HomeAssistant, ufp: MockUFPFixture): assert ufp.api.async_disconnect_ws.called -async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture): +async def test_unload(hass: HomeAssistant, ufp: MockUFPFixture, light: Light): """Test unloading of unifiprotect entry.""" - await hass.config_entries.async_setup(ufp.entry.entry_id) - await hass.async_block_till_done() + await init_entry(hass, ufp, [light]) assert ufp.entry.state == ConfigEntryState.LOADED await hass.config_entries.async_unload(ufp.entry.entry_id) From 9b60b0c23f2b05377b9a44c9ca5e187407c0ab09 Mon Sep 17 00:00:00 2001 From: mletenay Date: Wed, 29 Jun 2022 06:09:24 +0200 Subject: [PATCH 811/947] Keep sum energy sensors always available (#69218) --- homeassistant/components/goodwe/sensor.py | 30 +++++++++++++++++------ 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/goodwe/sensor.py b/homeassistant/components/goodwe/sensor.py index 96a8ef49af3..6dcdc6e8cb1 100644 --- a/homeassistant/components/goodwe/sensor.py +++ b/homeassistant/components/goodwe/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from typing import Any +from typing import Any, cast from goodwe import Inverter, Sensor, SensorKind @@ -49,7 +49,7 @@ _MAIN_SENSORS = ( "e_bat_discharge_total", ) -_ICONS = { +_ICONS: dict[SensorKind, str] = { SensorKind.PV: "mdi:solar-power", SensorKind.AC: "mdi:power-plug-outline", SensorKind.UPS: "mdi:power-plug-off-outline", @@ -62,10 +62,13 @@ _ICONS = { class GoodweSensorEntityDescription(SensorEntityDescription): """Class describing Goodwe sensor entities.""" - value: Callable[[str, Any, Any], Any] = lambda sensor, prev, val: val + value: Callable[[Any, Any], Any] = lambda prev, val: val + available: Callable[ + [CoordinatorEntity], bool + ] = lambda entity: entity.coordinator.last_update_success -_DESCRIPTIONS = { +_DESCRIPTIONS: dict[str, GoodweSensorEntityDescription] = { "A": GoodweSensorEntityDescription( key="A", device_class=SensorDeviceClass.CURRENT, @@ -89,7 +92,8 @@ _DESCRIPTIONS = { device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, - value=lambda sensor, prev, val: prev if "total" in sensor and not val else val, + value=lambda prev, val: prev if not val else val, + available=lambda entity: entity.coordinator.data is not None, ), "C": GoodweSensorEntityDescription( key="C", @@ -167,10 +171,22 @@ class InverterSensor(CoordinatorEntity, SensorEntity): @property def native_value(self): """Return the value reported by the sensor.""" - value = self.entity_description.value( - self._sensor.id_, + value = cast(GoodweSensorEntityDescription, self.entity_description).value( self._previous_value, self.coordinator.data.get(self._sensor.id_, self._previous_value), ) self._previous_value = value return value + + @property + def available(self) -> bool: + """Return if entity is available. + + We delegate the behavior to entity description lambda, since + some sensors (like energy produced today) should report themselves + as available even when the (non-battery) pv inverter is off-line during night + and most of the sensors are actually unavailable. + """ + return cast(GoodweSensorEntityDescription, self.entity_description).available( + self + ) From 305dff0dc1d053f5e954cbda9067d60aa8bc8a55 Mon Sep 17 00:00:00 2001 From: stegm Date: Wed, 29 Jun 2022 06:29:21 +0200 Subject: [PATCH 812/947] Add number platform for kostal_plenticore (#64927) --- .../components/kostal_plenticore/__init__.py | 2 +- .../components/kostal_plenticore/const.py | 74 ++++--- .../components/kostal_plenticore/helper.py | 26 ++- .../components/kostal_plenticore/number.py | 157 ++++++++++++++ .../components/kostal_plenticore/sensor.py | 45 +--- .../kostal_plenticore/test_number.py | 197 ++++++++++++++++++ 6 files changed, 430 insertions(+), 71 deletions(-) create mode 100644 homeassistant/components/kostal_plenticore/number.py create mode 100644 tests/components/kostal_plenticore/test_number.py diff --git a/homeassistant/components/kostal_plenticore/__init__.py b/homeassistant/components/kostal_plenticore/__init__.py index a42ad0a64ff..b431960caef 100644 --- a/homeassistant/components/kostal_plenticore/__init__.py +++ b/homeassistant/components/kostal_plenticore/__init__.py @@ -12,7 +12,7 @@ from .helper import Plenticore _LOGGER = logging.getLogger(__name__) -PLATFORMS = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [Platform.SELECT, Platform.SENSOR, Platform.SWITCH, Platform.NUMBER] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index ac2ecb44fd5..ba850ed58bd 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -1,6 +1,8 @@ """Constants for the Kostal Plenticore Solar Inverter integration.""" +from dataclasses import dataclass from typing import NamedTuple +from homeassistant.components.number import NumberEntityDescription from homeassistant.components.sensor import ( ATTR_STATE_CLASS, SensorDeviceClass, @@ -16,6 +18,7 @@ from homeassistant.const import ( PERCENTAGE, POWER_WATT, ) +from homeassistant.helpers.entity import EntityCategory DOMAIN = "kostal_plenticore" @@ -790,31 +793,54 @@ SENSOR_PROCESS_DATA = [ ), ] -# Defines all entities for settings. -# -# Each entry is defined with a tuple of these values: -# - module id (str) -# - process data id (str) -# - entity name suffix (str) -# - sensor properties (dict) -# - value formatter (str) -SENSOR_SETTINGS_DATA = [ - ( - "devices:local", - "Battery:MinHomeComsumption", - "Battery min Home Consumption", - { - ATTR_UNIT_OF_MEASUREMENT: POWER_WATT, - ATTR_DEVICE_CLASS: SensorDeviceClass.POWER, - }, - "format_round", + +@dataclass +class PlenticoreNumberEntityDescriptionMixin: + """Define an entity description mixin for number entities.""" + + module_id: str + data_id: str + fmt_from: str + fmt_to: str + + +@dataclass +class PlenticoreNumberEntityDescription( + NumberEntityDescription, PlenticoreNumberEntityDescriptionMixin +): + """Describes a Plenticore number entity.""" + + +NUMBER_SETTINGS_DATA = [ + PlenticoreNumberEntityDescription( + key="battery_min_soc", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + icon="mdi:battery-negative", + name="Battery min SoC", + unit_of_measurement=PERCENTAGE, + max_value=100, + min_value=5, + step=5, + module_id="devices:local", + data_id="Battery:MinSoc", + fmt_from="format_round", + fmt_to="format_round_back", ), - ( - "devices:local", - "Battery:MinSoc", - "Battery min Soc", - {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, ATTR_ICON: "mdi:battery-negative"}, - "format_round", + PlenticoreNumberEntityDescription( + key="battery_min_home_consumption", + device_class=SensorDeviceClass.POWER, + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + name="Battery min Home Consumption", + unit_of_measurement=POWER_WATT, + max_value=38000, + min_value=50, + step=1, + module_id="devices:local", + data_id="Battery:MinHomeComsumption", + fmt_from="format_round", + fmt_to="format_round_back", ), ] diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index e047c0dafba..c87d96161a4 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -3,9 +3,10 @@ from __future__ import annotations import asyncio from collections import defaultdict -from collections.abc import Iterable +from collections.abc import Callable, Iterable from datetime import datetime, timedelta import logging +from typing import Any from aiohttp.client_exceptions import ClientError from kostal.plenticore import ( @@ -122,7 +123,7 @@ class DataUpdateCoordinatorMixin: """Base implementation for read and write data.""" async def async_read_data(self, module_id: str, data_id: str) -> list[str, bool]: - """Write settings back to Plenticore.""" + """Read data from Plenticore.""" if (client := self._plenticore.client) is None: return False @@ -138,6 +139,10 @@ class DataUpdateCoordinatorMixin: if (client := self._plenticore.client) is None: return False + _LOGGER.debug( + "Setting value for %s in module %s to %s", self.name, module_id, value + ) + try: await client.set_setting_values(module_id, value) except PlenticoreApiException: @@ -328,7 +333,7 @@ class PlenticoreDataFormatter: } @classmethod - def get_method(cls, name: str) -> callable: + def get_method(cls, name: str) -> Callable[[Any], Any]: """Return a callable formatter of the given name.""" return getattr(cls, name) @@ -340,6 +345,21 @@ class PlenticoreDataFormatter: except (TypeError, ValueError): return state + @staticmethod + def format_round_back(value: float) -> str: + """Return a rounded integer value from a float.""" + try: + if isinstance(value, float) and value.is_integer(): + int_value = int(value) + elif isinstance(value, int): + int_value = value + else: + int_value = round(value) + + return str(int_value) + except (TypeError, ValueError): + return "" + @staticmethod def format_float(state: str) -> int | str: """Return the given state value as float rounded to three decimal places.""" diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py new file mode 100644 index 00000000000..33d431d6396 --- /dev/null +++ b/homeassistant/components/kostal_plenticore/number.py @@ -0,0 +1,157 @@ +"""Platform for Kostal Plenticore numbers.""" +from __future__ import annotations + +from abc import ABC +from datetime import timedelta +from functools import partial +import logging + +from kostal.plenticore import SettingsData + +from homeassistant.components.number import NumberEntity, NumberMode +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, NUMBER_SETTINGS_DATA, PlenticoreNumberEntityDescription +from .helper import PlenticoreDataFormatter, SettingDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Kostal Plenticore Number entities.""" + plenticore = hass.data[DOMAIN][entry.entry_id] + + entities = [] + + available_settings_data = await plenticore.client.get_settings() + settings_data_update_coordinator = SettingDataUpdateCoordinator( + hass, + _LOGGER, + "Settings Data", + timedelta(seconds=30), + plenticore, + ) + + for description in NUMBER_SETTINGS_DATA: + if ( + description.module_id not in available_settings_data + or description.data_id + not in ( + setting.id for setting in available_settings_data[description.module_id] + ) + ): + _LOGGER.debug( + "Skipping non existing setting data %s/%s", + description.module_id, + description.data_id, + ) + continue + + setting_data = next( + filter( + partial(lambda id, sd: id == sd.id, description.data_id), + available_settings_data[description.module_id], + ) + ) + + entities.append( + PlenticoreDataNumber( + settings_data_update_coordinator, + entry.entry_id, + entry.title, + plenticore.device_info, + description, + setting_data, + ) + ) + + async_add_entities(entities) + + +class PlenticoreDataNumber(CoordinatorEntity, NumberEntity, ABC): + """Representation of a Kostal Plenticore Number entity.""" + + entity_description: PlenticoreNumberEntityDescription + coordinator: SettingDataUpdateCoordinator + + def __init__( + self, + coordinator: SettingDataUpdateCoordinator, + entry_id: str, + platform_name: str, + device_info: DeviceInfo, + description: PlenticoreNumberEntityDescription, + setting_data: SettingsData, + ) -> None: + """Initialize the Plenticore Number entity.""" + super().__init__(coordinator) + + self.entity_description = description + self.entry_id = entry_id + + self._attr_device_info = device_info + self._attr_unique_id = f"{self.entry_id}_{self.module_id}_{self.data_id}" + self._attr_name = f"{platform_name} {description.name}" + self._attr_mode = NumberMode.BOX + + self._formatter = PlenticoreDataFormatter.get_method(description.fmt_from) + self._formatter_back = PlenticoreDataFormatter.get_method(description.fmt_to) + + # overwrite from retrieved setting data + if setting_data.min is not None: + self._attr_min_value = self._formatter(setting_data.min) + if setting_data.max is not None: + self._attr_max_value = self._formatter(setting_data.max) + + @property + def module_id(self) -> str: + """Return the plenticore module id of this entity.""" + return self.entity_description.module_id + + @property + def data_id(self) -> str: + """Return the plenticore data id for this entity.""" + return self.entity_description.data_id + + @property + def available(self) -> bool: + """Return if entity is available.""" + return ( + super().available + and self.coordinator.data is not None + and self.module_id in self.coordinator.data + and self.data_id in self.coordinator.data[self.module_id] + ) + + async def async_added_to_hass(self) -> None: + """Register this entity on the Update Coordinator.""" + await super().async_added_to_hass() + self.coordinator.start_fetch_data(self.module_id, self.data_id) + + async def async_will_remove_from_hass(self) -> None: + """Unregister this entity from the Update Coordinator.""" + self.coordinator.stop_fetch_data(self.module_id, self.data_id) + await super().async_will_remove_from_hass() + + @property + def value(self) -> float | None: + """Return the current value.""" + if self.available: + raw_value = self.coordinator.data[self.module_id][self.data_id] + return self._formatter(raw_value) + + return None + + async def async_set_value(self, value: float) -> None: + """Set a new value.""" + str_value = self._formatter_back(value) + await self.coordinator.async_write_data( + self.module_id, {self.data_id: str_value} + ) + await self.coordinator.async_refresh() diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 0b6b01aca71..5f8fb47e85a 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -14,17 +14,8 @@ from homeassistant.helpers.entity import DeviceInfo, EntityCategory from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_ENABLED_DEFAULT, - DOMAIN, - SENSOR_PROCESS_DATA, - SENSOR_SETTINGS_DATA, -) -from .helper import ( - PlenticoreDataFormatter, - ProcessDataUpdateCoordinator, - SettingDataUpdateCoordinator, -) +from .const import ATTR_ENABLED_DEFAULT, DOMAIN, SENSOR_PROCESS_DATA +from .helper import PlenticoreDataFormatter, ProcessDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -70,38 +61,6 @@ async def async_setup_entry( ) ) - available_settings_data = await plenticore.client.get_settings() - settings_data_update_coordinator = SettingDataUpdateCoordinator( - hass, - _LOGGER, - "Settings Data", - timedelta(seconds=300), - plenticore, - ) - for module_id, data_id, name, sensor_data, fmt in SENSOR_SETTINGS_DATA: - if module_id not in available_settings_data or data_id not in ( - setting.id for setting in available_settings_data[module_id] - ): - _LOGGER.debug( - "Skipping non existing setting data %s/%s", module_id, data_id - ) - continue - - entities.append( - PlenticoreDataSensor( - settings_data_update_coordinator, - entry.entry_id, - entry.title, - module_id, - data_id, - name, - sensor_data, - PlenticoreDataFormatter.get_method(fmt), - plenticore.device_info, - EntityCategory.DIAGNOSTIC, - ) - ) - async_add_entities(entities) diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py new file mode 100644 index 00000000000..43d693642d9 --- /dev/null +++ b/tests/components/kostal_plenticore/test_number.py @@ -0,0 +1,197 @@ +"""Test Kostal Plenticore number.""" + +from unittest.mock import AsyncMock, MagicMock + +from kostal.plenticore import SettingsData +import pytest + +from homeassistant.components.kostal_plenticore.const import ( + PlenticoreNumberEntityDescription, +) +from homeassistant.components.kostal_plenticore.number import PlenticoreDataNumber +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_coordinator() -> MagicMock: + """Return a mocked coordinator for tests.""" + coordinator = MagicMock() + coordinator.async_write_data = AsyncMock() + coordinator.async_refresh = AsyncMock() + return coordinator + + +@pytest.fixture +def mock_number_description() -> PlenticoreNumberEntityDescription: + """Return a PlenticoreNumberEntityDescription for tests.""" + return PlenticoreNumberEntityDescription( + key="mock key", + module_id="moduleid", + data_id="dataid", + min_value=0, + max_value=1000, + fmt_from="format_round", + fmt_to="format_round_back", + ) + + +@pytest.fixture +def mock_setting_data() -> SettingsData: + """Return a default SettingsData for tests.""" + return SettingsData( + { + "default": None, + "min": None, + "access": None, + "max": None, + "unit": None, + "type": None, + "id": "data_id", + } + ) + + +async def test_setup_all_entries( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore: MagicMock +): + """Test if all available entries are setup up.""" + mock_plenticore.client.get_settings.return_value = { + "devices:local": [ + SettingsData({"id": "Battery:MinSoc", "min": None, "max": None}), + SettingsData( + {"id": "Battery:MinHomeComsumption", "min": None, "max": None} + ), + ] + } + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + ent_reg = async_get(hass) + assert ent_reg.async_get("number.scb_battery_min_soc") is not None + assert ent_reg.async_get("number.scb_battery_min_home_consumption") is not None + + +async def test_setup_no_entries( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_plenticore: MagicMock +): + """Test that no entries are setup up.""" + mock_plenticore.client.get_settings.return_value = [] + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + ent_reg = async_get(hass) + assert ent_reg.async_get("number.scb_battery_min_soc") is None + assert ent_reg.async_get("number.scb_battery_min_home_consumption") is None + + +def test_number_returns_value_if_available( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if value property on PlenticoreDataNumber returns an int if available.""" + + mock_coordinator.data = {"moduleid": {"dataid": "42"}} + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + assert entity.value == 42 + assert type(entity.value) == int + + +def test_number_returns_none_if_unavailable( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if value property on PlenticoreDataNumber returns none if unavailable.""" + + mock_coordinator.data = {} # makes entity not available + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + assert entity.value is None + + +async def test_set_value( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if set value calls coordinator with new value.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_set_value(42) + + mock_coordinator.async_write_data.assert_called_once_with( + "moduleid", {"dataid": "42"} + ) + mock_coordinator.async_refresh.assert_called_once() + + +async def test_minmax_overwrite( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, +): + """Test if min/max value is overwritten from retrieved settings data.""" + + setting_data = SettingsData( + { + "min": "5", + "max": "100", + } + ) + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, setting_data + ) + + assert entity.min_value == 5 + assert entity.max_value == 100 + + +async def test_added_to_hass( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if coordinator starts fetching after added to hass.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_added_to_hass() + + mock_coordinator.start_fetch_data.assert_called_once_with("moduleid", "dataid") + + +async def test_remove_from_hass( + mock_coordinator: MagicMock, + mock_number_description: PlenticoreNumberEntityDescription, + mock_setting_data: SettingsData, +): + """Test if coordinator stops fetching after remove from hass.""" + + entity = PlenticoreDataNumber( + mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data + ) + + await entity.async_will_remove_from_hass() + + mock_coordinator.stop_fetch_data.assert_called_once_with("moduleid", "dataid") From 551929a175bab94e8703f06dd81e3c8c0f277899 Mon Sep 17 00:00:00 2001 From: Paul Annekov Date: Wed, 29 Jun 2022 07:37:23 +0300 Subject: [PATCH 813/947] More sensors for SMS integration (#70486) Co-authored-by: Paulus Schoutsen --- homeassistant/components/sms/__init__.py | 79 +++++++++++++++++- homeassistant/components/sms/const.py | 67 +++++++++++++++ homeassistant/components/sms/gateway.py | 4 + homeassistant/components/sms/notify.py | 4 +- homeassistant/components/sms/sensor.py | 101 ++++++++++------------- 5 files changed, 191 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index b1c2703409c..0b63a3d0366 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -1,6 +1,9 @@ """The sms component.""" +from datetime import timedelta import logging +import async_timeout +import gammu # pylint: disable=import-error import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -8,8 +11,18 @@ from homeassistant.const import CONF_DEVICE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_BAUD_SPEED, DEFAULT_BAUD_SPEED, DOMAIN, SMS_GATEWAY +from .const import ( + CONF_BAUD_SPEED, + DEFAULT_BAUD_SPEED, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + GATEWAY, + NETWORK_COORDINATOR, + SIGNAL_COORDINATOR, + SMS_GATEWAY, +) from .gateway import create_sms_gateway _LOGGER = logging.getLogger(__name__) @@ -30,6 +43,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +_LOGGER = logging.getLogger(__name__) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Configure Gammu state machine.""" @@ -61,7 +76,21 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: gateway = await create_sms_gateway(config, hass) if not gateway: return False - hass.data[DOMAIN][SMS_GATEWAY] = gateway + + signal_coordinator = SignalCoordinator(hass, gateway) + network_coordinator = NetworkCoordinator(hass, gateway) + + # Fetch initial data so we have data when entities subscribe + await signal_coordinator.async_config_entry_first_refresh() + await network_coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][SMS_GATEWAY] = { + SIGNAL_COORDINATOR: signal_coordinator, + NETWORK_COORDINATOR: network_coordinator, + GATEWAY: gateway, + } + hass.config_entries.async_setup_platforms(entry, PLATFORMS) return True @@ -71,7 +100,51 @@ 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: - gateway = hass.data[DOMAIN].pop(SMS_GATEWAY) + gateway = hass.data[DOMAIN].pop(SMS_GATEWAY)[GATEWAY] await gateway.terminate_async() return unload_ok + + +class SignalCoordinator(DataUpdateCoordinator): + """Signal strength coordinator.""" + + def __init__(self, hass, gateway): + """Initialize signal strength coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device signal state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device signal quality.""" + try: + async with async_timeout.timeout(10): + return await self._gateway.get_signal_quality_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc + + +class NetworkCoordinator(DataUpdateCoordinator): + """Network info coordinator.""" + + def __init__(self, hass, gateway): + """Initialize network info coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device network state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device network info.""" + try: + async with async_timeout.timeout(10): + return await self._gateway.get_network_info_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc diff --git a/homeassistant/components/sms/const.py b/homeassistant/components/sms/const.py index 7c40a04073c..858e53d9808 100644 --- a/homeassistant/components/sms/const.py +++ b/homeassistant/components/sms/const.py @@ -1,8 +1,17 @@ """Constants for sms Component.""" +from typing import Final + +from homeassistant.components.sensor import SensorDeviceClass, SensorEntityDescription +from homeassistant.const import PERCENTAGE, SIGNAL_STRENGTH_DECIBELS +from homeassistant.helpers.entity import EntityCategory DOMAIN = "sms" SMS_GATEWAY = "SMS_GATEWAY" SMS_STATE_UNREAD = "UnRead" +SIGNAL_COORDINATOR = "signal_coordinator" +NETWORK_COORDINATOR = "network_coordinator" +GATEWAY = "gateway" +DEFAULT_SCAN_INTERVAL = 30 CONF_BAUD_SPEED = "baud_speed" DEFAULT_BAUD_SPEED = "0" DEFAULT_BAUD_SPEEDS = [ @@ -27,3 +36,61 @@ DEFAULT_BAUD_SPEEDS = [ {"value": "76800", "label": "76800"}, {"value": "115200", "label": "115200"}, ] + +SIGNAL_SENSORS: Final[dict[str, SensorEntityDescription]] = { + "SignalStrength": SensorEntityDescription( + key="SignalStrength", + name="Signal Strength", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, + entity_registry_enabled_default=False, + ), + "SignalPercent": SensorEntityDescription( + key="SignalPercent", + icon="mdi:signal-cellular-3", + name="Signal Percent", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=True, + ), + "BitErrorRate": SensorEntityDescription( + key="BitErrorRate", + name="Bit Error Rate", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), +} + +NETWORK_SENSORS: Final[dict[str, SensorEntityDescription]] = { + "NetworkName": SensorEntityDescription( + key="NetworkName", + name="Network Name", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "State": SensorEntityDescription( + key="State", + name="Network Status", + entity_registry_enabled_default=True, + ), + "NetworkCode": SensorEntityDescription( + key="NetworkCode", + name="GSM network code", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "CID": SensorEntityDescription( + key="CID", + name="Cell ID", + icon="mdi:radio-tower", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "LAC": SensorEntityDescription( + key="LAC", + name="Local Area Code", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), +} diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py index 09992600943..c469e688737 100644 --- a/homeassistant/components/sms/gateway.py +++ b/homeassistant/components/sms/gateway.py @@ -154,6 +154,10 @@ class Gateway: """Get the current signal level of the modem.""" return await self._worker.get_signal_quality_async() + async def get_network_info_async(self): + """Get the current network info of the modem.""" + return await self._worker.get_network_info_async() + async def terminate_async(self): """Terminate modem connection.""" return await self._worker.terminate_async() diff --git a/homeassistant/components/sms/notify.py b/homeassistant/components/sms/notify.py index 433144773f7..d076f3625ba 100644 --- a/homeassistant/components/sms/notify.py +++ b/homeassistant/components/sms/notify.py @@ -8,7 +8,7 @@ from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationSer from homeassistant.const import CONF_NAME, CONF_RECIPIENT, CONF_TARGET import homeassistant.helpers.config_validation as cv -from .const import DOMAIN, SMS_GATEWAY +from .const import DOMAIN, GATEWAY, SMS_GATEWAY _LOGGER = logging.getLogger(__name__) @@ -24,7 +24,7 @@ def get_service(hass, config, discovery_info=None): _LOGGER.error("SMS gateway not found, cannot initialize service") return - gateway = hass.data[DOMAIN][SMS_GATEWAY] + gateway = hass.data[DOMAIN][SMS_GATEWAY][GATEWAY] if discovery_info is None: number = config[CONF_RECIPIENT] diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py index dcc85c4f8c6..de20a5b5d0f 100644 --- a/homeassistant/components/sms/sensor.py +++ b/homeassistant/components/sms/sensor.py @@ -1,22 +1,20 @@ """Support for SMS dongle sensor.""" -import logging - -import gammu # pylint: disable=import-error - -from homeassistant.components.sensor import ( - SensorDeviceClass, - SensorEntity, - SensorEntityDescription, -) +from homeassistant.components.sensor import SensorEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import SIGNAL_STRENGTH_DECIBELS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, SMS_GATEWAY - -_LOGGER = logging.getLogger(__name__) +from .const import ( + DOMAIN, + GATEWAY, + NETWORK_COORDINATOR, + NETWORK_SENSORS, + SIGNAL_COORDINATOR, + SIGNAL_SENSORS, + SMS_GATEWAY, +) async def async_setup_entry( @@ -24,61 +22,46 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the GSM Signal Sensor sensor.""" - gateway = hass.data[DOMAIN][SMS_GATEWAY] - imei = await gateway.get_imei_async() - async_add_entities( - [ - GSMSignalSensor( - hass, - gateway, - imei, - SensorEntityDescription( - key="signal", - name=f"gsm_signal_imei_{imei}", - device_class=SensorDeviceClass.SIGNAL_STRENGTH, - native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS, - entity_registry_enabled_default=False, - ), + """Set up all device sensors.""" + sms_data = hass.data[DOMAIN][SMS_GATEWAY] + signal_coordinator = sms_data[SIGNAL_COORDINATOR] + network_coordinator = sms_data[NETWORK_COORDINATOR] + gateway = sms_data[GATEWAY] + unique_id = str(await gateway.get_imei_async()) + entities = [] + for description in SIGNAL_SENSORS.values(): + entities.append( + DeviceSensor( + signal_coordinator, + description, + unique_id, ) - ], - True, - ) + ) + for description in NETWORK_SENSORS.values(): + entities.append( + DeviceSensor( + network_coordinator, + description, + unique_id, + ) + ) + async_add_entities(entities, True) -class GSMSignalSensor(SensorEntity): - """Implementation of a GSM Signal sensor.""" +class DeviceSensor(CoordinatorEntity, SensorEntity): + """Implementation of a device sensor.""" - def __init__(self, hass, gateway, imei, description): - """Initialize the GSM Signal sensor.""" + def __init__(self, coordinator, description, unique_id): + """Initialize the device sensor.""" + super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(imei))}, + identifiers={(DOMAIN, unique_id)}, name="SMS Gateway", ) - self._attr_unique_id = str(imei) - self._hass = hass - self._gateway = gateway - self._state = None + self._attr_unique_id = f"{unique_id}_{description.key}" self.entity_description = description - @property - def available(self): - """Return if the sensor data are available.""" - return self._state is not None - @property def native_value(self): """Return the state of the device.""" - return self._state["SignalStrength"] - - async def async_update(self): - """Get the latest data from the modem.""" - try: - self._state = await self._gateway.get_signal_quality_async() - except gammu.GSMError as exc: - _LOGGER.error("Failed to read signal quality: %s", exc) - - @property - def extra_state_attributes(self): - """Return the sensor attributes.""" - return self._state + return self.coordinator.data.get(self.entity_description.key) From 9f15234b92075164c720df37d01642dd802b3d4e Mon Sep 17 00:00:00 2001 From: Nick Dawson Date: Wed, 29 Jun 2022 14:48:30 +1000 Subject: [PATCH 814/947] Add Anywair in IntesisHome (#71686) --- homeassistant/components/intesishome/climate.py | 3 ++- homeassistant/components/intesishome/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/intesishome/climate.py b/homeassistant/components/intesishome/climate.py index 0f7fe6b33ca..050bed8c721 100644 --- a/homeassistant/components/intesishome/climate.py +++ b/homeassistant/components/intesishome/climate.py @@ -40,13 +40,14 @@ _LOGGER = logging.getLogger(__name__) IH_DEVICE_INTESISHOME = "IntesisHome" IH_DEVICE_AIRCONWITHME = "airconwithme" +IH_DEVICE_ANYWAIR = "anywair" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_DEVICE, default=IH_DEVICE_INTESISHOME): vol.In( - [IH_DEVICE_AIRCONWITHME, IH_DEVICE_INTESISHOME] + [IH_DEVICE_AIRCONWITHME, IH_DEVICE_ANYWAIR, IH_DEVICE_INTESISHOME] ), } ) diff --git a/homeassistant/components/intesishome/manifest.json b/homeassistant/components/intesishome/manifest.json index 6b84f735c12..d4ec7f6d744 100644 --- a/homeassistant/components/intesishome/manifest.json +++ b/homeassistant/components/intesishome/manifest.json @@ -3,7 +3,7 @@ "name": "IntesisHome", "documentation": "https://www.home-assistant.io/integrations/intesishome", "codeowners": ["@jnimmo"], - "requirements": ["pyintesishome==1.7.6"], + "requirements": ["pyintesishome==1.8.0"], "iot_class": "cloud_push", "loggers": ["pyintesishome"] } diff --git a/requirements_all.txt b/requirements_all.txt index 104e01db741..8adf795c977 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1562,7 +1562,7 @@ pyicloud==1.0.0 pyinsteon==1.1.1 # homeassistant.components.intesishome -pyintesishome==1.7.6 +pyintesishome==1.8.0 # homeassistant.components.ipma pyipma==2.0.5 From 22b8afe96673ca88029ae2f8ac4727ece704fae5 Mon Sep 17 00:00:00 2001 From: Edward Date: Wed, 29 Jun 2022 14:52:17 +1000 Subject: [PATCH 815/947] Propagate destination of watched folder moves (#70252) --- .../components/folder_watcher/__init__.py | 27 ++++++++---- tests/components/folder_watcher/test_init.py | 41 ++++++++++++++++++- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py index e718c3d3bf2..cd979d51457 100644 --- a/homeassistant/components/folder_watcher/__init__.py +++ b/homeassistant/components/folder_watcher/__init__.py @@ -63,19 +63,30 @@ def create_event_handler(patterns, hass): super().__init__(patterns) self.hass = hass - def process(self, event): + def process(self, event, moved=False): """On Watcher event, fire HA event.""" _LOGGER.debug("process(%s)", event) if not event.is_directory: folder, file_name = os.path.split(event.src_path) + fireable = { + "event_type": event.event_type, + "path": event.src_path, + "file": file_name, + "folder": folder, + } + + if moved: + dest_folder, dest_file_name = os.path.split(event.dest_path) + fireable.update( + { + "dest_path": event.dest_path, + "dest_file": dest_file_name, + "dest_folder": dest_folder, + } + ) self.hass.bus.fire( DOMAIN, - { - "event_type": event.event_type, - "path": event.src_path, - "file": file_name, - "folder": folder, - }, + fireable, ) def on_modified(self, event): @@ -84,7 +95,7 @@ def create_event_handler(patterns, hass): def on_moved(self, event): """File moved.""" - self.process(event) + self.process(event, moved=True) def on_created(self, event): """File created.""" diff --git a/tests/components/folder_watcher/test_init.py b/tests/components/folder_watcher/test_init.py index b0a522cb7fc..babac930c2d 100644 --- a/tests/components/folder_watcher/test_init.py +++ b/tests/components/folder_watcher/test_init.py @@ -1,5 +1,6 @@ """The tests for the folder_watcher component.""" import os +from types import SimpleNamespace from unittest.mock import Mock, patch from homeassistant.components import folder_watcher @@ -43,7 +44,9 @@ def test_event(): hass = Mock() handler = folder_watcher.create_event_handler(["*"], hass) handler.on_created( - Mock(is_directory=False, src_path="/hello/world.txt", event_type="created") + SimpleNamespace( + is_directory=False, src_path="/hello/world.txt", event_type="created" + ) ) assert hass.bus.fire.called assert hass.bus.fire.mock_calls[0][1][0] == folder_watcher.DOMAIN @@ -53,3 +56,39 @@ def test_event(): "file": "world.txt", "folder": "/hello", } + + +def test_move_event(): + """Check that Home Assistant events are fired correctly on watchdog event.""" + + class MockPatternMatchingEventHandler: + """Mock base class for the pattern matcher event handler.""" + + def __init__(self, patterns): + pass + + with patch( + "homeassistant.components.folder_watcher.PatternMatchingEventHandler", + MockPatternMatchingEventHandler, + ): + hass = Mock() + handler = folder_watcher.create_event_handler(["*"], hass) + handler.on_moved( + SimpleNamespace( + is_directory=False, + src_path="/hello/world.txt", + dest_path="/hello/earth.txt", + event_type="moved", + ) + ) + assert hass.bus.fire.called + assert hass.bus.fire.mock_calls[0][1][0] == folder_watcher.DOMAIN + assert hass.bus.fire.mock_calls[0][1][1] == { + "event_type": "moved", + "path": "/hello/world.txt", + "dest_path": "/hello/earth.txt", + "file": "world.txt", + "dest_file": "earth.txt", + "folder": "/hello", + "dest_folder": "/hello", + } From b7b8feda0ffb7487954545c96c50e7f64e2195bc Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Wed, 29 Jun 2022 06:59:19 +0200 Subject: [PATCH 816/947] Add tests for LCN sensor and binary_sensor platforms (#67263) --- .coveragerc | 2 - tests/components/lcn/fixtures/config.json | 41 +++++ .../lcn/fixtures/config_entry_pchk.json | 67 +++++++++ tests/components/lcn/test_binary_sensor.py | 140 ++++++++++++++++++ tests/components/lcn/test_cover.py | 115 +++++++------- tests/components/lcn/test_events.py | 37 +++-- tests/components/lcn/test_light.py | 74 ++++----- tests/components/lcn/test_sensor.py | 131 ++++++++++++++++ tests/components/lcn/test_switch.py | 63 ++++---- 9 files changed, 532 insertions(+), 138 deletions(-) create mode 100644 tests/components/lcn/test_binary_sensor.py create mode 100644 tests/components/lcn/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 8253ed56ccd..981e2d06680 100644 --- a/.coveragerc +++ b/.coveragerc @@ -633,11 +633,9 @@ omit = homeassistant/components/launch_library/const.py homeassistant/components/launch_library/diagnostics.py homeassistant/components/launch_library/sensor.py - homeassistant/components/lcn/binary_sensor.py homeassistant/components/lcn/climate.py homeassistant/components/lcn/helpers.py homeassistant/components/lcn/scene.py - homeassistant/components/lcn/sensor.py homeassistant/components/lcn/services.py homeassistant/components/lg_netcast/media_player.py homeassistant/components/lg_soundbar/media_player.py diff --git a/tests/components/lcn/fixtures/config.json b/tests/components/lcn/fixtures/config.json index cc615b6083b..13b3dd5feed 100644 --- a/tests/components/lcn/fixtures/config.json +++ b/tests/components/lcn/fixtures/config.json @@ -90,6 +90,47 @@ "address": "s0.m7", "motor": "motor1" } + ], + "binary_sensors": [ + { + "name": "Sensor_LockRegulator1", + "address": "s0.m7", + "source": "r1varsetpoint" + }, + { + "name": "Binary_Sensor1", + "address": "s0.m7", + "source": "binsensor1" + }, + { + "name": "Sensor_KeyLock", + "address": "s0.m7", + "source": "a5" + } + ], + "sensors": [ + { + "name": "Sensor_Var1", + "address": "s0.m7", + "source": "var1", + "unit_of_measurement": "°C" + }, + { + "name": "Sensor_Setpoint1", + "address": "s0.m7", + "source": "r1varsetpoint", + "unit_of_measurement": "°C" + }, + { + "name": "Sensor_Led6", + "address": "s0.m7", + "source": "led6" + }, + { + "name": "Sensor_LogicOp1", + "address": "s0.m7", + "source": "logicop1" + } ] } } diff --git a/tests/components/lcn/fixtures/config_entry_pchk.json b/tests/components/lcn/fixtures/config_entry_pchk.json index 620bbb673f5..31b51adfce7 100644 --- a/tests/components/lcn/fixtures/config_entry_pchk.json +++ b/tests/components/lcn/fixtures/config_entry_pchk.json @@ -120,6 +120,73 @@ "motor": "MOTOR1", "reverse_time": "RT1200" } + }, + { + "address": [0, 7, false], + "name": "Sensor_LockRegulator1", + "resource": "r1varsetpoint", + "domain": "binary_sensor", + "domain_data": { + "source": "R1VARSETPOINT" + } + }, + { + "address": [0, 7, false], + "name": "Binary_Sensor1", + "resource": "binsensor1", + "domain": "binary_sensor", + "domain_data": { + "source": "BINSENSOR1" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_KeyLock", + "resource": "a5", + "domain": "binary_sensor", + "domain_data": { + "source": "A5" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Var1", + "resource": "var1", + "domain": "sensor", + "domain_data": { + "source": "VAR1", + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Setpoint1", + "resource": "r1varsetpoint", + "domain": "sensor", + "domain_data": { + "source": "R1VARSETPOINT", + "unit_of_measurement": "°C" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_Led6", + "resource": "led6", + "domain": "sensor", + "domain_data": { + "source": "LED6", + "unit_of_measurement": "NATIVE" + } + }, + { + "address": [0, 7, false], + "name": "Sensor_LogicOp1", + "resource": "logicop1", + "domain": "sensor", + "domain_data": { + "source": "LOGICOP1", + "unit_of_measurement": "NATIVE" + } } ] } diff --git a/tests/components/lcn/test_binary_sensor.py b/tests/components/lcn/test_binary_sensor.py new file mode 100644 index 00000000000..3f9adb34295 --- /dev/null +++ b/tests/components/lcn/test_binary_sensor.py @@ -0,0 +1,140 @@ +"""Test for the LCN binary sensor platform.""" +from pypck.inputs import ModStatusBinSensors, ModStatusKeyLocks, ModStatusVar +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import Var, VarValue + +from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN +from homeassistant.helpers import entity_registry as er + +BINARY_SENSOR_LOCKREGULATOR1 = "binary_sensor.sensor_lockregulator1" +BINARY_SENSOR_SENSOR1 = "binary_sensor.binary_sensor1" +BINARY_SENSOR_KEYLOCK = "binary_sensor.sensor_keylock" + + +async def test_setup_lcn_binary_sensor(hass, lcn_connection): + """Test the setup of binary sensor.""" + for entity_id in ( + BINARY_SENSOR_LOCKREGULATOR1, + BINARY_SENSOR_SENSOR1, + BINARY_SENSOR_KEYLOCK, + ): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_entity_state(hass, lcn_connection): + """Test state of entity.""" + state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) + assert state + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state + + state = hass.states.get(BINARY_SENSOR_KEYLOCK) + assert state + + +async def test_entity_attributes(hass, entry, lcn_connection): + """Test the attributes of an entity.""" + entity_registry = er.async_get(hass) + + entity_setpoint1 = entity_registry.async_get(BINARY_SENSOR_LOCKREGULATOR1) + assert entity_setpoint1 + assert entity_setpoint1.unique_id == f"{entry.entry_id}-m000007-r1varsetpoint" + assert entity_setpoint1.original_name == "Sensor_LockRegulator1" + + entity_binsensor1 = entity_registry.async_get(BINARY_SENSOR_SENSOR1) + assert entity_binsensor1 + assert entity_binsensor1.unique_id == f"{entry.entry_id}-m000007-binsensor1" + assert entity_binsensor1.original_name == "Binary_Sensor1" + + entity_keylock = entity_registry.async_get(BINARY_SENSOR_KEYLOCK) + assert entity_keylock + assert entity_keylock.unique_id == f"{entry.entry_id}-m000007-a5" + assert entity_keylock.original_name == "Sensor_KeyLock" + + +async def test_pushed_lock_setpoint_status_change(hass, entry, lcn_connection): + """Test the lock setpoint sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + # push status lock setpoint + inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x8000)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) + assert state is not None + assert state.state == STATE_ON + + # push status unlock setpoint + inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue(0x7FFF)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_LOCKREGULATOR1) + assert state is not None + assert state.state == STATE_OFF + + +async def test_pushed_binsensor_status_change(hass, entry, lcn_connection): + """Test the binary port sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + states = [False] * 8 + + # push status binary port "off" + inp = ModStatusBinSensors(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state is not None + assert state.state == STATE_OFF + + # push status binary port "on" + states[0] = True + inp = ModStatusBinSensors(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_SENSOR1) + assert state is not None + assert state.state == STATE_ON + + +async def test_pushed_keylock_status_change(hass, entry, lcn_connection): + """Test the keylock sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + states = [[False] * 8 for i in range(4)] + + # push status keylock "off" + inp = ModStatusKeyLocks(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_KEYLOCK) + assert state is not None + assert state.state == STATE_OFF + + # push status keylock "on" + states[0][4] = True + inp = ModStatusKeyLocks(address, states) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(BINARY_SENSOR_KEYLOCK) + assert state is not None + assert state.state == STATE_ON + + +async def test_unload_config_entry(hass, entry, lcn_connection): + """Test the binary sensor is removed when the config entry is unloaded.""" + await hass.config_entries.async_unload(entry.entry_id) + assert hass.states.get(BINARY_SENSOR_LOCKREGULATOR1).state == STATE_UNAVAILABLE + assert hass.states.get(BINARY_SENSOR_SENSOR1).state == STATE_UNAVAILABLE + assert hass.states.get(BINARY_SENSOR_KEYLOCK).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_cover.py b/tests/components/lcn/test_cover.py index 8c6b814e525..b7eab5f2ecc 100644 --- a/tests/components/lcn/test_cover.py +++ b/tests/components/lcn/test_cover.py @@ -22,12 +22,15 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockModuleConnection +COVER_OUTPUTS = "cover.cover_outputs" +COVER_RELAYS = "cover.cover_relays" + async def test_setup_lcn_cover(hass, entry, lcn_connection): """Test the setup of cover.""" for entity_id in ( - "cover.cover_outputs", - "cover.cover_relays", + COVER_OUTPUTS, + COVER_RELAYS, ): state = hass.states.get(entity_id) assert state is not None @@ -38,13 +41,13 @@ async def test_entity_attributes(hass, entry, lcn_connection): """Test the attributes of an entity.""" entity_registry = er.async_get(hass) - entity_outputs = entity_registry.async_get("cover.cover_outputs") + entity_outputs = entity_registry.async_get(COVER_OUTPUTS) assert entity_outputs assert entity_outputs.unique_id == f"{entry.entry_id}-m000007-outputs" assert entity_outputs.original_name == "Cover_Outputs" - entity_relays = entity_registry.async_get("cover.cover_relays") + entity_relays = entity_registry.async_get(COVER_RELAYS) assert entity_relays assert entity_relays.unique_id == f"{entry.entry_id}-m000007-motor1" @@ -54,7 +57,7 @@ async def test_entity_attributes(hass, entry, lcn_connection): @patch.object(MockModuleConnection, "control_motors_outputs") async def test_outputs_open(control_motors_outputs, hass, lcn_connection): """Test the outputs cover opens.""" - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_CLOSED # command failed @@ -63,7 +66,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -71,7 +74,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): MotorStateModifier.UP, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state != STATE_OPENING @@ -82,7 +85,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -90,7 +93,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): MotorStateModifier.UP, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_OPENING @@ -98,7 +101,7 @@ async def test_outputs_open(control_motors_outputs, hass, lcn_connection): @patch.object(MockModuleConnection, "control_motors_outputs") async def test_outputs_close(control_motors_outputs, hass, lcn_connection): """Test the outputs cover closes.""" - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_OPEN # command failed @@ -107,7 +110,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -115,7 +118,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state != STATE_CLOSING @@ -126,7 +129,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() @@ -134,7 +137,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): MotorStateModifier.DOWN, MotorReverseTime.RT1200 ) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_CLOSING @@ -142,7 +145,7 @@ async def test_outputs_close(control_motors_outputs, hass, lcn_connection): @patch.object(MockModuleConnection, "control_motors_outputs") async def test_outputs_stop(control_motors_outputs, hass, lcn_connection): """Test the outputs cover stops.""" - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_CLOSING # command failed @@ -151,13 +154,13 @@ async def test_outputs_stop(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_CLOSING @@ -168,13 +171,13 @@ async def test_outputs_stop(control_motors_outputs, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_outputs"}, + {ATTR_ENTITY_ID: COVER_OUTPUTS}, blocking=True, ) await hass.async_block_till_done() control_motors_outputs.assert_awaited_with(MotorStateModifier.STOP) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state not in (STATE_CLOSING, STATE_OPENING) @@ -185,7 +188,7 @@ async def test_relays_open(control_motors_relays, hass, lcn_connection): states = [MotorStateModifier.NOCHANGE] * 4 states[0] = MotorStateModifier.UP - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_CLOSED # command failed @@ -194,13 +197,13 @@ async def test_relays_open(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != STATE_OPENING @@ -211,13 +214,13 @@ async def test_relays_open(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_OPEN_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_OPENING @@ -228,7 +231,7 @@ async def test_relays_close(control_motors_relays, hass, lcn_connection): states = [MotorStateModifier.NOCHANGE] * 4 states[0] = MotorStateModifier.DOWN - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_OPEN # command failed @@ -237,13 +240,13 @@ async def test_relays_close(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state != STATE_CLOSING @@ -254,13 +257,13 @@ async def test_relays_close(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_CLOSE_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_CLOSING @@ -271,7 +274,7 @@ async def test_relays_stop(control_motors_relays, hass, lcn_connection): states = [MotorStateModifier.NOCHANGE] * 4 states[0] = MotorStateModifier.STOP - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_CLOSING # command failed @@ -280,13 +283,13 @@ async def test_relays_stop(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_CLOSING @@ -297,13 +300,13 @@ async def test_relays_stop(control_motors_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_COVER, SERVICE_STOP_COVER, - {ATTR_ENTITY_ID: "cover.cover_relays"}, + {ATTR_ENTITY_ID: COVER_RELAYS}, blocking=True, ) await hass.async_block_till_done() control_motors_relays.assert_awaited_with(states) - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state not in (STATE_CLOSING, STATE_OPENING) @@ -313,33 +316,33 @@ async def test_pushed_outputs_status_change(hass, entry, lcn_connection): device_connection = get_device_connection(hass, (0, 7, False), entry) address = LcnAddr(0, 7, False) - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) state.state = STATE_CLOSED # push status "open" - input = ModStatusOutput(address, 0, 100) - await device_connection.async_process_input(input) + inp = ModStatusOutput(address, 0, 100) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_OPENING # push status "stop" - input = ModStatusOutput(address, 0, 0) - await device_connection.async_process_input(input) + inp = ModStatusOutput(address, 0, 0) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state not in (STATE_OPENING, STATE_CLOSING) # push status "close" - input = ModStatusOutput(address, 1, 100) - await device_connection.async_process_input(input) + inp = ModStatusOutput(address, 1, 100) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_outputs") + state = hass.states.get(COVER_OUTPUTS) assert state is not None assert state.state == STATE_CLOSING @@ -350,36 +353,36 @@ async def test_pushed_relays_status_change(hass, entry, lcn_connection): address = LcnAddr(0, 7, False) states = [False] * 8 - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) state.state = STATE_CLOSED # push status "open" states[0:2] = [True, False] - input = ModStatusRelays(address, states) - await device_connection.async_process_input(input) + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_OPENING # push status "stop" states[0] = False - input = ModStatusRelays(address, states) - await device_connection.async_process_input(input) + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state not in (STATE_OPENING, STATE_CLOSING) # push status "close" states[0:2] = [True, True] - input = ModStatusRelays(address, states) - await device_connection.async_process_input(input) + inp = ModStatusRelays(address, states) + await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("cover.cover_relays") + state = hass.states.get(COVER_RELAYS) assert state is not None assert state.state == STATE_CLOSING @@ -387,5 +390,5 @@ async def test_pushed_relays_status_change(hass, entry, lcn_connection): async def test_unload_config_entry(hass, entry, lcn_connection): """Test the cover is removed when the config entry is unloaded.""" await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get("cover.cover_outputs").state == STATE_UNAVAILABLE - assert hass.states.get("cover.cover_relays").state == STATE_UNAVAILABLE + assert hass.states.get(COVER_OUTPUTS).state == STATE_UNAVAILABLE + assert hass.states.get(COVER_RELAYS).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_events.py b/tests/components/lcn/test_events.py index 518af46b02a..9786d1895da 100644 --- a/tests/components/lcn/test_events.py +++ b/tests/components/lcn/test_events.py @@ -5,10 +5,15 @@ from pypck.lcn_defs import AccessControlPeriphery, KeyAction, SendKeyCommand from tests.common import async_capture_events +LCN_TRANSPONDER = "lcn_transponder" +LCN_FINGERPRINT = "lcn_fingerprint" +LCN_TRANSMITTER = "lcn_transmitter" +LCN_SEND_KEYS = "lcn_send_keys" + async def test_fire_transponder_event(hass, lcn_connection): """Test the transponder event is fired.""" - events = async_capture_events(hass, "lcn_transponder") + events = async_capture_events(hass, LCN_TRANSPONDER) inp = ModStatusAccessControl( LcnAddr(0, 7, False), @@ -20,13 +25,13 @@ async def test_fire_transponder_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 1 - assert events[0].event_type == "lcn_transponder" + assert events[0].event_type == LCN_TRANSPONDER assert events[0].data["code"] == "aabbcc" async def test_fire_fingerprint_event(hass, lcn_connection): """Test the fingerprint event is fired.""" - events = async_capture_events(hass, "lcn_fingerprint") + events = async_capture_events(hass, LCN_FINGERPRINT) inp = ModStatusAccessControl( LcnAddr(0, 7, False), @@ -38,7 +43,7 @@ async def test_fire_fingerprint_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 1 - assert events[0].event_type == "lcn_fingerprint" + assert events[0].event_type == LCN_FINGERPRINT assert events[0].data["code"] == "aabbcc" @@ -62,7 +67,7 @@ async def test_fire_codelock_event(hass, lcn_connection): async def test_fire_transmitter_event(hass, lcn_connection): """Test the transmitter event is fired.""" - events = async_capture_events(hass, "lcn_transmitter") + events = async_capture_events(hass, LCN_TRANSMITTER) inp = ModStatusAccessControl( LcnAddr(0, 7, False), @@ -77,7 +82,7 @@ async def test_fire_transmitter_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 1 - assert events[0].event_type == "lcn_transmitter" + assert events[0].event_type == LCN_TRANSMITTER assert events[0].data["code"] == "aabbcc" assert events[0].data["level"] == 0 assert events[0].data["key"] == 0 @@ -86,7 +91,7 @@ async def test_fire_transmitter_event(hass, lcn_connection): async def test_fire_sendkeys_event(hass, lcn_connection): """Test the send_keys event is fired.""" - events = async_capture_events(hass, "lcn_send_keys") + events = async_capture_events(hass, LCN_SEND_KEYS) inp = ModSendKeysHost( LcnAddr(0, 7, False), @@ -98,16 +103,16 @@ async def test_fire_sendkeys_event(hass, lcn_connection): await hass.async_block_till_done() assert len(events) == 4 - assert events[0].event_type == "lcn_send_keys" + assert events[0].event_type == LCN_SEND_KEYS assert events[0].data["key"] == "a1" assert events[0].data["action"] == "hit" - assert events[1].event_type == "lcn_send_keys" + assert events[1].event_type == LCN_SEND_KEYS assert events[1].data["key"] == "a2" assert events[1].data["action"] == "hit" - assert events[2].event_type == "lcn_send_keys" + assert events[2].event_type == LCN_SEND_KEYS assert events[2].data["key"] == "b1" assert events[2].data["action"] == "make" - assert events[3].event_type == "lcn_send_keys" + assert events[3].event_type == LCN_SEND_KEYS assert events[3].data["key"] == "b2" assert events[3].data["action"] == "make" @@ -117,10 +122,10 @@ async def test_dont_fire_on_non_module_input(hass, lcn_connection): inp = Input() for event_name in ( - "lcn_transponder", - "lcn_fingerprint", - "lcn_transmitter", - "lcn_send_keys", + LCN_TRANSPONDER, + LCN_FINGERPRINT, + LCN_TRANSMITTER, + LCN_SEND_KEYS, ): events = async_capture_events(hass, event_name) await lcn_connection.async_process_input(inp) @@ -136,7 +141,7 @@ async def test_dont_fire_on_unknown_module(hass, lcn_connection): code="aabbcc", ) - events = async_capture_events(hass, "lcn_fingerprint") + events = async_capture_events(hass, LCN_FINGERPRINT) await lcn_connection.async_process_input(inp) await hass.async_block_till_done() assert len(events) == 0 diff --git a/tests/components/lcn/test_light.py b/tests/components/lcn/test_light.py index efde0daa68f..1795f716868 100644 --- a/tests/components/lcn/test_light.py +++ b/tests/components/lcn/test_light.py @@ -27,13 +27,17 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockModuleConnection +LIGHT_OUTPUT1 = "light.light_output1" +LIGHT_OUTPUT2 = "light.light_output2" +LIGHT_RELAY1 = "light.light_relay1" + async def test_setup_lcn_light(hass, lcn_connection): """Test the setup of light.""" for entity_id in ( - "light.light_output1", - "light.light_output2", - "light.light_relay1", + LIGHT_OUTPUT1, + LIGHT_OUTPUT2, + LIGHT_RELAY1, ): state = hass.states.get(entity_id) assert state is not None @@ -42,12 +46,12 @@ async def test_setup_lcn_light(hass, lcn_connection): async def test_entity_state(hass, lcn_connection): """Test state of entity.""" - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - state = hass.states.get("light.light_output2") + state = hass.states.get(LIGHT_OUTPUT2) assert state assert state.attributes[ATTR_SUPPORTED_FEATURES] == LightEntityFeature.TRANSITION assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.ONOFF] @@ -57,13 +61,13 @@ async def test_entity_attributes(hass, entry, lcn_connection): """Test the attributes of an entity.""" entity_registry = er.async_get(hass) - entity_output = entity_registry.async_get("light.light_output1") + entity_output = entity_registry.async_get(LIGHT_OUTPUT1) assert entity_output assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" assert entity_output.original_name == "Light_Output1" - entity_relay = entity_registry.async_get("light.light_relay1") + entity_relay = entity_registry.async_get(LIGHT_RELAY1) assert entity_relay assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" @@ -79,13 +83,13 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_ON @@ -96,13 +100,13 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON @@ -116,7 +120,7 @@ async def test_output_turn_on_with_attributes(dim_output, hass, lcn_connection): DOMAIN_LIGHT, SERVICE_TURN_ON, { - ATTR_ENTITY_ID: "light.light_output1", + ATTR_ENTITY_ID: LIGHT_OUTPUT1, ATTR_BRIGHTNESS: 50, ATTR_TRANSITION: 2, }, @@ -125,7 +129,7 @@ async def test_output_turn_on_with_attributes(dim_output, hass, lcn_connection): await hass.async_block_till_done() dim_output.assert_awaited_with(0, 19, 6) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON @@ -133,7 +137,7 @@ async def test_output_turn_on_with_attributes(dim_output, hass, lcn_connection): @patch.object(MockModuleConnection, "dim_output") async def test_output_turn_off(dim_output, hass, lcn_connection): """Test the output light turns off.""" - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) state.state = STATE_ON # command failed @@ -142,13 +146,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state != STATE_OFF @@ -159,13 +163,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_output1"}, + {ATTR_ENTITY_ID: LIGHT_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 9) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_OFF @@ -175,14 +179,14 @@ async def test_output_turn_off_with_attributes(dim_output, hass, lcn_connection) """Test the output light turns off.""" dim_output.return_value = True - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) state.state = STATE_ON await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, { - ATTR_ENTITY_ID: "light.light_output1", + ATTR_ENTITY_ID: LIGHT_OUTPUT1, ATTR_TRANSITION: 2, }, blocking=True, @@ -190,7 +194,7 @@ async def test_output_turn_off_with_attributes(dim_output, hass, lcn_connection) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 6) - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_OFF @@ -207,13 +211,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state != STATE_ON @@ -224,13 +228,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_ON @@ -241,7 +245,7 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) state.state = STATE_ON # command failed @@ -250,13 +254,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state != STATE_OFF @@ -267,13 +271,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_LIGHT, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.light_relay1"}, + {ATTR_ENTITY_ID: LIGHT_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_OFF @@ -288,7 +292,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_ON assert state.attributes[ATTR_BRIGHTNESS] == 127 @@ -298,7 +302,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_output1") + state = hass.states.get(LIGHT_OUTPUT1) assert state is not None assert state.state == STATE_OFF @@ -315,7 +319,7 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_ON @@ -325,7 +329,7 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("light.light_relay1") + state = hass.states.get(LIGHT_RELAY1) assert state is not None assert state.state == STATE_OFF @@ -333,4 +337,4 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): async def test_unload_config_entry(hass, entry, lcn_connection): """Test the light is removed when the config entry is unloaded.""" await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get("light.light_output1").state == STATE_UNAVAILABLE + assert hass.states.get(LIGHT_OUTPUT1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_sensor.py b/tests/components/lcn/test_sensor.py new file mode 100644 index 00000000000..4b6c0beb7e2 --- /dev/null +++ b/tests/components/lcn/test_sensor.py @@ -0,0 +1,131 @@ +"""Test for the LCN sensor platform.""" +from pypck.inputs import ModStatusLedsAndLogicOps, ModStatusVar +from pypck.lcn_addr import LcnAddr +from pypck.lcn_defs import LedStatus, LogicOpStatus, Var, VarValue + +from homeassistant.components.lcn.helpers import get_device_connection +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + TEMP_CELSIUS, +) +from homeassistant.helpers import entity_registry as er + +SENSOR_VAR1 = "sensor.sensor_var1" +SENSOR_SETPOINT1 = "sensor.sensor_setpoint1" +SENSOR_LED6 = "sensor.sensor_led6" +SENSOR_LOGICOP1 = "sensor.sensor_logicop1" + + +async def test_setup_lcn_sensor(hass, entry, lcn_connection): + """Test the setup of sensor.""" + for entity_id in ( + SENSOR_VAR1, + SENSOR_SETPOINT1, + SENSOR_LED6, + SENSOR_LOGICOP1, + ): + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + +async def test_entity_state(hass, lcn_connection): + """Test state of entity.""" + state = hass.states.get(SENSOR_VAR1) + assert state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + + state = hass.states.get(SENSOR_SETPOINT1) + assert state + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == TEMP_CELSIUS + + state = hass.states.get(SENSOR_LED6) + assert state + + state = hass.states.get(SENSOR_LOGICOP1) + assert state + + +async def test_entity_attributes(hass, entry, lcn_connection): + """Test the attributes of an entity.""" + entity_registry = er.async_get(hass) + + entity_var1 = entity_registry.async_get(SENSOR_VAR1) + assert entity_var1 + assert entity_var1.unique_id == f"{entry.entry_id}-m000007-var1" + assert entity_var1.original_name == "Sensor_Var1" + + entity_r1varsetpoint = entity_registry.async_get(SENSOR_SETPOINT1) + assert entity_r1varsetpoint + assert entity_r1varsetpoint.unique_id == f"{entry.entry_id}-m000007-r1varsetpoint" + assert entity_r1varsetpoint.original_name == "Sensor_Setpoint1" + + entity_led6 = entity_registry.async_get(SENSOR_LED6) + assert entity_led6 + assert entity_led6.unique_id == f"{entry.entry_id}-m000007-led6" + assert entity_led6.original_name == "Sensor_Led6" + + entity_logicop1 = entity_registry.async_get(SENSOR_LOGICOP1) + assert entity_logicop1 + assert entity_logicop1.unique_id == f"{entry.entry_id}-m000007-logicop1" + assert entity_logicop1.original_name == "Sensor_LogicOp1" + + +async def test_pushed_variable_status_change(hass, entry, lcn_connection): + """Test the variable sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + # push status variable + inp = ModStatusVar(address, Var.VAR1, VarValue.from_celsius(42)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(SENSOR_VAR1) + assert state is not None + assert float(state.state) == 42.0 + + # push status setpoint + inp = ModStatusVar(address, Var.R1VARSETPOINT, VarValue.from_celsius(42)) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(SENSOR_SETPOINT1) + assert state is not None + assert float(state.state) == 42.0 + + +async def test_pushed_ledlogicop_status_change(hass, entry, lcn_connection): + """Test the led and logicop sensor changes its state on status received.""" + device_connection = get_device_connection(hass, (0, 7, False), entry) + address = LcnAddr(0, 7, False) + + states_led = [LedStatus.OFF] * 12 + states_logicop = [LogicOpStatus.NONE] * 4 + + states_led[5] = LedStatus.ON + states_logicop[0] = LogicOpStatus.ALL + + # push status led and logicop + inp = ModStatusLedsAndLogicOps(address, states_led, states_logicop) + await device_connection.async_process_input(inp) + await hass.async_block_till_done() + + state = hass.states.get(SENSOR_LED6) + assert state is not None + assert state.state == "on" + + state = hass.states.get(SENSOR_LOGICOP1) + assert state is not None + assert state.state == "all" + + +async def test_unload_config_entry(hass, entry, lcn_connection): + """Test the sensor is removed when the config entry is unloaded.""" + await hass.config_entries.async_unload(entry.entry_id) + assert hass.states.get(SENSOR_VAR1).state == STATE_UNAVAILABLE + assert hass.states.get(SENSOR_SETPOINT1).state == STATE_UNAVAILABLE + assert hass.states.get(SENSOR_LED6).state == STATE_UNAVAILABLE + assert hass.states.get(SENSOR_LOGICOP1).state == STATE_UNAVAILABLE diff --git a/tests/components/lcn/test_switch.py b/tests/components/lcn/test_switch.py index 8c4fb1ff0a8..a21bd35db09 100644 --- a/tests/components/lcn/test_switch.py +++ b/tests/components/lcn/test_switch.py @@ -19,14 +19,19 @@ from homeassistant.helpers import entity_registry as er from .conftest import MockModuleConnection +SWITCH_OUTPUT1 = "switch.switch_output1" +SWITCH_OUTPUT2 = "switch.switch_output2" +SWITCH_RELAY1 = "switch.switch_relay1" +SWITCH_RELAY2 = "switch.switch_relay2" + async def test_setup_lcn_switch(hass, lcn_connection): """Test the setup of switch.""" for entity_id in ( - "switch.switch_output1", - "switch.switch_output2", - "switch.switch_relay1", - "switch.switch_relay2", + SWITCH_OUTPUT1, + SWITCH_OUTPUT2, + SWITCH_RELAY1, + SWITCH_RELAY2, ): state = hass.states.get(entity_id) assert state is not None @@ -37,13 +42,13 @@ async def test_entity_attributes(hass, entry, lcn_connection): """Test the attributes of an entity.""" entity_registry = er.async_get(hass) - entity_output = entity_registry.async_get("switch.switch_output1") + entity_output = entity_registry.async_get(SWITCH_OUTPUT1) assert entity_output assert entity_output.unique_id == f"{entry.entry_id}-m000007-output1" assert entity_output.original_name == "Switch_Output1" - entity_relay = entity_registry.async_get("switch.switch_relay1") + entity_relay = entity_registry.async_get(SWITCH_RELAY1) assert entity_relay assert entity_relay.unique_id == f"{entry.entry_id}-m000007-relay1" @@ -59,13 +64,13 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_OFF # command success @@ -75,20 +80,20 @@ async def test_output_turn_on(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 100, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_ON @patch.object(MockModuleConnection, "dim_output") async def test_output_turn_off(dim_output, hass, lcn_connection): """Test the output switch turns off.""" - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) state.state = STATE_ON # command failed @@ -97,13 +102,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_ON # command success @@ -113,13 +118,13 @@ async def test_output_turn_off(dim_output, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_output1"}, + {ATTR_ENTITY_ID: SWITCH_OUTPUT1}, blocking=True, ) await hass.async_block_till_done() dim_output.assert_awaited_with(0, 0, 0) - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_OFF @@ -135,13 +140,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_OFF # command success @@ -151,13 +156,13 @@ async def test_relay_turn_on(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_ON @@ -167,7 +172,7 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): states = [RelayStateModifier.NOCHANGE] * 8 states[0] = RelayStateModifier.OFF - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) state.state = STATE_ON # command failed @@ -176,13 +181,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_ON # command success @@ -192,13 +197,13 @@ async def test_relay_turn_off(control_relays, hass, lcn_connection): await hass.services.async_call( DOMAIN_SWITCH, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "switch.switch_relay1"}, + {ATTR_ENTITY_ID: SWITCH_RELAY1}, blocking=True, ) await hass.async_block_till_done() control_relays.assert_awaited_with(states) - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_OFF @@ -212,7 +217,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_ON # push status "off" @@ -220,7 +225,7 @@ async def test_pushed_output_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_output1") + state = hass.states.get(SWITCH_OUTPUT1) assert state.state == STATE_OFF @@ -236,7 +241,7 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_ON # push status "off" @@ -245,11 +250,11 @@ async def test_pushed_relay_status_change(hass, entry, lcn_connection): await device_connection.async_process_input(inp) await hass.async_block_till_done() - state = hass.states.get("switch.switch_relay1") + state = hass.states.get(SWITCH_RELAY1) assert state.state == STATE_OFF async def test_unload_config_entry(hass, entry, lcn_connection): """Test the switch is removed when the config entry is unloaded.""" await hass.config_entries.async_unload(entry.entry_id) - assert hass.states.get("switch.switch_output1").state == STATE_UNAVAILABLE + assert hass.states.get(SWITCH_OUTPUT1).state == STATE_UNAVAILABLE From 596f60bdb5eeff13bca93795554439be6145bbbd Mon Sep 17 00:00:00 2001 From: Chris Browet Date: Wed, 29 Jun 2022 07:03:56 +0200 Subject: [PATCH 817/947] Universal media player: ordered states (#68036) --- .../components/universal/media_player.py | 29 ++++++++++++++----- .../components/universal/test_media_player.py | 6 ++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index 7ffd8b9d13d..f33db3827af 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -72,6 +72,8 @@ from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_ON, + STATE_PAUSED, + STATE_PLAYING, STATE_UNAVAILABLE, STATE_UNKNOWN, ) @@ -94,8 +96,15 @@ CONF_ATTRS = "attributes" CONF_CHILDREN = "children" CONF_COMMANDS = "commands" -OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE, STATE_UNKNOWN] - +STATES_ORDER = [ + STATE_UNKNOWN, + STATE_UNAVAILABLE, + STATE_OFF, + STATE_IDLE, + STATE_ON, + STATE_PAUSED, + STATE_PLAYING, +] ATTRS_SCHEMA = cv.schema_with_slug_keys(cv.string) CMD_SCHEMA = cv.schema_with_slug_keys(cv.SERVICE_SCHEMA) @@ -614,9 +623,15 @@ class UniversalMediaPlayer(MediaPlayerEntity): async def async_update(self): """Update state in HA.""" - for child_name in self._children: - child_state = self.hass.states.get(child_name) - if child_state and child_state.state not in OFF_STATES: - self._child_state = child_state - return self._child_state = None + for child_name in self._children: + if (child_state := self.hass.states.get(child_name)) and STATES_ORDER.index( + child_state.state + ) >= STATES_ORDER.index(STATE_IDLE): + if self._child_state: + if STATES_ORDER.index(child_state.state) > STATES_ORDER.index( + self._child_state.state + ): + self._child_state = child_state + else: + self._child_state = child_state diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index d4fe2ce64ae..c50c3e97713 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -422,6 +422,12 @@ async def test_active_child_state(hass, mock_states): await ump.async_update() assert mock_states.mock_mp_1.entity_id == ump._child_state.entity_id + mock_states.mock_mp_1._state = STATE_PAUSED + mock_states.mock_mp_1.async_schedule_update_ha_state() + await hass.async_block_till_done() + await ump.async_update() + assert mock_states.mock_mp_2.entity_id == ump._child_state.entity_id + mock_states.mock_mp_1._state = STATE_OFF mock_states.mock_mp_1.async_schedule_update_ha_state() await hass.async_block_till_done() From 90c68085bef5b91e9f15fb11f4eb9fc83072c1fc Mon Sep 17 00:00:00 2001 From: Thomas Schamm Date: Wed, 29 Jun 2022 07:08:16 +0200 Subject: [PATCH 818/947] Differ device and domain entities in bosch_shc integration (#67957) --- .../components/bosch_shc/__init__.py | 7 +- homeassistant/components/bosch_shc/entity.py | 81 +++++++++++++++---- 2 files changed, 70 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/bosch_shc/__init__.py b/homeassistant/components/bosch_shc/__init__.py index 2b95702e44c..4d076a784d1 100644 --- a/homeassistant/components/bosch_shc/__init__.py +++ b/homeassistant/components/bosch_shc/__init__.py @@ -19,7 +19,12 @@ from .const import ( DOMAIN, ) -PLATFORMS = [Platform.BINARY_SENSOR, Platform.COVER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BINARY_SENSOR, + Platform.COVER, + Platform.SENSOR, + Platform.SWITCH, +] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/bosch_shc/entity.py b/homeassistant/components/bosch_shc/entity.py index c3a981aa658..4ef0e37132d 100644 --- a/homeassistant/components/bosch_shc/entity.py +++ b/homeassistant/components/bosch_shc/entity.py @@ -1,5 +1,7 @@ """Bosch Smart Home Controller base entity.""" -from boschshcpy.device import SHCDevice +from __future__ import annotations + +from boschshcpy import SHCDevice, SHCIntrusionSystem from homeassistant.helpers.device_registry import async_get as get_dev_reg from homeassistant.helpers.entity import DeviceInfo, Entity @@ -7,7 +9,7 @@ from homeassistant.helpers.entity import DeviceInfo, Entity from .const import DOMAIN -async def async_remove_devices(hass, entity, entry_id): +async def async_remove_devices(hass, entity, entry_id) -> None: """Get item that is removed from session.""" dev_registry = get_dev_reg(hass) device = dev_registry.async_get_device( @@ -17,16 +19,42 @@ async def async_remove_devices(hass, entity, entry_id): dev_registry.async_update_device(device.id, remove_config_entry_id=entry_id) -class SHCEntity(Entity): - """Representation of a SHC base entity.""" +class SHCBaseEntity(Entity): + """Base representation of a SHC entity.""" _attr_should_poll = False - def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + def __init__( + self, device: SHCDevice | SHCIntrusionSystem, parent_id: str, entry_id: str + ) -> None: """Initialize the generic SHC device.""" self._device = device self._entry_id = entry_id self._attr_name = device.name + + async def async_added_to_hass(self) -> None: + """Subscribe to SHC events.""" + await super().async_added_to_hass() + + def on_state_changed() -> None: + if self._device.deleted: + self.hass.add_job(async_remove_devices(self.hass, self, self._entry_id)) + else: + self.schedule_update_ha_state() + + self._device.subscribe_callback(self.entity_id, on_state_changed) + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe from SHC events.""" + await super().async_will_remove_from_hass() + self._device.unsubscribe_callback(self.entity_id) + + +class SHCEntity(SHCBaseEntity): + """Representation of a SHC device entity.""" + + def __init__(self, device: SHCDevice, parent_id: str, entry_id: str) -> None: + """Initialize generic SHC device.""" self._attr_unique_id = device.serial self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.id)}, @@ -40,32 +68,51 @@ class SHCEntity(Entity): else parent_id, ), ) + super().__init__(device=device, parent_id=parent_id, entry_id=entry_id) - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Subscribe to SHC events.""" await super().async_added_to_hass() - def on_state_changed(): + def on_state_changed() -> None: self.schedule_update_ha_state() - def update_entity_information(): - if self._device.deleted: - self.hass.add_job(async_remove_devices(self.hass, self, self._entry_id)) - else: - self.schedule_update_ha_state() - for service in self._device.device_services: service.subscribe_callback(self.entity_id, on_state_changed) - self._device.subscribe_callback(self.entity_id, update_entity_information) - async def async_will_remove_from_hass(self): + async def async_will_remove_from_hass(self) -> None: """Unsubscribe from SHC events.""" await super().async_will_remove_from_hass() for service in self._device.device_services: service.unsubscribe_callback(self.entity_id) - self._device.unsubscribe_callback(self.entity_id) @property - def available(self): + def available(self) -> bool: """Return false if status is unavailable.""" return self._device.status == "AVAILABLE" + + +class SHCDomainEntity(SHCBaseEntity): + """Representation of a SHC domain service entity.""" + + def __init__( + self, domain: SHCIntrusionSystem, parent_id: str, entry_id: str + ) -> None: + """Initialize the generic SHC device.""" + self._attr_unique_id = domain.id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, domain.id)}, + manufacturer=domain.manufacturer, + model=domain.device_model, + name=domain.name, + via_device=( + DOMAIN, + parent_id, + ), + ) + super().__init__(device=domain, parent_id=parent_id, entry_id=entry_id) + + @property + def available(self) -> bool: + """Return false if status is unavailable.""" + return self._device.system_availability From 00810235c92b492a966c6021021d49360ffb3cdd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 09:38:35 +0200 Subject: [PATCH 819/947] Track tasks adding entities (#73828) * Track tasks adding entities * Update homeassistant/config_entries.py * fix cast tests Co-authored-by: J. Nick Koston --- homeassistant/config_entries.py | 37 ++++++++++++++++++---- homeassistant/helpers/entity_platform.py | 17 +++++++++- tests/components/cast/test_media_player.py | 2 +- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 2aa5b1b8c62..c832bab7eb4 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -73,6 +73,7 @@ PATH_CONFIG = ".config_entries.json" SAVE_DELAY = 1 _T = TypeVar("_T", bound="ConfigEntryState") +_R = TypeVar("_R") class ConfigEntryState(Enum): @@ -193,6 +194,7 @@ class ConfigEntry: "_async_cancel_retry_setup", "_on_unload", "reload_lock", + "_pending_tasks", ) def __init__( @@ -285,6 +287,8 @@ class ConfigEntry: # Reload lock to prevent conflicting reloads self.reload_lock = asyncio.Lock() + self._pending_tasks: list[asyncio.Future[Any]] = [] + async def async_setup( self, hass: HomeAssistant, @@ -366,7 +370,7 @@ class ConfigEntry: self.domain, auth_message, ) - self._async_process_on_unload() + await self._async_process_on_unload() self.async_start_reauth(hass) result = False except ConfigEntryNotReady as ex: @@ -406,7 +410,7 @@ class ConfigEntry: EVENT_HOMEASSISTANT_STARTED, setup_again ) - self._async_process_on_unload() + await self._async_process_on_unload() return except Exception: # pylint: disable=broad-except _LOGGER.exception( @@ -494,7 +498,7 @@ class ConfigEntry: self.state = ConfigEntryState.NOT_LOADED self.reason = None - self._async_process_on_unload() + await self._async_process_on_unload() # https://github.com/python/mypy/issues/11839 return result # type: ignore[no-any-return] @@ -619,13 +623,18 @@ class ConfigEntry: self._on_unload = [] self._on_unload.append(func) - @callback - def _async_process_on_unload(self) -> None: - """Process the on_unload callbacks.""" + async def _async_process_on_unload(self) -> None: + """Process the on_unload callbacks and wait for pending tasks.""" if self._on_unload is not None: while self._on_unload: self._on_unload.pop()() + while self._pending_tasks: + pending = [task for task in self._pending_tasks if not task.done()] + self._pending_tasks.clear() + if pending: + await asyncio.gather(*pending) + @callback def async_start_reauth(self, hass: HomeAssistant) -> None: """Start a reauth flow.""" @@ -648,6 +657,22 @@ class ConfigEntry: ) ) + @callback + def async_create_task( + self, hass: HomeAssistant, target: Coroutine[Any, Any, _R] + ) -> asyncio.Task[_R]: + """Create a task from within the eventloop. + + This method must be run in the event loop. + + target: target to call. + """ + task = hass.async_create_task(target) + + self._pending_tasks.append(task) + + return task + current_entry: ContextVar[ConfigEntry | None] = ContextVar( "current_entry", default=None diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index ec71778af12..6253b939bed 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -214,8 +214,9 @@ class EntityPlatform: def async_create_setup_task() -> Coroutine: """Get task to set up platform.""" config_entries.current_entry.set(config_entry) + return platform.async_setup_entry( # type: ignore[no-any-return,union-attr] - self.hass, config_entry, self._async_schedule_add_entities + self.hass, config_entry, self._async_schedule_add_entities_for_entry ) return await self._async_setup_platform(async_create_setup_task) @@ -334,6 +335,20 @@ class EntityPlatform: if not self._setup_complete: self._tasks.append(task) + @callback + def _async_schedule_add_entities_for_entry( + self, new_entities: Iterable[Entity], update_before_add: bool = False + ) -> None: + """Schedule adding entities for a single platform async and track the task.""" + assert self.config_entry + task = self.config_entry.async_create_task( + self.hass, + self.async_add_entities(new_entities, update_before_add=update_before_add), + ) + + if not self._setup_complete: + self._tasks.append(task) + def add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index e4df84f6443..00626cc8c16 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -127,7 +127,7 @@ async def async_setup_cast(hass, config=None): config = {} data = {**{"ignore_cec": [], "known_hosts": [], "uuid": []}, **config} with patch( - "homeassistant.helpers.entity_platform.EntityPlatform._async_schedule_add_entities" + "homeassistant.helpers.entity_platform.EntityPlatform._async_schedule_add_entities_for_entry" ) as add_entities: entry = MockConfigEntry(data=data, domain="cast") entry.add_to_hass(hass) From aca0fd317840df4f9b76c820c1757130b4872cf1 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 09:46:05 +0200 Subject: [PATCH 820/947] Adjust type hints in rflink cover (#73946) * Adjust type hints in rflink cover * Move definition back to init * Use attributes * Revert "Use attributes" This reverts commit ff4851015d5e15b1b1304554228ca274d586977d. * Use _attr_should_poll --- homeassistant/components/rflink/__init__.py | 10 ++++------ homeassistant/components/rflink/cover.py | 11 +++-------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index 563ecedec3d..7bc87de9f46 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -1,4 +1,6 @@ """Support for Rflink devices.""" +from __future__ import annotations + import asyncio from collections import defaultdict import logging @@ -315,8 +317,9 @@ class RflinkDevice(Entity): """ platform = None - _state = None + _state: bool | None = None _available = True + _attr_should_poll = False def __init__( self, @@ -369,11 +372,6 @@ class RflinkDevice(Entity): """Platform specific event handler.""" raise NotImplementedError() - @property - def should_poll(self): - """No polling needed.""" - return False - @property def name(self): """Return a name for the device.""" diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py index 91e68fa0fb8..9492611f439 100644 --- a/homeassistant/components/rflink/cover.py +++ b/homeassistant/components/rflink/cover.py @@ -125,7 +125,7 @@ async def async_setup_platform( class RflinkCover(RflinkCommand, CoverEntity, RestoreEntity): """Rflink entity which can switch on/stop/off (eg: cover).""" - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Restore RFLink cover state (OPEN/CLOSE).""" await super().async_added_to_hass() if (old_state := await self.async_get_last_state()) is not None: @@ -142,17 +142,12 @@ class RflinkCover(RflinkCommand, CoverEntity, RestoreEntity): self._state = False @property - def should_poll(self): - """No polling available in RFlink cover.""" - return False - - @property - def is_closed(self): + def is_closed(self) -> bool | None: """Return if the cover is closed.""" return not self._state @property - def assumed_state(self): + def assumed_state(self) -> bool: """Return True because covers can be stopped midway.""" return True From 20680535ecd8a4d653b806828b1bf23681622129 Mon Sep 17 00:00:00 2001 From: Maximilian <43999966+DeerMaximum@users.noreply.github.com> Date: Wed, 29 Jun 2022 09:52:21 +0200 Subject: [PATCH 821/947] Add options flow to NINA (#65890) * Added options flow * Resolve conflicts * Fix lint * Implement improvements --- homeassistant/components/nina/__init__.py | 12 + homeassistant/components/nina/config_flow.py | 209 ++++++++++++++---- homeassistant/components/nina/strings.json | 22 ++ .../components/nina/translations/en.json | 22 ++ tests/components/nina/__init__.py | 12 +- .../nina/fixtures/sample_regions.json | 37 +++- tests/components/nina/test_config_flow.py | 194 +++++++++++++++- 7 files changed, 453 insertions(+), 55 deletions(-) diff --git a/homeassistant/components/nina/__init__.py b/homeassistant/components/nina/__init__.py index 16b6d01b8c2..c5375c96785 100644 --- a/homeassistant/components/nina/__init__.py +++ b/homeassistant/components/nina/__init__.py @@ -42,6 +42,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_config_entry_first_refresh() + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator hass.config_entries.async_setup_platforms(entry, PLATFORMS) @@ -49,6 +51,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + class NINADataUpdateCoordinator(DataUpdateCoordinator): """Class to manage fetching NINA data API.""" diff --git a/homeassistant/components/nina/config_flow.py b/homeassistant/components/nina/config_flow.py index 0574de96681..aa06b00e0ad 100644 --- a/homeassistant/components/nina/config_flow.py +++ b/homeassistant/components/nina/config_flow.py @@ -7,9 +7,14 @@ from pynina import ApiError, Nina import voluptuous as vol from homeassistant import config_entries +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get, +) from .const import ( _LOGGER, @@ -22,6 +27,58 @@ from .const import ( ) +def swap_key_value(dict_to_sort: dict[str, str]) -> dict[str, str]: + """Swap keys and values in dict.""" + all_region_codes_swaped: dict[str, str] = {} + + for key, value in dict_to_sort.items(): + if value not in all_region_codes_swaped: + all_region_codes_swaped[value] = key + else: + for i in range(len(dict_to_sort)): + tmp_value: str = f"{value}_{i}" + if tmp_value not in all_region_codes_swaped: + all_region_codes_swaped[tmp_value] = key + break + + return dict(sorted(all_region_codes_swaped.items(), key=lambda ele: ele[1])) + + +def split_regions( + _all_region_codes_sorted: dict[str, str], regions: dict[str, dict[str, Any]] +) -> dict[str, dict[str, Any]]: + """Split regions alphabetical.""" + for index, name in _all_region_codes_sorted.items(): + for region_name, grouping_letters in CONST_REGION_MAPPING.items(): + if name[0] in grouping_letters: + regions[region_name][index] = name + break + return regions + + +def prepare_user_input( + user_input: dict[str, Any], _all_region_codes_sorted: dict[str, str] +) -> dict[str, Any]: + """Prepare the user inputs.""" + tmp: dict[str, Any] = {} + + for reg in user_input[CONF_REGIONS]: + tmp[_all_region_codes_sorted[reg]] = reg.split("_", 1)[0] + + compact: dict[str, Any] = {} + + for key, val in tmp.items(): + if val in compact: + # Abenberg, St + Abenberger Wald + compact[val] = f"{compact[val]} + {key}" + break + compact[val] = key + + user_input[CONF_REGIONS] = compact + + return user_input + + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for NINA.""" @@ -50,7 +107,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): nina: Nina = Nina(async_get_clientsession(self.hass)) try: - self._all_region_codes_sorted = self.swap_key_value( + self._all_region_codes_sorted = swap_key_value( await nina.getAllRegionalCodes() ) except ApiError: @@ -59,7 +116,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.exception("Unexpected exception: %s", err) return self.async_abort(reason="unknown") - self.split_regions() + self.regions = split_regions(self._all_region_codes_sorted, self.regions) if user_input is not None and not errors: user_input[CONF_REGIONS] = [] @@ -69,23 +126,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_REGIONS] += group_input if user_input[CONF_REGIONS]: - tmp: dict[str, Any] = {} - for reg in user_input[CONF_REGIONS]: - tmp[self._all_region_codes_sorted[reg]] = reg.split("_", 1)[0] - - compact: dict[str, Any] = {} - - for key, val in tmp.items(): - if val in compact: - # Abenberg, St + Abenberger Wald - compact[val] = f"{compact[val]} + {key}" - break - compact[val] = key - - user_input[CONF_REGIONS] = compact - - return self.async_create_entry(title="NINA", data=user_input) + return self.async_create_entry( + title="NINA", + data=prepare_user_input(user_input, self._all_region_codes_sorted), + ) errors["base"] = "no_selection" @@ -107,26 +152,114 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) @staticmethod - def swap_key_value(dict_to_sort: dict[str, str]) -> dict[str, str]: - """Swap keys and values in dict.""" - all_region_codes_swaped: dict[str, str] = {} + @callback + def async_get_options_flow(config_entry): + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) - for key, value in dict_to_sort.items(): - if value not in all_region_codes_swaped: - all_region_codes_swaped[value] = key - else: - for i in range(len(dict_to_sort)): - tmp_value: str = f"{value}_{i}" - if tmp_value not in all_region_codes_swaped: - all_region_codes_swaped[tmp_value] = key - break - return dict(sorted(all_region_codes_swaped.items(), key=lambda ele: ele[1])) +class OptionsFlowHandler(config_entries.OptionsFlow): + """Handle a option flow for nut.""" - def split_regions(self) -> None: - """Split regions alphabetical.""" - for index, name in self._all_region_codes_sorted.items(): - for region_name, grouping_letters in CONST_REGION_MAPPING.items(): - if name[0] in grouping_letters: - self.regions[region_name][index] = name - break + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + self.data = dict(self.config_entry.data) + + self._all_region_codes_sorted: dict[str, str] = {} + self.regions: dict[str, dict[str, Any]] = {} + + for name in CONST_REGIONS: + self.regions[name] = {} + if name not in self.data: + self.data[name] = [] + + async def async_step_init(self, user_input=None): + """Handle options flow.""" + errors: dict[str, Any] = {} + + if not self._all_region_codes_sorted: + nina: Nina = Nina(async_get_clientsession(self.hass)) + + try: + self._all_region_codes_sorted = swap_key_value( + await nina.getAllRegionalCodes() + ) + except ApiError: + errors["base"] = "cannot_connect" + except Exception as err: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", err) + return self.async_abort(reason="unknown") + + self.regions = split_regions(self._all_region_codes_sorted, self.regions) + + if user_input is not None and not errors: + user_input[CONF_REGIONS] = [] + + for group in CONST_REGIONS: + if group_input := user_input.get(group): + user_input[CONF_REGIONS] += group_input + + if user_input[CONF_REGIONS]: + + user_input = prepare_user_input( + user_input, self._all_region_codes_sorted + ) + + entity_registry = async_get(self.hass) + + entries = async_entries_for_config_entry( + entity_registry, self.config_entry.entry_id + ) + + removed_entities_slots = [ + f"{region}-{slot_id}" + for region in self.data[CONF_REGIONS] + for slot_id in range(0, self.data[CONF_MESSAGE_SLOTS] + 1) + if slot_id > user_input[CONF_MESSAGE_SLOTS] + ] + + removed_entites_area = [ + f"{cfg_region}-{slot_id}" + for slot_id in range(1, self.data[CONF_MESSAGE_SLOTS] + 1) + for cfg_region in self.data[CONF_REGIONS] + if cfg_region not in user_input[CONF_REGIONS] + ] + + for entry in entries: + for entity_uid in list( + set(removed_entities_slots + removed_entites_area) + ): + if entry.unique_id == entity_uid: + entity_registry.async_remove(entry.entity_id) + + self.hass.config_entries.async_update_entry( + self.config_entry, data=user_input + ) + + return self.async_create_entry(title="", data=None) + + errors["base"] = "no_selection" + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + **{ + vol.Optional( + region, default=self.data[region] + ): cv.multi_select(self.regions[region]) + for region in CONST_REGIONS + }, + vol.Required( + CONF_MESSAGE_SLOTS, + default=self.data[CONF_MESSAGE_SLOTS], + ): vol.All(int, vol.Range(min=1, max=20)), + vol.Required( + CONF_FILTER_CORONA, + default=self.data[CONF_FILTER_CORONA], + ): cv.boolean, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/nina/strings.json b/homeassistant/components/nina/strings.json index 49ecf7fa7fa..b22c2640084 100644 --- a/homeassistant/components/nina/strings.json +++ b/homeassistant/components/nina/strings.json @@ -23,5 +23,27 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "options": { + "step": { + "init": { + "title": "Options", + "data": { + "_a_to_d": "City/county (A-D)", + "_e_to_h": "City/county (E-H)", + "_i_to_l": "City/county (I-L)", + "_m_to_q": "City/county (M-Q)", + "_r_to_u": "City/county (R-U)", + "_v_to_z": "City/county (V-Z)", + "slots": "Maximum warnings per city/county", + "corona_filter": "Remove Corona Warnings" + } + } + }, + "error": { + "no_selection": "Please select at least one city/county", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } } } diff --git a/homeassistant/components/nina/translations/en.json b/homeassistant/components/nina/translations/en.json index 793cbf595f1..0e46b30512d 100644 --- a/homeassistant/components/nina/translations/en.json +++ b/homeassistant/components/nina/translations/en.json @@ -23,5 +23,27 @@ "title": "Select city/county" } } + }, + "options": { + "error": { + "cannot_connect": "Failed to connect", + "no_selection": "Please select at least one city/county", + "unknown": "Unexpected error" + }, + "step": { + "init": { + "data": { + "_a_to_d": "City/county (A-D)", + "_e_to_h": "City/county (E-H)", + "_i_to_l": "City/county (I-L)", + "_m_to_q": "City/county (M-Q)", + "_r_to_u": "City/county (R-U)", + "_v_to_z": "City/county (V-Z)", + "corona_filter": "Remove Corona Warnings", + "slots": "Maximum warnings per city/county" + }, + "title": "Options" + } + } } } \ No newline at end of file diff --git a/tests/components/nina/__init__.py b/tests/components/nina/__init__.py index d6c9fffdfa7..da09b0ba17b 100644 --- a/tests/components/nina/__init__.py +++ b/tests/components/nina/__init__.py @@ -15,9 +15,19 @@ def mocked_request_function(url: str) -> dict[str, Any]: load_fixture("sample_warning_details.json", "nina") ) - if url == "https://warnung.bund.de/api31/dashboard/083350000000.json": + dummy_response_regions: dict[str, Any] = json.loads( + load_fixture("sample_regions.json", "nina") + ) + + if "https://warnung.bund.de/api31/dashboard/" in url: return dummy_response + if ( + url + == "https://www.xrepository.de/api/xrepository/urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31/download/Regionalschl_ssel_2021-07-31.json" + ): + return dummy_response_regions + warning_id = url.replace("https://warnung.bund.de/api31/warnings/", "").replace( ".json", "" ) diff --git a/tests/components/nina/fixtures/sample_regions.json b/tests/components/nina/fixtures/sample_regions.json index 4fbc0638604..140feb39c3b 100644 --- a/tests/components/nina/fixtures/sample_regions.json +++ b/tests/components/nina/fixtures/sample_regions.json @@ -3,14 +3,28 @@ "kennung": "urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs_2021-07-31", "kennungInhalt": "urn:de:bund:destatis:bevoelkerungsstatistik:schluessel:rs", "version": "2021-07-31", - "nameKurz": "Regionalschlüssel", - "nameLang": "Gemeinden, dargestellt durch den Amtlichen Regionalschlüssel (ARS) des Statistischen Bundesamtes", + "nameKurz": [{ "value": "Regionalschlüssel", "lang": null }], + "nameLang": [ + { + "value": "Gemeinden, dargestellt durch den Amtlichen Regionalschlüssel (ARS) des Statistischen Bundesamtes", + "lang": null + } + ], "nameTechnisch": "Regionalschluessel", - "herausgebernameLang": "Statistisches Bundesamt, Wiesbaden", - "herausgebernameKurz": "Destatis", - "beschreibung": "Diese Codeliste stellt alle Gemeinden Deutschlands durch den Amtlichen Regionalschlüssel (ARS) dar, wie im Gemeindeverzeichnis des Statistischen Bundesamtes enthalten. Darüber hinaus enthält die Codeliste für die Stadtstaaten Hamburg, Bremen und Berlin Einträge für Stadt-/Ortsteile bzw. Stadtbezirke. Diese Einträge sind mit einem entsprechenden Hinweis versehen.", - "versionBeschreibung": null, - "aenderungZurVorversion": "Mehrere Aenderungen", + "herausgebernameLang": [ + { "value": "Statistisches Bundesamt, Wiesbaden", "lang": null } + ], + "herausgebernameKurz": [{ "value": "Destatis", "lang": null }], + "beschreibung": [ + { + "value": "Diese Codeliste stellt alle Gemeinden Deutschlands durch den Amtlichen Regionalschlüssel (ARS) dar, wie im Gemeindeverzeichnis des Statistischen Bundesamtes enthalten. Darüber hinaus enthält die Codeliste für die Stadtstaaten Hamburg, Bremen und Berlin Einträge für Stadt-/Ortsteile bzw. Stadtbezirke. Diese Einträge sind mit einem entsprechenden Hinweis versehen.", + "lang": null + } + ], + "versionBeschreibung": [], + "aenderungZurVorversion": [ + { "value": "Mehrere Aenderungen", "lang": null } + ], "handbuchVersion": "1.0", "xoevHandbuch": false, "gueltigAb": 1627682400000, @@ -23,7 +37,8 @@ "datentyp": "string", "codeSpalte": true, "verwendung": { "code": "REQUIRED" }, - "empfohleneCodeSpalte": true + "empfohleneCodeSpalte": true, + "sprache": null }, { "spaltennameLang": "Bezeichnung", @@ -31,7 +46,8 @@ "datentyp": "string", "codeSpalte": false, "verwendung": { "code": "REQUIRED" }, - "empfohleneCodeSpalte": false + "empfohleneCodeSpalte": false, + "sprache": null }, { "spaltennameLang": "Hinweis", @@ -39,7 +55,8 @@ "datentyp": "string", "codeSpalte": false, "verwendung": { "code": "OPTIONAL" }, - "empfohleneCodeSpalte": false + "empfohleneCodeSpalte": false, + "sprache": null } ], "daten": [ diff --git a/tests/components/nina/test_config_flow.py b/tests/components/nina/test_config_flow.py index a1aa97e0fbe..1578991ba11 100644 --- a/tests/components/nina/test_config_flow.py +++ b/tests/components/nina/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant import data_entry_flow from homeassistant.components.nina.const import ( CONF_FILTER_CORONA, CONF_MESSAGE_SLOTS, + CONF_REGIONS, CONST_REGION_A_TO_D, CONST_REGION_E_TO_H, CONST_REGION_I_TO_L, @@ -21,8 +22,11 @@ from homeassistant.components.nina.const import ( ) from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from tests.common import load_fixture +from . import mocked_request_function + +from tests.common import MockConfigEntry, load_fixture DUMMY_DATA: dict[str, Any] = { CONF_MESSAGE_SLOTS: 5, @@ -35,14 +39,19 @@ DUMMY_DATA: dict[str, Any] = { CONF_FILTER_CORONA: True, } -DUMMY_RESPONSE: dict[str, Any] = json.loads(load_fixture("sample_regions.json", "nina")) +DUMMY_RESPONSE_REGIONS: dict[str, Any] = json.loads( + load_fixture("sample_regions.json", "nina") +) +DUMMY_RESPONSE_WARNIGNS: dict[str, Any] = json.loads( + load_fixture("sample_warnings.json", "nina") +) async def test_show_set_form(hass: HomeAssistant) -> None: """Test that the setup form is served.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -86,7 +95,7 @@ async def test_step_user(hass: HomeAssistant) -> None: """Test starting a flow by user with valid values.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ), patch( "homeassistant.components.nina.async_setup_entry", return_value=True, @@ -104,7 +113,7 @@ async def test_step_user_no_selection(hass: HomeAssistant) -> None: """Test starting a flow by user with no selection.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( @@ -120,7 +129,7 @@ async def test_step_user_already_configured(hass: HomeAssistant) -> None: """Test starting a flow by user but it was already configured.""" with patch( "pynina.baseApi.BaseAPI._makeRequest", - return_value=DUMMY_RESPONSE, + wraps=mocked_request_function, ): result: dict[str, Any] = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=DUMMY_DATA @@ -132,3 +141,176 @@ async def test_step_user_already_configured(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "single_instance_allowed" + + +async def test_options_flow_init(hass: HomeAssistant) -> None: + """Test config flow options.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data={ + CONF_FILTER_CORONA: DUMMY_DATA[CONF_FILTER_CORONA], + CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS], + CONST_REGION_A_TO_D: DUMMY_DATA[CONST_REGION_A_TO_D], + CONF_REGIONS: {"095760000000": "Aach"}, + }, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nina.async_setup_entry", return_value=True + ), patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONST_REGION_A_TO_D: ["072350000000_1"], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] is None + + assert dict(config_entry.data) == { + CONF_FILTER_CORONA: DUMMY_DATA[CONF_FILTER_CORONA], + CONF_MESSAGE_SLOTS: DUMMY_DATA[CONF_MESSAGE_SLOTS], + CONST_REGION_A_TO_D: ["072350000000_1"], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + CONF_REGIONS: { + "072350000000": "Damflos (Trier-Saarburg - Rheinland-Pfalz)" + }, + } + + +async def test_options_flow_with_no_selection(hass: HomeAssistant) -> None: + """Test config flow options with no selection.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.nina.async_setup_entry", return_value=True + ), patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONST_REGION_A_TO_D: [], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert result["errors"] == {"base": "no_selection"} + + +async def test_options_flow_connection_error(hass: HomeAssistant) -> None: + """Test config flow options but no connection.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=ApiError("Could not connect to Api"), + ): + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_options_flow_unexpected_exception(hass: HomeAssistant) -> None: + """Test config flow options but with an unexpected exception.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + side_effect=Exception("DUMMY"), + ): + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_options_flow_entity_removal(hass: HomeAssistant) -> None: + """Test if old entities are removed.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + title="NINA", + data=DUMMY_DATA, + ) + config_entry.add_to_hass(hass) + + with patch( + "pynina.baseApi.BaseAPI._makeRequest", + wraps=mocked_request_function, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_MESSAGE_SLOTS: 2, + CONST_REGION_A_TO_D: ["072350000000", "095760000000"], + CONST_REGION_E_TO_H: [], + CONST_REGION_I_TO_L: [], + CONST_REGION_M_TO_Q: [], + CONST_REGION_R_TO_U: [], + CONST_REGION_V_TO_Z: [], + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + entity_registry: er = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + + assert len(entries) == 2 From 6a0ca2b36d247cdc7656e90b559c5b91d2f6a83b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 10:04:22 +0200 Subject: [PATCH 822/947] Migrate kostal_plenticore number to native_* (#74159) --- .../components/kostal_plenticore/const.py | 16 ++++++++-------- .../components/kostal_plenticore/number.py | 8 ++++---- .../components/kostal_plenticore/test_number.py | 6 +++--- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/const.py b/homeassistant/components/kostal_plenticore/const.py index ba850ed58bd..11bb794f799 100644 --- a/homeassistant/components/kostal_plenticore/const.py +++ b/homeassistant/components/kostal_plenticore/const.py @@ -818,10 +818,10 @@ NUMBER_SETTINGS_DATA = [ entity_registry_enabled_default=False, icon="mdi:battery-negative", name="Battery min SoC", - unit_of_measurement=PERCENTAGE, - max_value=100, - min_value=5, - step=5, + native_unit_of_measurement=PERCENTAGE, + native_max_value=100, + native_min_value=5, + native_step=5, module_id="devices:local", data_id="Battery:MinSoc", fmt_from="format_round", @@ -833,10 +833,10 @@ NUMBER_SETTINGS_DATA = [ entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, name="Battery min Home Consumption", - unit_of_measurement=POWER_WATT, - max_value=38000, - min_value=50, - step=1, + native_unit_of_measurement=POWER_WATT, + native_max_value=38000, + native_min_value=50, + native_step=1, module_id="devices:local", data_id="Battery:MinHomeComsumption", fmt_from="format_round", diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 33d431d6396..1ad911f6d15 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -105,9 +105,9 @@ class PlenticoreDataNumber(CoordinatorEntity, NumberEntity, ABC): # overwrite from retrieved setting data if setting_data.min is not None: - self._attr_min_value = self._formatter(setting_data.min) + self._attr_native_min_value = self._formatter(setting_data.min) if setting_data.max is not None: - self._attr_max_value = self._formatter(setting_data.max) + self._attr_native_max_value = self._formatter(setting_data.max) @property def module_id(self) -> str: @@ -140,7 +140,7 @@ class PlenticoreDataNumber(CoordinatorEntity, NumberEntity, ABC): await super().async_will_remove_from_hass() @property - def value(self) -> float | None: + def native_value(self) -> float | None: """Return the current value.""" if self.available: raw_value = self.coordinator.data[self.module_id][self.data_id] @@ -148,7 +148,7 @@ class PlenticoreDataNumber(CoordinatorEntity, NumberEntity, ABC): return None - async def async_set_value(self, value: float) -> None: + async def async_set_native_value(self, value: float) -> None: """Set a new value.""" str_value = self._formatter_back(value) await self.coordinator.async_write_data( diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index 43d693642d9..f0fb42f6e78 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -31,8 +31,8 @@ def mock_number_description() -> PlenticoreNumberEntityDescription: key="mock key", module_id="moduleid", data_id="dataid", - min_value=0, - max_value=1000, + native_min_value=0, + native_max_value=1000, fmt_from="format_round", fmt_to="format_round_back", ) @@ -136,7 +136,7 @@ async def test_set_value( mock_coordinator, "42", "scb", None, mock_number_description, mock_setting_data ) - await entity.async_set_value(42) + await entity.async_set_native_value(42) mock_coordinator.async_write_data.assert_called_once_with( "moduleid", {"dataid": "42"} From 99329ef04f8c0276ee247e03fe7d92de366f4b94 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jun 2022 03:13:10 -0500 Subject: [PATCH 823/947] Wait for discovery to complete before starting apple_tv (#74133) --- homeassistant/components/apple_tv/__init__.py | 105 +++++++++++------- 1 file changed, 63 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index d61c21972fb..45250451f37 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -49,6 +50,13 @@ PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a config entry for Apple TV.""" manager = AppleTVManager(hass, entry) + + if manager.is_on: + await manager.connect_once(raise_missing_credentials=True) + if not manager.atv: + address = entry.data[CONF_ADDRESS] + raise ConfigEntryNotReady(f"Not found at {address}, waiting for discovery") + hass.data.setdefault(DOMAIN, {})[entry.unique_id] = manager async def on_hass_stop(event): @@ -148,14 +156,14 @@ class AppleTVManager: self.config_entry = config_entry self.hass = hass self.atv = None - self._is_on = not config_entry.options.get(CONF_START_OFF, False) + self.is_on = not config_entry.options.get(CONF_START_OFF, False) self._connection_attempts = 0 self._connection_was_lost = False self._task = None async def init(self): """Initialize power management.""" - if self._is_on: + if self.is_on: await self.connect() def connection_lost(self, _): @@ -186,13 +194,13 @@ class AppleTVManager: async def connect(self): """Connect to device.""" - self._is_on = True + self.is_on = True self._start_connect_loop() async def disconnect(self): """Disconnect from device.""" _LOGGER.debug("Disconnecting from device") - self._is_on = False + self.is_on = False try: if self.atv: self.atv.close() @@ -205,50 +213,53 @@ class AppleTVManager: def _start_connect_loop(self): """Start background connect loop to device.""" - if not self._task and self.atv is None and self._is_on: + if not self._task and self.atv is None and self.is_on: self._task = asyncio.create_task(self._connect_loop()) else: _LOGGER.debug( - "Not starting connect loop (%s, %s)", self.atv is None, self._is_on + "Not starting connect loop (%s, %s)", self.atv is None, self.is_on ) + async def connect_once(self, raise_missing_credentials): + """Try to connect once.""" + try: + if conf := await self._scan(): + await self._connect(conf, raise_missing_credentials) + except exceptions.AuthenticationError: + self.config_entry.async_start_reauth(self.hass) + asyncio.create_task(self.disconnect()) + _LOGGER.exception( + "Authentication failed for %s, try reconfiguring device", + self.config_entry.data[CONF_NAME], + ) + return + except asyncio.CancelledError: + pass + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Failed to connect") + self.atv = None + async def _connect_loop(self): """Connect loop background task function.""" _LOGGER.debug("Starting connect loop") # Try to find device and connect as long as the user has said that # we are allowed to connect and we are not already connected. - while self._is_on and self.atv is None: - try: - conf = await self._scan() - if conf: - await self._connect(conf) - except exceptions.AuthenticationError: - self.config_entry.async_start_reauth(self.hass) - asyncio.create_task(self.disconnect()) - _LOGGER.exception( - "Authentication failed for %s, try reconfiguring device", - self.config_entry.data[CONF_NAME], - ) + while self.is_on and self.atv is None: + await self.connect_once(raise_missing_credentials=False) + if self.atv is not None: break - except asyncio.CancelledError: - pass - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Failed to connect") - self.atv = None + self._connection_attempts += 1 + backoff = min( + max( + BACKOFF_TIME_LOWER_LIMIT, + randrange(2**self._connection_attempts), + ), + BACKOFF_TIME_UPPER_LIMIT, + ) - if self.atv is None: - self._connection_attempts += 1 - backoff = min( - max( - BACKOFF_TIME_LOWER_LIMIT, - randrange(2**self._connection_attempts), - ), - BACKOFF_TIME_UPPER_LIMIT, - ) - - _LOGGER.debug("Reconnecting in %d seconds", backoff) - await asyncio.sleep(backoff) + _LOGGER.debug("Reconnecting in %d seconds", backoff) + await asyncio.sleep(backoff) _LOGGER.debug("Connect loop ended") self._task = None @@ -287,23 +298,33 @@ class AppleTVManager: # it will update the address and reload the config entry when the device is found. return None - async def _connect(self, conf): + async def _connect(self, conf, raise_missing_credentials): """Connect to device.""" credentials = self.config_entry.data[CONF_CREDENTIALS] - session = async_get_clientsession(self.hass) - + name = self.config_entry.data[CONF_NAME] + missing_protocols = [] for protocol_int, creds in credentials.items(): protocol = Protocol(int(protocol_int)) if conf.get_service(protocol) is not None: conf.set_credentials(protocol, creds) else: - _LOGGER.warning( - "Protocol %s not found for %s, functionality will be reduced", - protocol.name, - self.config_entry.data[CONF_NAME], + missing_protocols.append(protocol.name) + + if missing_protocols: + missing_protocols_str = ", ".join(missing_protocols) + if raise_missing_credentials: + raise ConfigEntryNotReady( + f"Protocol(s) {missing_protocols_str} not yet found for {name}, waiting for discovery." ) + _LOGGER.info( + "Protocol(s) %s not yet found for %s, trying later", + missing_protocols_str, + name, + ) + return _LOGGER.debug("Connecting to device %s", self.config_entry.data[CONF_NAME]) + session = async_get_clientsession(self.hass) self.atv = await connect(conf, self.hass.loop, session=session) self.atv.listener = self From f45afe737979726b2b85c5f66dccd02a33c570a4 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Wed, 29 Jun 2022 16:15:22 +0800 Subject: [PATCH 824/947] Use bitstream filter to allow ADTS AAC audio in stream (#74151) --- .../components/generic/manifest.json | 2 +- homeassistant/components/stream/manifest.json | 2 +- homeassistant/components/stream/worker.py | 52 ++++++++++++------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/stream/test_worker.py | 19 ------- 6 files changed, 36 insertions(+), 43 deletions(-) diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 3e8e7717a10..5ef47f0c941 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -2,7 +2,7 @@ "domain": "generic", "name": "Generic Camera", "config_flow": true, - "requirements": ["ha-av==10.0.0b3", "pillow==9.1.1"], + "requirements": ["ha-av==10.0.0b4", "pillow==9.1.1"], "documentation": "https://www.home-assistant.io/integrations/generic", "codeowners": ["@davet2001"], "iot_class": "local_push" diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index eb525700bb0..e9411e53224 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "documentation": "https://www.home-assistant.io/integrations/stream", - "requirements": ["PyTurboJPEG==1.6.6", "ha-av==10.0.0b3"], + "requirements": ["PyTurboJPEG==1.6.6", "ha-av==10.0.0b4"], "dependencies": ["http"], "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], "quality_scale": "internal", diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py index 1d29bd17c33..e46d83542f7 100644 --- a/homeassistant/components/stream/worker.py +++ b/homeassistant/components/stream/worker.py @@ -108,6 +108,7 @@ class StreamMuxer: hass: HomeAssistant, video_stream: av.video.VideoStream, audio_stream: av.audio.stream.AudioStream | None, + audio_bsf: av.BitStreamFilterContext | None, stream_state: StreamState, stream_settings: StreamSettings, ) -> None: @@ -118,6 +119,7 @@ class StreamMuxer: self._av_output: av.container.OutputContainer = None self._input_video_stream: av.video.VideoStream = video_stream self._input_audio_stream: av.audio.stream.AudioStream | None = audio_stream + self._audio_bsf = audio_bsf self._output_video_stream: av.video.VideoStream = None self._output_audio_stream: av.audio.stream.AudioStream | None = None self._segment: Segment | None = None @@ -192,7 +194,9 @@ class StreamMuxer: # Check if audio is requested output_astream = None if input_astream: - output_astream = container.add_stream(template=input_astream) + output_astream = container.add_stream( + template=self._audio_bsf or input_astream + ) return container, output_vstream, output_astream def reset(self, video_dts: int) -> None: @@ -234,6 +238,12 @@ class StreamMuxer: self._part_has_keyframe |= packet.is_keyframe elif packet.stream == self._input_audio_stream: + if self._audio_bsf: + self._audio_bsf.send(packet) + while packet := self._audio_bsf.recv(): + packet.stream = self._output_audio_stream + self._av_output.mux(packet) + return packet.stream = self._output_audio_stream self._av_output.mux(packet) @@ -355,12 +365,6 @@ class PeekIterator(Iterator): """Return and consume the next item available.""" return self._next() - def replace_underlying_iterator(self, new_iterator: Iterator) -> None: - """Replace the underlying iterator while preserving the buffer.""" - self._iterator = new_iterator - if not self._buffer: - self._next = self._iterator.__next__ - def _pop_buffer(self) -> av.Packet: """Consume items from the buffer until exhausted.""" if self._buffer: @@ -422,10 +426,12 @@ def is_keyframe(packet: av.Packet) -> Any: return packet.is_keyframe -def unsupported_audio(packets: Iterator[av.Packet], audio_stream: Any) -> bool: - """Detect ADTS AAC, which is not supported by pyav.""" +def get_audio_bitstream_filter( + packets: Iterator[av.Packet], audio_stream: Any +) -> av.BitStreamFilterContext | None: + """Return the aac_adtstoasc bitstream filter if ADTS AAC is detected.""" if not audio_stream: - return False + return None for count, packet in enumerate(packets): if count >= PACKETS_TO_WAIT_FOR_AUDIO: # Some streams declare an audio stream and never send any packets @@ -436,10 +442,15 @@ def unsupported_audio(packets: Iterator[av.Packet], audio_stream: Any) -> bool: if audio_stream.codec.name == "aac" and packet.size > 2: with memoryview(packet) as packet_view: if packet_view[0] == 0xFF and packet_view[1] & 0xF0 == 0xF0: - _LOGGER.warning("ADTS AAC detected - disabling audio stream") - return True + _LOGGER.debug( + "ADTS AAC detected. Adding aac_adtstoaac bitstream filter" + ) + bsf = av.BitStreamFilter("aac_adtstoasc") + bsf_context = bsf.create() + bsf_context.set_input_stream(audio_stream) + return bsf_context break - return False + return None def stream_worker( @@ -500,12 +511,8 @@ def stream_worker( # Use a peeking iterator to peek into the start of the stream, ensuring # everything looks good, then go back to the start when muxing below. try: - if audio_stream and unsupported_audio(container_packets.peek(), audio_stream): - audio_stream = None - container_packets.replace_underlying_iterator( - filter(dts_validator.is_valid, container.demux(video_stream)) - ) - + # Get the required bitstream filter + audio_bsf = get_audio_bitstream_filter(container_packets.peek(), audio_stream) # Advance to the first keyframe for muxing, then rewind so the muxing # loop below can consume. first_keyframe = next( @@ -535,7 +542,12 @@ def stream_worker( ) from ex muxer = StreamMuxer( - stream_state.hass, video_stream, audio_stream, stream_state, stream_settings + stream_state.hass, + video_stream, + audio_stream, + audio_bsf, + stream_state, + stream_settings, ) muxer.reset(start_dts) diff --git a/requirements_all.txt b/requirements_all.txt index 8adf795c977..11ed4a2113a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -780,7 +780,7 @@ guppy3==3.1.2 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0b3 +ha-av==10.0.0b4 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9675da97f7..dc7366f151b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -559,7 +559,7 @@ guppy3==3.1.2 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0b3 +ha-av==10.0.0b4 # homeassistant.components.ffmpeg ha-ffmpeg==3.0.2 diff --git a/tests/components/stream/test_worker.py b/tests/components/stream/test_worker.py index 863a289c2c5..8717e23a476 100644 --- a/tests/components/stream/test_worker.py +++ b/tests/components/stream/test_worker.py @@ -552,25 +552,6 @@ async def test_audio_packets_not_found(hass): assert len(decoded_stream.audio_packets) == 0 -async def test_adts_aac_audio(hass): - """Set up an ADTS AAC audio stream and disable audio.""" - py_av = MockPyAv(audio=True) - - num_packets = PACKETS_TO_WAIT_FOR_AUDIO + 1 - packets = list(PacketSequence(num_packets)) - packets[1].stream = AUDIO_STREAM - packets[1].dts = int(packets[0].dts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - packets[1].pts = int(packets[0].pts / VIDEO_FRAME_RATE * AUDIO_SAMPLE_RATE) - # The following is packet data is a sign of ADTS AAC - packets[1][0] = 255 - packets[1][1] = 241 - - decoded_stream = await async_decode_stream(hass, packets, py_av=py_av) - assert len(decoded_stream.audio_packets) == 0 - # All decoded video packets are still preserved - assert len(decoded_stream.video_packets) == num_packets - 1 - - async def test_audio_is_first_packet(hass): """Set up an audio stream and audio packet is the first packet in the stream.""" py_av = MockPyAv(audio=True) From 0769b33e19ac81a875b16947d0485f7d24c71691 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 10:16:23 +0200 Subject: [PATCH 825/947] Migrate darksky to native_* (#74047) --- homeassistant/components/darksky/weather.py | 86 +++++++++------------ 1 file changed, 38 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py index 4965505150a..1bc56706007 100644 --- a/homeassistant/components/darksky/weather.py +++ b/homeassistant/components/darksky/weather.py @@ -21,12 +21,12 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity, ) @@ -36,10 +36,11 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MODE, CONF_NAME, - PRESSURE_HPA, - PRESSURE_INHG, + LENGTH_KILOMETERS, + LENGTH_MILLIMETERS, + PRESSURE_MBAR, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, - TEMP_FAHRENHEIT, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -47,7 +48,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import Throttle from homeassistant.util.dt import utc_from_timestamp -from homeassistant.util.pressure import convert as convert_pressure _LOGGER = logging.getLogger(__name__) @@ -75,15 +75,18 @@ CONF_UNITS = "units" DEFAULT_NAME = "Dark Sky" -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), - vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } +PLATFORM_SCHEMA = vol.All( + cv.removed(CONF_UNITS), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MODE, default="hourly"): vol.In(FORECAST_MODE), + vol.Optional(CONF_UNITS): vol.In(["auto", "si", "us", "ca", "uk", "uk2"]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } + ), ) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3) @@ -101,9 +104,7 @@ def setup_platform( name = config.get(CONF_NAME) mode = config.get(CONF_MODE) - if not (units := config.get(CONF_UNITS)): - units = "ca" if hass.config.units.is_metric else "us" - + units = "si" dark_sky = DarkSkyData(config.get(CONF_API_KEY), latitude, longitude, units) add_entities([DarkSkyWeather(name, dark_sky, mode)], True) @@ -112,6 +113,12 @@ def setup_platform( class DarkSkyWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_MBAR + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_visibility_unit = LENGTH_KILOMETERS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + def __init__(self, name, dark_sky, mode): """Initialize Dark Sky weather.""" self._name = name @@ -139,24 +146,17 @@ class DarkSkyWeather(WeatherEntity): return self._name @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self._ds_currently.get("temperature") - @property - def temperature_unit(self): - """Return the unit of measurement.""" - if self._dark_sky.units is None: - return None - return TEMP_FAHRENHEIT if "us" in self._dark_sky.units else TEMP_CELSIUS - @property def humidity(self): """Return the humidity.""" return round(self._ds_currently.get("humidity") * 100.0, 2) @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self._ds_currently.get("windSpeed") @@ -171,15 +171,12 @@ class DarkSkyWeather(WeatherEntity): return self._ds_currently.get("ozone") @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" - pressure = self._ds_currently.get("pressure") - if "us" in self._dark_sky.units: - return round(convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2) - return pressure + return self._ds_currently.get("pressure") @property - def visibility(self): + def native_visibility(self): """Return the visibility.""" return self._ds_currently.get("visibility") @@ -208,12 +205,12 @@ class DarkSkyWeather(WeatherEntity): ATTR_FORECAST_TIME: utc_from_timestamp( entry.d.get("time") ).isoformat(), - ATTR_FORECAST_TEMP: entry.d.get("temperatureHigh"), - ATTR_FORECAST_TEMP_LOW: entry.d.get("temperatureLow"), - ATTR_FORECAST_PRECIPITATION: calc_precipitation( + ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperatureHigh"), + ATTR_FORECAST_NATIVE_TEMP_LOW: entry.d.get("temperatureLow"), + ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( entry.d.get("precipIntensity"), 24 ), - ATTR_FORECAST_WIND_SPEED: entry.d.get("windSpeed"), + ATTR_FORECAST_NATIVE_WIND_SPEED: entry.d.get("windSpeed"), ATTR_FORECAST_WIND_BEARING: entry.d.get("windBearing"), ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), } @@ -225,8 +222,8 @@ class DarkSkyWeather(WeatherEntity): ATTR_FORECAST_TIME: utc_from_timestamp( entry.d.get("time") ).isoformat(), - ATTR_FORECAST_TEMP: entry.d.get("temperature"), - ATTR_FORECAST_PRECIPITATION: calc_precipitation( + ATTR_FORECAST_NATIVE_TEMP: entry.d.get("temperature"), + ATTR_FORECAST_NATIVE_PRECIPITATION: calc_precipitation( entry.d.get("precipIntensity"), 1 ), ATTR_FORECAST_CONDITION: MAP_CONDITION.get(entry.d.get("icon")), @@ -281,10 +278,3 @@ class DarkSkyData: self._connect_error = True _LOGGER.error("Unable to connect to Dark Sky. %s", error) self.data = None - - @property - def units(self): - """Get the unit system of returned data.""" - if self.data is None: - return None - return self.data.json.get("flags").get("units") From 6dc6e71f01be1757251f46c5c21abd47ef59bce0 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 10:19:39 +0200 Subject: [PATCH 826/947] Use attributes in manual alarm (#74122) --- .../components/manual/alarm_control_panel.py | 60 +++++++------------ 1 file changed, 22 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py index cfa81a816c3..9a5d84f5997 100644 --- a/homeassistant/components/manual/alarm_control_panel.py +++ b/homeassistant/components/manual/alarm_control_panel.py @@ -5,6 +5,7 @@ import copy import datetime import logging import re +from typing import Any import voluptuous as vol @@ -185,6 +186,16 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): A trigger_time of zero disables the alarm_trigger service. """ + _attr_should_poll = False + _attr_supported_features = ( + AlarmControlPanelEntityFeature.ARM_HOME + | AlarmControlPanelEntityFeature.ARM_AWAY + | AlarmControlPanelEntityFeature.ARM_NIGHT + | AlarmControlPanelEntityFeature.ARM_VACATION + | AlarmControlPanelEntityFeature.TRIGGER + | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS + ) + def __init__( self, hass, @@ -198,13 +209,13 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): """Init the manual alarm panel.""" self._state = STATE_ALARM_DISARMED self._hass = hass - self._name = name + self._attr_name = name if code_template: self._code = code_template self._code.hass = hass else: self._code = code or None - self._code_arm_required = code_arm_required + self._attr_code_arm_required = code_arm_required self._disarm_after_trigger = disarm_after_trigger self._previous_state = self._state self._state_ts = None @@ -222,17 +233,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): } @property - def should_poll(self): - """Return the polling state.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name - - @property - def state(self): + def state(self) -> str: """Return the state of the device.""" if self._state == STATE_ALARM_TRIGGERED: if self._within_pending_time(self._state): @@ -253,18 +254,6 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return self._state - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return ( - AlarmControlPanelEntityFeature.ARM_HOME - | AlarmControlPanelEntityFeature.ARM_AWAY - | AlarmControlPanelEntityFeature.ARM_NIGHT - | AlarmControlPanelEntityFeature.ARM_VACATION - | AlarmControlPanelEntityFeature.TRIGGER - | AlarmControlPanelEntityFeature.ARM_CUSTOM_BYPASS - ) - @property def _active_state(self): """Get the current state.""" @@ -289,7 +278,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return self._state_ts + self._pending_time(state) > dt_util.utcnow() @property - def code_format(self): + def code_format(self) -> alarm.CodeFormat | None: """Return one or more digits/characters.""" if self._code is None: return None @@ -297,11 +286,6 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return alarm.CodeFormat.NUMBER return alarm.CodeFormat.TEXT - @property - def code_arm_required(self): - """Whether the code is required for arm actions.""" - return self._code_arm_required - def alarm_disarm(self, code: str | None = None) -> None: """Send disarm command.""" if not self._validate_code(code, STATE_ALARM_DISARMED): @@ -313,7 +297,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def alarm_arm_home(self, code: str | None = None) -> None: """Send arm home command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_HOME ): return @@ -322,7 +306,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def alarm_arm_away(self, code: str | None = None) -> None: """Send arm away command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_AWAY ): return @@ -331,7 +315,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def alarm_arm_night(self, code: str | None = None) -> None: """Send arm night command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_NIGHT ): return @@ -340,7 +324,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def alarm_arm_vacation(self, code: str | None = None) -> None: """Send arm vacation command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_VACATION ): return @@ -349,7 +333,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): def alarm_arm_custom_bypass(self, code: str | None = None) -> None: """Send arm custom bypass command.""" - if self._code_arm_required and not self._validate_code( + if self.code_arm_required and not self._validate_code( code, STATE_ALARM_ARMED_CUSTOM_BYPASS ): return @@ -367,7 +351,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return self._update_state(STATE_ALARM_TRIGGERED) - def _update_state(self, state): + def _update_state(self, state: str) -> None: """Update the state.""" if self._state == state: return @@ -414,7 +398,7 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity): return check @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes.""" if self.state in (STATE_ALARM_PENDING, STATE_ALARM_ARMING): return { From edc1ee2985e855f630f45d7bb00ddaa3e28d50c7 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 10:21:58 +0200 Subject: [PATCH 827/947] Add type hints to async_step_reauth in components (#74138) --- homeassistant/components/bosch_shc/config_flow.py | 4 +++- homeassistant/components/enphase_envoy/config_flow.py | 3 ++- homeassistant/components/flume/config_flow.py | 5 ++++- homeassistant/components/intellifire/config_flow.py | 5 ++++- homeassistant/components/lyric/config_flow.py | 5 ++++- homeassistant/components/myq/config_flow.py | 5 ++++- homeassistant/components/picnic/config_flow.py | 5 ++++- homeassistant/components/prosegur/config_flow.py | 10 ++++++++-- homeassistant/components/vulcan/config_flow.py | 5 ++++- 9 files changed, 37 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/bosch_shc/config_flow.py b/homeassistant/components/bosch_shc/config_flow.py index 6ad1a374a5a..7cc4527a64f 100644 --- a/homeassistant/components/bosch_shc/config_flow.py +++ b/homeassistant/components/bosch_shc/config_flow.py @@ -1,6 +1,8 @@ """Config flow for Bosch Smart Home Controller integration.""" +from collections.abc import Mapping import logging from os import makedirs +from typing import Any from boschshcpy import SHCRegisterClient, SHCSession from boschshcpy.exceptions import ( @@ -83,7 +85,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): host = None hostname = None - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index 88310579e72..3707733b1af 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -1,6 +1,7 @@ """Config flow for Enphase Envoy integration.""" from __future__ import annotations +from collections.abc import Mapping import contextlib import logging from typing import Any @@ -110,7 +111,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_user() - async def async_step_reauth(self, user_input): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py index 6d9554f42c0..049b702dc3c 100644 --- a/homeassistant/components/flume/config_flow.py +++ b/homeassistant/components/flume/config_flow.py @@ -1,6 +1,8 @@ """Config flow for flume integration.""" +from collections.abc import Mapping import logging import os +from typing import Any from pyflume import FlumeAuth, FlumeDeviceList from requests.exceptions import RequestException @@ -13,6 +15,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, ) +from homeassistant.data_entry_flow import FlowResult from .const import BASE_TOKEN_FILENAME, DOMAIN @@ -103,7 +106,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._reauth_unique_id = self.context["unique_id"] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index 6066d703729..df13795a4ed 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -1,6 +1,7 @@ """Config flow for IntelliFire integration.""" from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass from typing import Any @@ -220,10 +221,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("Running Step: manual_device_entry") return await self.async_step_manual_device_entry() - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" LOGGER.debug("STEP: reauth") entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + assert entry + assert entry.unique_id # populate the expected vars self._serial = entry.unique_id diff --git a/homeassistant/components/lyric/config_flow.py b/homeassistant/components/lyric/config_flow.py index 698e7e19a26..12f91cfe206 100644 --- a/homeassistant/components/lyric/config_flow.py +++ b/homeassistant/components/lyric/config_flow.py @@ -1,6 +1,9 @@ """Config flow for Honeywell Lyric.""" +from collections.abc import Mapping import logging +from typing import Any +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .const import DOMAIN @@ -18,7 +21,7 @@ class OAuth2FlowHandler( """Return logger.""" return logging.getLogger(__name__) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/myq/config_flow.py b/homeassistant/components/myq/config_flow.py index 8c088de6715..c26b54d7332 100644 --- a/homeassistant/components/myq/config_flow.py +++ b/homeassistant/components/myq/config_flow.py @@ -1,5 +1,7 @@ """Config flow for MyQ integration.""" +from collections.abc import Mapping import logging +from typing import Any import pymyq from pymyq.errors import InvalidCredentialsError, MyQError @@ -7,6 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN @@ -60,7 +63,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._reauth_unique_id = self.context["unique_id"] return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/picnic/config_flow.py b/homeassistant/components/picnic/config_flow.py index c2d48ca9415..904b68e3d32 100644 --- a/homeassistant/components/picnic/config_flow.py +++ b/homeassistant/components/picnic/config_flow.py @@ -1,7 +1,9 @@ """Config flow for Picnic integration.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from python_picnic_api import PicnicAPI from python_picnic_api.session import PicnicAuthError @@ -11,6 +13,7 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import SOURCE_REAUTH from homeassistant.const import CONF_ACCESS_TOKEN, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from .const import CONF_COUNTRY_CODE, COUNTRY_CODES, DOMAIN @@ -72,7 +75,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_reauth(self, _): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform the re-auth step upon an API authentication error.""" return await self.async_step_user() diff --git a/homeassistant/components/prosegur/config_flow.py b/homeassistant/components/prosegur/config_flow.py index 1807561663b..ee2fa795f2d 100644 --- a/homeassistant/components/prosegur/config_flow.py +++ b/homeassistant/components/prosegur/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Prosegur Alarm integration.""" +from collections.abc import Mapping import logging +from typing import Any, cast from pyprosegur.auth import COUNTRY, Auth from pyprosegur.installation import Installation @@ -8,6 +10,7 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import CONF_COUNTRY, DOMAIN @@ -75,9 +78,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initiation of re-authentication with Prosegur.""" - self.entry = self.hass.config_entries.async_get_entry(self.context["entry_id"]) + self.entry = cast( + ConfigEntry, + self.hass.config_entries.async_get_entry(self.context["entry_id"]), + ) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm(self, user_input=None): diff --git a/homeassistant/components/vulcan/config_flow.py b/homeassistant/components/vulcan/config_flow.py index 09acb13ea27..f1e1c13871c 100644 --- a/homeassistant/components/vulcan/config_flow.py +++ b/homeassistant/components/vulcan/config_flow.py @@ -1,5 +1,7 @@ """Adds config flow for Vulcan.""" +from collections.abc import Mapping import logging +from typing import Any from aiohttp import ClientConnectionError import voluptuous as vol @@ -16,6 +18,7 @@ from vulcan import ( from homeassistant import config_entries from homeassistant.const import CONF_PIN, CONF_REGION, CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import DOMAIN @@ -236,7 +239,7 @@ class VulcanFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" return await self.async_step_reauth_confirm() From 808798219352e1cd184b63c061ac31f3672005ee Mon Sep 17 00:00:00 2001 From: Lorenzo Milesi Date: Wed, 29 Jun 2022 10:25:38 +0200 Subject: [PATCH 828/947] Update base url for ViaggiaTreno API (#71974) --- homeassistant/components/viaggiatreno/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 3fdca6653d0..0e7a047f6da 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by ViaggiaTreno Data" VIAGGIATRENO_ENDPOINT = ( - "http://www.viaggiatreno.it/viaggiatrenonew/" + "http://www.viaggiatreno.it/infomobilita/" "resteasy/viaggiatreno/andamentoTreno/" "{station_id}/{train_id}/{timestamp}" ) From 0404c76c41bf4701e28cf761caf1c0cd5605dfdc Mon Sep 17 00:00:00 2001 From: alexanv1 <44785744+alexanv1@users.noreply.github.com> Date: Wed, 29 Jun 2022 01:29:19 -0700 Subject: [PATCH 829/947] Add Tuya Sous Vide Cooker (#69777) --- homeassistant/components/tuya/const.py | 4 ++++ homeassistant/components/tuya/number.py | 23 +++++++++++++++++++++++ homeassistant/components/tuya/sensor.py | 22 ++++++++++++++++++++++ homeassistant/components/tuya/switch.py | 10 ++++++++++ 4 files changed, 59 insertions(+) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 5486e94786d..8a3e59b1ac9 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -181,6 +181,7 @@ class DPCode(StrEnum): CLEAN_AREA = "clean_area" CLEAN_TIME = "clean_time" CLICK_SUSTAIN_TIME = "click_sustain_time" + CLOUD_RECIPE_NUMBER = "cloud_recipe_number" CO_STATE = "co_state" CO_STATUS = "co_status" CO_VALUE = "co_value" @@ -191,6 +192,8 @@ class DPCode(StrEnum): COLOUR_DATA = "colour_data" # Colored light mode COLOUR_DATA_HSV = "colour_data_hsv" # Colored light mode COLOUR_DATA_V2 = "colour_data_v2" # Colored light mode + COOK_TEMPERATURE = "cook_temperature" + COOK_TIME = "cook_time" CONCENTRATION_SET = "concentration_set" # Concentration setting CONTROL = "control" CONTROL_2 = "control_2" @@ -295,6 +298,7 @@ class DPCode(StrEnum): RECORD_MODE = "record_mode" RECORD_SWITCH = "record_switch" # Recording switch RELAY_STATUS = "relay_status" + REMAIN_TIME = "remain_time" RESET_DUSTER_CLOTH = "reset_duster_cloth" RESET_EDGE_BRUSH = "reset_edge_brush" RESET_FILTER = "reset_filter" diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index bee5242d4ae..b1afeb0364c 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -9,6 +9,7 @@ from homeassistant.components.number import ( NumberEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_MINUTES from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import EntityCategory @@ -132,6 +133,28 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + NumberEntityDescription( + key=DPCode.COOK_TEMPERATURE, + name="Cook Temperature", + icon="mdi:thermometer", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.COOK_TIME, + name="Cook Time", + icon="mdi:timer", + unit_of_measurement=TIME_MINUTES, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.CLOUD_RECIPE_NUMBER, + name="Cloud Recipe", + entity_category=EntityCategory.CONFIG, + ), + ), # Robot Vacuum # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo "sd": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index acb2ffe7987..dd2996f61ba 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( ELECTRIC_POTENTIAL_VOLT, PERCENTAGE, POWER_KILO_WATT, + TIME_MINUTES, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -379,6 +380,27 @@ SENSORS: dict[str, tuple[TuyaSensorEntityDescription, ...]] = { # Door Window Sensor # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m "mcs": BATTERY_SENSORS, + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + TuyaSensorEntityDescription( + key=DPCode.TEMP_CURRENT, + name="Current Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.STATUS, + name="Status", + device_class=TuyaDeviceClass.STATUS, + ), + TuyaSensorEntityDescription( + key=DPCode.REMAIN_TIME, + name="Remaining Time", + native_unit_of_measurement=TIME_MINUTES, + icon="mdi:timer", + ), + ), # PIR Detector # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 "pir": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index d587e8ea54b..37834f0f273 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -269,6 +269,16 @@ SWITCHES: dict[str, tuple[SwitchEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Sous Vide Cooker + # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + "mzj": ( + SwitchEntityDescription( + key=DPCode.SWITCH, + name="Switch", + icon="mdi:pot-steam", + entity_category=EntityCategory.CONFIG, + ), + ), # Power Socket # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s "pc": ( From 1590c0a46c885589d2f25e6ee387284dd88ab0c2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 10:42:24 +0200 Subject: [PATCH 830/947] Migrate abode light to color_mode (#69070) --- homeassistant/components/abode/light.py | 31 +++++++++++++++++++------ tests/components/abode/test_light.py | 13 +++++++++-- 2 files changed, 35 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index f998e21510d..6d1123cd233 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -11,9 +11,10 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - SUPPORT_BRIGHTNESS, - SUPPORT_COLOR, - SUPPORT_COLOR_TEMP, + COLOR_MODE_BRIGHTNESS, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + COLOR_MODE_ONOFF, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -101,11 +102,27 @@ class AbodeLight(AbodeDevice, LightEntity): _hs = self._device.color return _hs + @property + def color_mode(self) -> str | None: + """Return the color mode of the light.""" + if self._device.is_dimmable and self._device.is_color_capable: + if self.hs_color is not None: + return COLOR_MODE_HS + return COLOR_MODE_COLOR_TEMP + if self._device.is_dimmable: + return COLOR_MODE_BRIGHTNESS + return COLOR_MODE_ONOFF + + @property + def supported_color_modes(self) -> set[str] | None: + """Flag supported color modes.""" + if self._device.is_dimmable and self._device.is_color_capable: + return {COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS} + if self._device.is_dimmable: + return {COLOR_MODE_BRIGHTNESS} + return {COLOR_MODE_ONOFF} + @property def supported_features(self) -> int: """Flag supported features.""" - if self._device.is_dimmable and self._device.is_color_capable: - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP - if self._device.is_dimmable: - return SUPPORT_BRIGHTNESS return 0 diff --git a/tests/components/abode/test_light.py b/tests/components/abode/test_light.py index d27a07227d0..3a1adc069e4 100644 --- a/tests/components/abode/test_light.py +++ b/tests/components/abode/test_light.py @@ -4,8 +4,12 @@ from unittest.mock import patch from homeassistant.components.abode import ATTR_DEVICE_ID from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_MODE, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, + ATTR_SUPPORTED_COLOR_MODES, + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, DOMAIN as LIGHT_DOMAIN, ) from homeassistant.const import ( @@ -41,13 +45,18 @@ async def test_attributes(hass: HomeAssistant) -> None: assert state.state == STATE_ON assert state.attributes.get(ATTR_BRIGHTNESS) == 204 assert state.attributes.get(ATTR_RGB_COLOR) == (0, 63, 255) - assert state.attributes.get(ATTR_COLOR_TEMP) == 280 + assert state.attributes.get(ATTR_COLOR_TEMP) is None assert state.attributes.get(ATTR_DEVICE_ID) == "ZB:db5b1a" assert not state.attributes.get("battery_low") assert not state.attributes.get("no_response") assert state.attributes.get("device_type") == "RGB Dimmer" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Living Room Lamp" - assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 19 + assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0 + assert state.attributes.get(ATTR_COLOR_MODE) == COLOR_MODE_HS + assert state.attributes.get(ATTR_SUPPORTED_COLOR_MODES) == [ + COLOR_MODE_COLOR_TEMP, + COLOR_MODE_HS, + ] async def test_switch_off(hass: HomeAssistant) -> None: From fbaff21b67913ba829614dc8b6e5c221ac309cf9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 10:43:58 +0200 Subject: [PATCH 831/947] Format viaggiatreno/sensor.py (#74161) --- homeassistant/components/viaggiatreno/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index 0e7a047f6da..95eeb154f9c 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) ATTRIBUTION = "Powered by ViaggiaTreno Data" VIAGGIATRENO_ENDPOINT = ( - "http://www.viaggiatreno.it/infomobilita/" + "http://www.viaggiatreno.it/infomobilita/" "resteasy/viaggiatreno/andamentoTreno/" "{station_id}/{train_id}/{timestamp}" ) From e64336cb91d1ce97ac82c57e98477acedfcbcf71 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Wed, 29 Jun 2022 09:54:04 +0100 Subject: [PATCH 832/947] Allow configuring username and password in generic camera config flow (#73804) * Add ability to use user & pw not in stream url * Increase test coverage to 100% * Increase test coverage * Verify that stream source includes user:pass * Code review: refactor test to use MockConfigEntry * Code review: Improve test docstring * Edit comment; retrigger CI. Co-authored-by: Dave T --- homeassistant/components/generic/camera.py | 9 +++- .../components/generic/config_flow.py | 8 ++++ tests/components/generic/test_camera.py | 44 +++++++++++++------ tests/components/generic/test_config_flow.py | 12 +++++ 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 5f1f9ba9c2c..8b03f0a8ed3 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -7,6 +7,7 @@ from typing import Any import httpx import voluptuous as vol +import yarl from homeassistant.components.camera import ( DEFAULT_CONTENT_TYPE, @@ -146,6 +147,8 @@ class GenericCamera(Camera): self.hass = hass self._attr_unique_id = identifier self._authentication = device_info.get(CONF_AUTHENTICATION) + self._username = device_info.get(CONF_USERNAME) + self._password = device_info.get(CONF_PASSWORD) self._name = device_info.get(CONF_NAME, title) self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL) if ( @@ -223,7 +226,11 @@ class GenericCamera(Camera): return None try: - return self._stream_source.async_render(parse_result=False) + stream_url = self._stream_source.async_render(parse_result=False) + url = yarl.URL(stream_url) + if not url.user and not url.password and self._username and self._password: + url = url.with_user(self._username).with_password(self._password) + return str(url) except TemplateError as err: _LOGGER.error("Error parsing template %s: %s", self._stream_source, err) return None diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index b6abdc5eec8..9096f2ce87e 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -221,6 +221,14 @@ async def async_test_stream( stream_options[CONF_RTSP_TRANSPORT] = rtsp_transport if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True + + url = yarl.URL(stream_source) + if not url.user and not url.password: + username = info.get(CONF_USERNAME) + password = info.get(CONF_PASSWORD) + if username and password: + url = url.with_user(username).with_password(password) + stream_source = str(url) try: stream = create_stream(hass, stream_source, stream_options, "test_stream") hls_provider = stream.add_provider(HLS_PROVIDER) diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py index ec0d89eb0eb..f7e1898f735 100644 --- a/tests/components/generic/test_camera.py +++ b/tests/components/generic/test_camera.py @@ -8,12 +8,24 @@ import httpx import pytest import respx -from homeassistant.components.camera import async_get_mjpeg_stream +from homeassistant.components.camera import ( + async_get_mjpeg_stream, + async_get_stream_source, +) +from homeassistant.components.generic.const import ( + CONF_CONTENT_TYPE, + CONF_FRAMERATE, + CONF_LIMIT_REFETCH_TO_URL_CHANGE, + CONF_STILL_IMAGE_URL, + CONF_STREAM_SOURCE, + DOMAIN, +) from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.setup import async_setup_component -from tests.common import AsyncMock, Mock +from tests.common import AsyncMock, Mock, MockConfigEntry @respx.mock @@ -184,23 +196,29 @@ async def test_stream_source(hass, hass_client, hass_ws_client, fakeimgbytes_png respx.get("http://example.com/0a").respond(stream=fakeimgbytes_png) hass.states.async_set("sensor.temp", "0") - assert await async_setup_component( - hass, - "camera", - { - "camera": { - "name": "config_test", - "platform": "generic", - "still_image_url": "http://example.com", - "stream_source": 'http://example.com/{{ states.sensor.temp.state + "a" }}', - "limit_refetch_to_url_change": True, - }, + mock_entry = MockConfigEntry( + title="config_test", + domain=DOMAIN, + data={}, + options={ + CONF_STILL_IMAGE_URL: "http://example.com", + CONF_STREAM_SOURCE: 'http://example.com/{{ states.sensor.temp.state + "a" }}', + CONF_LIMIT_REFETCH_TO_URL_CHANGE: True, + CONF_FRAMERATE: 2, + CONF_CONTENT_TYPE: "image/png", + CONF_VERIFY_SSL: False, + CONF_USERNAME: "barney", + CONF_PASSWORD: "betty", }, ) + mock_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_entry.entry_id) assert await async_setup_component(hass, "stream", {}) await hass.async_block_till_done() hass.states.async_set("sensor.temp", "5") + stream_source = await async_get_stream_source(hass, "camera.config_test") + assert stream_source == "http://barney:betty@example.com/5a" with patch( "homeassistant.components.camera.Stream.endpoint_url", diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index 2979513e5c0..f0589301014 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -10,6 +10,7 @@ import respx from homeassistant import config_entries, data_entry_flow from homeassistant.components.camera import async_get_image +from homeassistant.components.generic.config_flow import slug from homeassistant.components.generic.const import ( CONF_CONTENT_TYPE, CONF_FRAMERATE, @@ -517,6 +518,17 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream assert result5["errors"] == {"stream_source": "template_error"} +async def test_slug(hass, caplog): + """ + Test that the slug function generates an error in case of invalid template. + + Other paths in the slug function are already tested by other tests. + """ + result = slug(hass, "http://127.0.0.2/testurl/{{1/0}}") + assert result is None + assert "Syntax error in" in caplog.text + + @respx.mock async def test_options_only_stream(hass, fakeimgbytes_png, mock_create_stream): """Test the options flow without a still_image_url.""" From 9d73f9a2c5bf0848ae7581cdf8263ec214f81fb3 Mon Sep 17 00:00:00 2001 From: mbo18 Date: Wed, 29 Jun 2022 11:02:20 +0200 Subject: [PATCH 833/947] Move power and energy attributes to sensors for SmartThings Air conditioner (#72594) Move power and energy attribute to sensor for Air conditioner --- .../components/smartthings/climate.py | 5 ---- .../components/smartthings/sensor.py | 24 +++++++++++++++---- tests/components/smartthings/test_climate.py | 11 --------- tests/components/smartthings/test_sensor.py | 2 ++ 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9bc287b054c..87d20b4533f 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -106,7 +106,6 @@ def get_capabilities(capabilities: Sequence[str]) -> Sequence[str] | None: Capability.air_conditioner_mode, Capability.demand_response_load_control, Capability.air_conditioner_fan_mode, - Capability.power_consumption_report, Capability.relative_humidity_measurement, Capability.switch, Capability.temperature_measurement, @@ -422,10 +421,6 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): "drlc_status_level", "drlc_status_start", "drlc_status_override", - "power_consumption_start", - "power_consumption_power", - "power_consumption_energy", - "power_consumption_end", ] state_attributes = {} for attribute in attributes: diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 872921199f0..64869347228 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -553,7 +553,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Add binary sensors for a config entry.""" + """Add sensors for a config entry.""" broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] entities: list[SensorEntity] = [] for device in broker.devices.values(): @@ -641,7 +641,7 @@ class SmartThingsSensor(SmartThingsEntity, SensorEntity): @property def name(self) -> str: - """Return the name of the binary sensor.""" + """Return the name of the sensor.""" return f"{self._device.label} {self._name}" @property @@ -681,7 +681,7 @@ class SmartThingsThreeAxisSensor(SmartThingsEntity, SensorEntity): @property def name(self) -> str: - """Return the name of the binary sensor.""" + """Return the name of the sensor.""" return f"{self._device.label} {THREE_AXIS_NAMES[self._index]}" @property @@ -716,7 +716,7 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): @property def name(self) -> str: - """Return the name of the binary sensor.""" + """Return the name of the sensor.""" return f"{self._device.label} {self.report_name}" @property @@ -747,3 +747,19 @@ class SmartThingsPowerConsumptionSensor(SmartThingsEntity, SensorEntity): if self.report_name == "power": return POWER_WATT return ENERGY_KILO_WATT_HOUR + + @property + def extra_state_attributes(self): + """Return specific state attributes.""" + if self.report_name == "power": + attributes = [ + "power_consumption_start", + "power_consumption_end", + ] + state_attributes = {} + for attribute in attributes: + value = getattr(self._device.status, attribute) + if value is not None: + state_attributes[attribute] = value + return state_attributes + return None diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 5c3d61d8b41..825b8259276 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -148,7 +148,6 @@ def air_conditioner_fixture(device_factory): Capability.air_conditioner_mode, Capability.demand_response_load_control, Capability.air_conditioner_fan_mode, - Capability.power_consumption_report, Capability.switch, Capability.temperature_measurement, Capability.thermostat_cooling_setpoint, @@ -177,12 +176,6 @@ def air_conditioner_fixture(device_factory): "high", "turbo", ], - Attribute.power_consumption: { - "start": "2019-02-24T21:03:04Z", - "power": 0, - "energy": 500, - "end": "2019-02-26T02:05:55Z", - }, Attribute.switch: "on", Attribute.cooling_setpoint: 23, }, @@ -320,10 +313,6 @@ async def test_air_conditioner_entity_state(hass, air_conditioner): assert state.attributes["drlc_status_level"] == -1 assert state.attributes["drlc_status_start"] == "1970-01-01T00:00:00Z" assert state.attributes["drlc_status_override"] is False - assert state.attributes["power_consumption_start"] == "2019-02-24T21:03:04Z" - assert state.attributes["power_consumption_power"] == 0 - assert state.attributes["power_consumption_energy"] == 500 - assert state.attributes["power_consumption_end"] == "2019-02-26T02:05:55Z" async def test_set_fan_mode(hass, thermostat, air_conditioner): diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 98464af24af..a4e89ebe5c7 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -190,6 +190,8 @@ async def test_power_consumption_sensor(hass, device_factory): state = hass.states.get("sensor.refrigerator_power") assert state assert state.state == "109" + assert state.attributes["power_consumption_start"] == "2021-07-30T16:45:25Z" + assert state.attributes["power_consumption_end"] == "2021-07-30T16:58:33Z" entry = entity_registry.async_get("sensor.refrigerator_power") assert entry assert entry.unique_id == f"{device.device_id}.power_meter" From 1970e36f10362822dba4e9d973d1936f3538adf3 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:03:53 +0200 Subject: [PATCH 834/947] Fix CI (tuya number and abode light) (#74163) * Fix tuya unit_of_measurement * Fix abode ColorMode --- homeassistant/components/abode/light.py | 19 ++++++++----------- homeassistant/components/tuya/number.py | 2 +- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index 6d1123cd233..1bb9d41f461 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -11,10 +11,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, - COLOR_MODE_BRIGHTNESS, - COLOR_MODE_COLOR_TEMP, - COLOR_MODE_HS, - COLOR_MODE_ONOFF, + ColorMode, LightEntity, ) from homeassistant.config_entries import ConfigEntry @@ -107,20 +104,20 @@ class AbodeLight(AbodeDevice, LightEntity): """Return the color mode of the light.""" if self._device.is_dimmable and self._device.is_color_capable: if self.hs_color is not None: - return COLOR_MODE_HS - return COLOR_MODE_COLOR_TEMP + return ColorMode.HS + return ColorMode.COLOR_TEMP if self._device.is_dimmable: - return COLOR_MODE_BRIGHTNESS - return COLOR_MODE_ONOFF + return ColorMode.BRIGHTNESS + return ColorMode.ONOFF @property def supported_color_modes(self) -> set[str] | None: """Flag supported color modes.""" if self._device.is_dimmable and self._device.is_color_capable: - return {COLOR_MODE_COLOR_TEMP, COLOR_MODE_HS} + return {ColorMode.COLOR_TEMP, ColorMode.HS} if self._device.is_dimmable: - return {COLOR_MODE_BRIGHTNESS} - return {COLOR_MODE_ONOFF} + return {ColorMode.BRIGHTNESS} + return {ColorMode.ONOFF} @property def supported_features(self) -> int: diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index b1afeb0364c..e7712dcf630 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -146,7 +146,7 @@ NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { key=DPCode.COOK_TIME, name="Cook Time", icon="mdi:timer", - unit_of_measurement=TIME_MINUTES, + native_unit_of_measurement=TIME_MINUTES, entity_category=EntityCategory.CONFIG, ), NumberEntityDescription( From 981249d330b9605b878bdf87195b8cb31ca8421e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 29 Jun 2022 03:16:06 -0600 Subject: [PATCH 835/947] Ensure `_attr_native_value` type matches what `SensorExtraStoredData` produces (#73970) --- homeassistant/components/sensor/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 2c0d75ff471..6d35c2a4635 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -264,7 +264,7 @@ class SensorEntity(Entity): _attr_device_class: SensorDeviceClass | str | None _attr_last_reset: datetime | None _attr_native_unit_of_measurement: str | None - _attr_native_value: StateType | date | datetime = None + _attr_native_value: StateType | date | datetime | Decimal = None _attr_state_class: SensorStateClass | str | None _attr_state: None = None # Subclasses of SensorEntity should not set this _attr_unit_of_measurement: None = ( @@ -349,7 +349,7 @@ class SensorEntity(Entity): return None @property - def native_value(self) -> StateType | date | datetime: + def native_value(self) -> StateType | date | datetime | Decimal: """Return the value reported by the sensor.""" return self._attr_native_value From 500105fa86fb99031a818eda019dfed20d8ec250 Mon Sep 17 00:00:00 2001 From: Robert Hillis Date: Wed, 29 Jun 2022 05:21:01 -0400 Subject: [PATCH 836/947] Move Tautulli attributes to their own sensors (#71712) --- homeassistant/components/tautulli/__init__.py | 10 +- homeassistant/components/tautulli/const.py | 2 + homeassistant/components/tautulli/sensor.py | 285 +++++++++++++++--- 3 files changed, 247 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py index fe6eeb9e303..339ec6eb895 100644 --- a/homeassistant/components/tautulli/__init__.py +++ b/homeassistant/components/tautulli/__init__.py @@ -1,7 +1,7 @@ """The Tautulli integration.""" from __future__ import annotations -from pytautulli import PyTautulli, PyTautulliHostConfiguration +from pytautulli import PyTautulli, PyTautulliApiUser, PyTautulliHostConfiguration from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform @@ -50,14 +50,18 @@ class TautulliEntity(CoordinatorEntity[TautulliDataUpdateCoordinator]): self, coordinator: TautulliDataUpdateCoordinator, description: EntityDescription, + user: PyTautulliApiUser | None = None, ) -> None: """Initialize the Tautulli entity.""" super().__init__(coordinator) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{description.key}" self.entity_description = description - self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" + self.user = user self._attr_device_info = DeviceInfo( configuration_url=coordinator.host_configuration.base_url, entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, user.user_id if user else entry_id)}, manufacturer=DEFAULT_NAME, + name=user.username if user else DEFAULT_NAME, ) diff --git a/homeassistant/components/tautulli/const.py b/homeassistant/components/tautulli/const.py index 5c0a1b56cda..49b86ec6ef7 100644 --- a/homeassistant/components/tautulli/const.py +++ b/homeassistant/components/tautulli/const.py @@ -1,6 +1,8 @@ """Constants for the Tautulli integration.""" from logging import Logger, getLogger +ATTR_TOP_USER = "top_user" + CONF_MONITORED_USERS = "monitored_users" DEFAULT_NAME = "Tautulli" DEFAULT_PATH = "" diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index b1af6e3ce47..5981992c946 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -1,14 +1,23 @@ """A platform which allows you to get information from Tautulli.""" from __future__ import annotations -from typing import Any +from collections.abc import Callable +from dataclasses import dataclass +from typing import cast +from pytautulli import ( + PyTautulliApiActivity, + PyTautulliApiHomeStats, + PyTautulliApiSession, + PyTautulliApiUser, +) import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -20,14 +29,18 @@ from homeassistant.const import ( CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, + DATA_KILOBITS, + PERCENTAGE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import EntityCategory, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from . import TautulliEntity from .const import ( + ATTR_TOP_USER, CONF_MONITORED_USERS, DEFAULT_NAME, DEFAULT_PATH, @@ -53,12 +66,188 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( } ) -SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( - SensorEntityDescription( + +def get_top_stats( + home_stats: PyTautulliApiHomeStats, activity: PyTautulliApiActivity, key: str +) -> str | None: + """Get top statistics.""" + value = None + for stat in home_stats: + if stat.rows and stat.stat_id == key: + value = stat.rows[0].title + elif stat.rows and stat.stat_id == "top_users" and key == ATTR_TOP_USER: + value = stat.rows[0].user + return value + + +@dataclass +class TautulliSensorEntityMixin: + """Mixin for Tautulli sensor.""" + + value_fn: Callable[[PyTautulliApiHomeStats, PyTautulliApiActivity, str], StateType] + + +@dataclass +class TautulliSensorEntityDescription( + SensorEntityDescription, TautulliSensorEntityMixin +): + """Describes a Tautulli sensor.""" + + +SENSOR_TYPES: tuple[TautulliSensorEntityDescription, ...] = ( + TautulliSensorEntityDescription( icon="mdi:plex", key="watching_count", name="Tautulli", native_unit_of_measurement="Watching", + value_fn=lambda home_stats, activity, _: cast(int, activity.stream_count), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_direct_play", + name="Direct Plays", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_direct_play + ), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_direct_stream", + name="Direct Streams", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_direct_stream + ), + ), + TautulliSensorEntityDescription( + icon="mdi:plex", + key="stream_count_transcode", + name="Transcodes", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement="Streams", + entity_registry_enabled_default=False, + value_fn=lambda home_stats, activity, _: cast( + int, activity.stream_count_transcode + ), + ), + TautulliSensorEntityDescription( + key="total_bandwidth", + name="Total Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.total_bandwidth), + ), + TautulliSensorEntityDescription( + key="lan_bandwidth", + name="LAN Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.lan_bandwidth), + ), + TautulliSensorEntityDescription( + key="wan_bandwidth", + name="WAN Bandwidth", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=DATA_KILOBITS, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda home_stats, activity, _: cast(int, activity.wan_bandwidth), + ), + TautulliSensorEntityDescription( + icon="mdi:movie-open", + key="top_movies", + name="Top Movie", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), + TautulliSensorEntityDescription( + icon="mdi:television", + key="top_tv", + name="Top TV Show", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), + TautulliSensorEntityDescription( + icon="mdi:walk", + key=ATTR_TOP_USER, + name="Top User", + entity_registry_enabled_default=False, + value_fn=get_top_stats, + ), +) + + +@dataclass +class TautulliSessionSensorEntityMixin: + """Mixin for Tautulli session sensor.""" + + value_fn: Callable[[PyTautulliApiSession], StateType] + + +@dataclass +class TautulliSessionSensorEntityDescription( + SensorEntityDescription, TautulliSessionSensorEntityMixin +): + """Describes a Tautulli session sensor.""" + + +SESSION_SENSOR_TYPES: tuple[TautulliSessionSensorEntityDescription, ...] = ( + TautulliSessionSensorEntityDescription( + icon="mdi:plex", + key="state", + name="State", + value_fn=lambda session: cast(str, session.state), + ), + TautulliSessionSensorEntityDescription( + key="full_title", + name="Full Title", + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.full_title), + ), + TautulliSessionSensorEntityDescription( + icon="mdi:progress-clock", + key="progress", + name="Progress", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.progress_percent), + ), + TautulliSessionSensorEntityDescription( + key="stream_resolution", + name="Stream Resolution", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.stream_video_resolution), + ), + TautulliSessionSensorEntityDescription( + icon="mdi:plex", + key="transcode_decision", + name="Transcode Decision", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.transcode_decision), + ), + TautulliSessionSensorEntityDescription( + key="session_thumb", + name="session Thumbnail", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.user_thumb), + ), + TautulliSessionSensorEntityDescription( + key="video_resolution", + name="Video Resolution", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + value_fn=lambda session: cast(str, session.video_resolution), ), ) @@ -82,62 +271,64 @@ async def async_setup_entry( ) -> None: """Set up Tautulli sensor.""" coordinator: TautulliDataUpdateCoordinator = hass.data[DOMAIN] - async_add_entities( + entities: list[TautulliSensor | TautulliSessionSensor] = [ TautulliSensor( coordinator, description, ) for description in SENSOR_TYPES - ) + ] + if coordinator.users: + entities.extend( + TautulliSessionSensor( + coordinator, + description, + user, + ) + for description in SESSION_SENSOR_TYPES + for user in coordinator.users + if user.username != "Local" + ) + async_add_entities(entities) class TautulliSensor(TautulliEntity, SensorEntity): """Representation of a Tautulli sensor.""" + entity_description: TautulliSensorEntityDescription + @property def native_value(self) -> StateType: """Return the state of the sensor.""" - if not self.coordinator.activity: - return 0 - return self.coordinator.activity.stream_count or 0 + return self.entity_description.value_fn( + self.coordinator.home_stats, + self.coordinator.activity, + self.entity_description.key, + ) + + +class TautulliSessionSensor(TautulliEntity, SensorEntity): + """Representation of a Tautulli session sensor.""" + + entity_description: TautulliSessionSensorEntityDescription + + def __init__( + self, + coordinator: TautulliDataUpdateCoordinator, + description: EntityDescription, + user: PyTautulliApiUser, + ) -> None: + """Initialize the Tautulli entity.""" + super().__init__(coordinator, description, user) + entry_id = coordinator.config_entry.entry_id + self._attr_unique_id = f"{entry_id}_{user.user_id}_{description.key}" + self._attr_name = f"{user.username} {description.name}" @property - def extra_state_attributes(self) -> dict[str, Any] | None: - """Return attributes for the sensor.""" - if ( - not self.coordinator.activity - or not self.coordinator.home_stats - or not self.coordinator.users - ): - return None - - _attributes = { - "stream_count": self.coordinator.activity.stream_count, - "stream_count_direct_play": self.coordinator.activity.stream_count_direct_play, - "stream_count_direct_stream": self.coordinator.activity.stream_count_direct_stream, - "stream_count_transcode": self.coordinator.activity.stream_count_transcode, - "total_bandwidth": self.coordinator.activity.total_bandwidth, - "lan_bandwidth": self.coordinator.activity.lan_bandwidth, - "wan_bandwidth": self.coordinator.activity.wan_bandwidth, - } - - for stat in self.coordinator.home_stats: - if stat.stat_id == "top_movies": - _attributes["Top Movie"] = stat.rows[0].title if stat.rows else None - elif stat.stat_id == "top_tv": - _attributes["Top TV Show"] = stat.rows[0].title if stat.rows else None - elif stat.stat_id == "top_users": - _attributes["Top User"] = stat.rows[0].user if stat.rows else None - - for user in self.coordinator.users: - if user.username == "Local": - continue - _attributes.setdefault(user.username, {})["Activity"] = None - - for session in self.coordinator.activity.sessions: - if not _attributes.get(session.username) or "null" in session.state: - continue - - _attributes[session.username]["Activity"] = session.state - - return _attributes + def native_value(self) -> StateType: + """Return the state of the sensor.""" + if self.coordinator.activity: + for session in self.coordinator.activity.sessions: + if self.user and session.user_id == self.user.user_id: + return self.entity_description.value_fn(session) + return None From d323508f796e1b0ffb7d02b90a8ea08a356cd229 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:25:37 +0200 Subject: [PATCH 837/947] Add type hints to async_step_reauth (#74164) --- homeassistant/components/august/config_flow.py | 7 +++++-- .../components/azure_devops/config_flow.py | 15 ++++++++++----- homeassistant/components/elmax/config_flow.py | 7 ++++--- homeassistant/components/hive/config_flow.py | 10 +++++++--- homeassistant/components/hyperion/config_flow.py | 7 ++----- homeassistant/components/mazda/config_flow.py | 9 ++++++--- homeassistant/components/nuki/config_flow.py | 6 ++++-- homeassistant/components/plex/config_flow.py | 7 +++++-- homeassistant/components/sense/config_flow.py | 9 ++++++--- homeassistant/components/spotify/config_flow.py | 5 +++-- .../components/totalconnect/config_flow.py | 10 +++++++--- 11 files changed, 59 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index eb7bac9ae1a..067f986c4e6 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -1,11 +1,14 @@ """Config flow for August integration.""" +from collections.abc import Mapping import logging +from typing import Any import voluptuous as vol from yalexs.authenticator import ValidationResult from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult from .const import CONF_LOGIN_METHOD, DOMAIN, LOGIN_METHODS, VERIFICATION_CODE_KEY from .exceptions import CannotConnect, InvalidAuth, RequireValidation @@ -109,9 +112,9 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._user_auth_details = dict(data) + self._user_auth_details = dict(entry_data) self._mode = "reauth" self._needs_reset = True self._august_gateway = AugustGateway(self.hass) diff --git a/homeassistant/components/azure_devops/config_flow.py b/homeassistant/components/azure_devops/config_flow.py index 350bad5852a..8fba3378886 100644 --- a/homeassistant/components/azure_devops/config_flow.py +++ b/homeassistant/components/azure_devops/config_flow.py @@ -1,9 +1,13 @@ """Config flow to configure the Azure DevOps integration.""" +from collections.abc import Mapping +from typing import Any + from aioazuredevops.client import DevOpsClient import aiohttp import voluptuous as vol from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult from .const import CONF_ORG, CONF_PAT, CONF_PROJECT, DOMAIN @@ -82,12 +86,12 @@ class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): return await self._show_setup_form(errors) return self._async_create_entry() - async def async_step_reauth(self, user_input): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - if user_input.get(CONF_ORG) and user_input.get(CONF_PROJECT): - self._organization = user_input[CONF_ORG] - self._project = user_input[CONF_PROJECT] - self._pat = user_input[CONF_PAT] + if entry_data.get(CONF_ORG) and entry_data.get(CONF_PROJECT): + self._organization = entry_data[CONF_ORG] + self._project = entry_data[CONF_PROJECT] + self._pat = entry_data[CONF_PAT] self.context["title_placeholders"] = { "project_url": f"{self._organization}/{self._project}", @@ -100,6 +104,7 @@ class AzureDevOpsFlowHandler(ConfigFlow, domain=DOMAIN): return await self._show_reauth_form(errors) entry = await self.async_set_unique_id(self.unique_id) + assert entry self.hass.config_entries.async_update_entry( entry, data={ diff --git a/homeassistant/components/elmax/config_flow.py b/homeassistant/components/elmax/config_flow.py index 6872a555b8a..0c1a0148205 100644 --- a/homeassistant/components/elmax/config_flow.py +++ b/homeassistant/components/elmax/config_flow.py @@ -1,6 +1,7 @@ """Config flow for elmax-cloud integration.""" from __future__ import annotations +from collections.abc import Mapping import logging from typing import Any @@ -167,10 +168,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="panels", data_schema=self._panels_schema, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._reauth_username = user_input.get(CONF_ELMAX_USERNAME) - self._reauth_panelid = user_input.get(CONF_ELMAX_PANEL_ID) + self._reauth_username = entry_data.get(CONF_ELMAX_USERNAME) + self._reauth_panelid = entry_data.get(CONF_ELMAX_PANEL_ID) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm(self, user_input=None): diff --git a/homeassistant/components/hive/config_flow.py b/homeassistant/components/hive/config_flow.py index 5368aa22c3f..ec1c4f78e87 100644 --- a/homeassistant/components/hive/config_flow.py +++ b/homeassistant/components/hive/config_flow.py @@ -1,6 +1,9 @@ """Config Flow for Hive.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from apyhiveapi import Auth from apyhiveapi.helper.hive_exceptions import ( HiveApiError, @@ -13,6 +16,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import CONF_CODE, CONF_DEVICE_NAME, CONFIG_ENTRY_VERSION, DOMAIN @@ -136,11 +140,11 @@ class HiveFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self.data["username"], data=self.data) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Re Authenticate a user.""" data = { - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_PASSWORD: user_input[CONF_PASSWORD], + CONF_USERNAME: entry_data[CONF_USERNAME], + CONF_PASSWORD: entry_data[CONF_PASSWORD], } return await self.async_step_user(data) diff --git a/homeassistant/components/hyperion/config_flow.py b/homeassistant/components/hyperion/config_flow.py index 8b8f8b62c47..97e97cd835d 100644 --- a/homeassistant/components/hyperion/config_flow.py +++ b/homeassistant/components/hyperion/config_flow.py @@ -141,12 +141,9 @@ class HyperionConfigFlow(ConfigFlow, domain=DOMAIN): return await self.async_step_auth() return await self.async_step_confirm() - async def async_step_reauth( - self, - config_data: Mapping[str, Any], - ) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthentication flow.""" - self._data = dict(config_data) + self._data = dict(entry_data) async with self._create_client(raw_connection=True) as hyperion_client: if not hyperion_client: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/mazda/config_flow.py b/homeassistant/components/mazda/config_flow.py index b1b0ce35b11..0b255483da1 100644 --- a/homeassistant/components/mazda/config_flow.py +++ b/homeassistant/components/mazda/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Mazda Connected Services integration.""" +from collections.abc import Mapping import logging +from typing import Any import aiohttp from pymazda import ( @@ -11,6 +13,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_REGION +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import aiohttp_client from .const import DOMAIN, MAZDA_REGIONS @@ -97,11 +100,11 @@ class MazdaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth if the user credentials have changed.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - self._email = user_input[CONF_EMAIL] - self._region = user_input[CONF_REGION] + self._email = entry_data[CONF_EMAIL] + self._region = entry_data[CONF_REGION] return await self.async_step_user() diff --git a/homeassistant/components/nuki/config_flow.py b/homeassistant/components/nuki/config_flow.py index 054d0aaa219..85144d9bb77 100644 --- a/homeassistant/components/nuki/config_flow.py +++ b/homeassistant/components/nuki/config_flow.py @@ -1,5 +1,7 @@ """Config flow to configure the Nuki integration.""" +from collections.abc import Mapping import logging +from typing import Any from pynuki import NukiBridge from pynuki.bridge import InvalidCredentialsException @@ -80,9 +82,9 @@ class NukiConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_validate() - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - self._data = data + self._data = entry_data return await self.async_step_reauth_confirm() diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 3489e41364e..42d227154a6 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -1,8 +1,10 @@ """Config flow for Plex.""" from __future__ import annotations +from collections.abc import Mapping import copy import logging +from typing import Any from aiohttp import web_response import plexapi.exceptions @@ -26,6 +28,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -329,9 +332,9 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): server_config = {CONF_TOKEN: self.token} return await self.async_step_server_validate(server_config) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle a reauthorization flow request.""" - self.current_login = dict(data) + self.current_login = dict(entry_data) return await self.async_step_user() diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py index 8769d4cb83f..0690344ccf1 100644 --- a/homeassistant/components/sense/config_flow.py +++ b/homeassistant/components/sense/config_flow.py @@ -1,5 +1,7 @@ """Config flow for Sense integration.""" +from collections.abc import Mapping import logging +from typing import Any from sense_energy import ( ASyncSenseable, @@ -10,6 +12,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_CODE, CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT, DOMAIN, SENSE_CONNECT_EXCEPTIONS @@ -120,10 +123,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" - self._auth_data = dict(data) - return await self.async_step_reauth_validate(data) + self._auth_data = dict(entry_data) + return await self.async_step_reauth_validate(entry_data) async def async_step_reauth_validate(self, user_input=None): """Handle reauth and validation.""" diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py index 30ad74f8e26..013063308bb 100644 --- a/homeassistant/components/spotify/config_flow.py +++ b/homeassistant/components/spotify/config_flow.py @@ -57,7 +57,7 @@ class SpotifyFlowHandler( return self.async_create_entry(title=name, data=data) - async def async_step_reauth(self, entry: Mapping[str, Any]) -> FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" self.reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] @@ -65,7 +65,8 @@ class SpotifyFlowHandler( persistent_notification.async_create( self.hass, - f"Spotify integration for account {entry['id']} needs to be re-authenticated. Please go to the integrations page to re-configure it.", + f"Spotify integration for account {entry_data['id']} needs to be " + "re-authenticated. Please go to the integrations page to re-configure it.", "Spotify re-authentication", "spotify_reauth", ) diff --git a/homeassistant/components/totalconnect/config_flow.py b/homeassistant/components/totalconnect/config_flow.py index 057328cffb0..8d35506af0f 100644 --- a/homeassistant/components/totalconnect/config_flow.py +++ b/homeassistant/components/totalconnect/config_flow.py @@ -1,6 +1,9 @@ """Config flow for the Total Connect component.""" from __future__ import annotations +from collections.abc import Mapping +from typing import Any + from total_connect_client.client import TotalConnectClient from total_connect_client.exceptions import AuthenticationError import voluptuous as vol @@ -8,6 +11,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_LOCATION, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import AUTO_BYPASS, CONF_USERCODES, DOMAIN @@ -121,10 +125,10 @@ class TotalConnectConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): description_placeholders={"location_id": location_for_user}, ) - async def async_step_reauth(self, config): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an authentication error or no usercode.""" - self.username = config[CONF_USERNAME] - self.usercodes = config[CONF_USERCODES] + self.username = entry_data[CONF_USERNAME] + self.usercodes = entry_data[CONF_USERCODES] return await self.async_step_reauth_confirm() From f2809262d527c1676f47b743ee02eeeb3c1ca99f Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Jun 2022 11:28:46 +0200 Subject: [PATCH 838/947] Netgear add CPU and Memory utilization sensors (#72667) --- homeassistant/components/netgear/__init__.py | 14 ++++++++++ homeassistant/components/netgear/const.py | 1 + homeassistant/components/netgear/router.py | 5 ++++ homeassistant/components/netgear/sensor.py | 27 ++++++++++++++++++++ 4 files changed, 47 insertions(+) diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 953008ae9f5..6e56aef91c5 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -18,6 +18,7 @@ from .const import ( KEY_COORDINATOR_LINK, KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, + KEY_COORDINATOR_UTIL, KEY_ROUTER, PLATFORMS, ) @@ -84,6 +85,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from the router.""" return await router.async_get_speed_test() + async def async_update_utilization() -> dict[str, Any] | None: + """Fetch data from the router.""" + return await router.async_get_utilization() + async def async_check_link_status() -> dict[str, Any] | None: """Fetch data from the router.""" return await router.async_get_link_status() @@ -110,6 +115,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=async_update_speed_test, update_interval=SPEED_TEST_INTERVAL, ) + coordinator_utilization = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{router.device_name} Utilization", + update_method=async_update_utilization, + update_interval=SCAN_INTERVAL, + ) coordinator_link = DataUpdateCoordinator( hass, _LOGGER, @@ -121,6 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if router.track_devices: await coordinator.async_config_entry_first_refresh() await coordinator_traffic_meter.async_config_entry_first_refresh() + await coordinator_utilization.async_config_entry_first_refresh() await coordinator_link.async_config_entry_first_refresh() hass.data[DOMAIN][entry.entry_id] = { @@ -128,6 +141,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: KEY_COORDINATOR: coordinator, KEY_COORDINATOR_TRAFFIC: coordinator_traffic_meter, KEY_COORDINATOR_SPEED: coordinator_speed_test, + KEY_COORDINATOR_UTIL: coordinator_utilization, KEY_COORDINATOR_LINK: coordinator_link, } diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index c8939208047..f9b9a93b767 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -13,6 +13,7 @@ KEY_ROUTER = "router" KEY_COORDINATOR = "coordinator" KEY_COORDINATOR_TRAFFIC = "coordinator_traffic" KEY_COORDINATOR_SPEED = "coordinator_speed" +KEY_COORDINATOR_UTIL = "coordinator_utilization" KEY_COORDINATOR_LINK = "coordinator_link" DEFAULT_CONSIDER_HOME = timedelta(seconds=180) diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 6284c6f4ac2..57b0efa1bf2 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -240,6 +240,11 @@ class NetgearRouter: self._api.allow_block_device, mac, allow_block ) + async def async_get_utilization(self) -> dict[str, Any] | None: + """Get the system information about utilization of the router.""" + async with self._api_lock: + return await self.hass.async_add_executor_job(self._api.get_system_info) + async def async_reboot(self) -> None: """Reboot the router.""" async with self._api_lock: diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index f860b65e10f..1ada340d1e1 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -32,6 +33,7 @@ from .const import ( KEY_COORDINATOR_LINK, KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, + KEY_COORDINATOR_UTIL, KEY_ROUTER, ) from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterEntity @@ -245,6 +247,25 @@ SENSOR_SPEED_TYPES = [ ), ] +SENSOR_UTILIZATION = [ + NetgearSensorEntityDescription( + key="NewCPUUtilization", + name="CPU Utilization", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:cpu-64-bit", + state_class=SensorStateClass.MEASUREMENT, + ), + NetgearSensorEntityDescription( + key="NewMemoryUtilization", + name="Memory Utilization", + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + icon="mdi:memory", + state_class=SensorStateClass.MEASUREMENT, + ), +] + SENSOR_LINK_TYPES = [ NetgearSensorEntityDescription( key="NewEthernetLinkStatus", @@ -263,6 +284,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR] coordinator_traffic = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_TRAFFIC] coordinator_speed = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_SPEED] + coordinator_utilization = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_UTIL] coordinator_link = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_LINK] # Router entities @@ -278,6 +300,11 @@ async def async_setup_entry( NetgearRouterSensorEntity(coordinator_speed, router, description) ) + for description in SENSOR_UTILIZATION: + router_entities.append( + NetgearRouterSensorEntity(coordinator_utilization, router, description) + ) + for description in SENSOR_LINK_TYPES: router_entities.append( NetgearRouterSensorEntity(coordinator_link, router, description) From ebf21d1bd7cf25f81bfc0153059226f0444ccdbe Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:41:56 +0200 Subject: [PATCH 839/947] Add BinarySensorEntity to pylint checks (#74131) --- pylint/plugins/hass_enforce_type_hints.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f3c7a01a10f..f08e6d932e7 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -678,6 +678,25 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "binary_sensor": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="BinarySensorEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["BinarySensorDeviceClass", "str", None], + ), + TypeHintMatch( + function_name="is_on", + return_type=["bool", None], + ), + ], + ), + ], "cover": [ ClassTypeHintMatch( base_class="Entity", From 5b73cb10c18192815e71d0e68cfb6adaabbf7312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Hansl=C3=ADk?= Date: Wed, 29 Jun 2022 11:42:52 +0200 Subject: [PATCH 840/947] MWh is valid unit for energy dashboard (#73929) MWh is valid unit for energy --- homeassistant/components/energy/validate.py | 7 ++++++- tests/components/energy/test_validate.py | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/energy/validate.py b/homeassistant/components/energy/validate.py index e48a576f44e..3baae348770 100644 --- a/homeassistant/components/energy/validate.py +++ b/homeassistant/components/energy/validate.py @@ -10,6 +10,7 @@ from homeassistant.components import recorder, sensor from homeassistant.const import ( ATTR_DEVICE_CLASS, ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, ENERGY_WATT_HOUR, STATE_UNAVAILABLE, STATE_UNKNOWN, @@ -23,7 +24,11 @@ from .const import DOMAIN ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,) ENERGY_USAGE_UNITS = { - sensor.SensorDeviceClass.ENERGY: (ENERGY_KILO_WATT_HOUR, ENERGY_WATT_HOUR) + sensor.SensorDeviceClass.ENERGY: ( + ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, + ) } ENERGY_PRICE_UNITS = tuple( f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units diff --git a/tests/components/energy/test_validate.py b/tests/components/energy/test_validate.py index e802688daaf..fe71663d41b 100644 --- a/tests/components/energy/test_validate.py +++ b/tests/components/energy/test_validate.py @@ -4,6 +4,11 @@ from unittest.mock import patch import pytest from homeassistant.components.energy import async_get_manager, validate +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + ENERGY_MEGA_WATT_HOUR, + ENERGY_WATT_HOUR, +) from homeassistant.helpers.json import JSON_DUMP from homeassistant.setup import async_setup_component @@ -60,16 +65,18 @@ async def test_validation_empty_config(hass): @pytest.mark.parametrize( - "state_class, extra", + "state_class, energy_unit, extra", [ - ("total_increasing", {}), - ("total", {}), - ("total", {"last_reset": "abc"}), - ("measurement", {"last_reset": "abc"}), + ("total_increasing", ENERGY_KILO_WATT_HOUR, {}), + ("total_increasing", ENERGY_MEGA_WATT_HOUR, {}), + ("total_increasing", ENERGY_WATT_HOUR, {}), + ("total", ENERGY_KILO_WATT_HOUR, {}), + ("total", ENERGY_KILO_WATT_HOUR, {"last_reset": "abc"}), + ("measurement", ENERGY_KILO_WATT_HOUR, {"last_reset": "abc"}), ], ) async def test_validation( - hass, mock_energy_manager, mock_get_metadata, state_class, extra + hass, mock_energy_manager, mock_get_metadata, state_class, energy_unit, extra ): """Test validating success.""" for key in ("device_cons", "battery_import", "battery_export", "solar_production"): @@ -78,7 +85,7 @@ async def test_validation( "123", { "device_class": "energy", - "unit_of_measurement": "kWh", + "unit_of_measurement": energy_unit, "state_class": state_class, **extra, }, From 79fdb0d847e4ecd514e8586b7869f34355aef6fb Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 29 Jun 2022 11:43:51 +0200 Subject: [PATCH 841/947] Netgear add update entity (#72429) --- .coveragerc | 1 + homeassistant/components/netgear/__init__.py | 15 ++++ homeassistant/components/netgear/const.py | 9 ++- homeassistant/components/netgear/router.py | 10 +++ homeassistant/components/netgear/update.py | 81 ++++++++++++++++++++ 5 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/netgear/update.py diff --git a/.coveragerc b/.coveragerc index 981e2d06680..02c643ae757 100644 --- a/.coveragerc +++ b/.coveragerc @@ -793,6 +793,7 @@ omit = homeassistant/components/netgear/router.py homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py + homeassistant/components/netgear/update.py homeassistant/components/netgear_lte/* homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 6e56aef91c5..a996699ab9e 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -15,6 +15,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( DOMAIN, KEY_COORDINATOR, + KEY_COORDINATOR_FIRMWARE, KEY_COORDINATOR_LINK, KEY_COORDINATOR_SPEED, KEY_COORDINATOR_TRAFFIC, @@ -29,6 +30,7 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=30) SPEED_TEST_INTERVAL = timedelta(seconds=1800) +SCAN_INTERVAL_FIRMWARE = timedelta(seconds=18000) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -85,6 +87,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Fetch data from the router.""" return await router.async_get_speed_test() + async def async_check_firmware() -> dict[str, Any] | None: + """Check for new firmware of the router.""" + return await router.async_check_new_firmware() + async def async_update_utilization() -> dict[str, Any] | None: """Fetch data from the router.""" return await router.async_get_utilization() @@ -115,6 +121,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=async_update_speed_test, update_interval=SPEED_TEST_INTERVAL, ) + coordinator_firmware = DataUpdateCoordinator( + hass, + _LOGGER, + name=f"{router.device_name} Firmware", + update_method=async_check_firmware, + update_interval=SCAN_INTERVAL_FIRMWARE, + ) coordinator_utilization = DataUpdateCoordinator( hass, _LOGGER, @@ -133,6 +146,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if router.track_devices: await coordinator.async_config_entry_first_refresh() await coordinator_traffic_meter.async_config_entry_first_refresh() + await coordinator_firmware.async_config_entry_first_refresh() await coordinator_utilization.async_config_entry_first_refresh() await coordinator_link.async_config_entry_first_refresh() @@ -141,6 +155,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: KEY_COORDINATOR: coordinator, KEY_COORDINATOR_TRAFFIC: coordinator_traffic_meter, KEY_COORDINATOR_SPEED: coordinator_speed_test, + KEY_COORDINATOR_FIRMWARE: coordinator_firmware, KEY_COORDINATOR_UTIL: coordinator_utilization, KEY_COORDINATOR_LINK: coordinator_link, } diff --git a/homeassistant/components/netgear/const.py b/homeassistant/components/netgear/const.py index f9b9a93b767..eaa32362baf 100644 --- a/homeassistant/components/netgear/const.py +++ b/homeassistant/components/netgear/const.py @@ -5,7 +5,13 @@ from homeassistant.const import Platform DOMAIN = "netgear" -PLATFORMS = [Platform.BUTTON, Platform.DEVICE_TRACKER, Platform.SENSOR, Platform.SWITCH] +PLATFORMS = [ + Platform.BUTTON, + Platform.DEVICE_TRACKER, + Platform.SENSOR, + Platform.SWITCH, + Platform.UPDATE, +] CONF_CONSIDER_HOME = "consider_home" @@ -13,6 +19,7 @@ KEY_ROUTER = "router" KEY_COORDINATOR = "coordinator" KEY_COORDINATOR_TRAFFIC = "coordinator_traffic" KEY_COORDINATOR_SPEED = "coordinator_speed" +KEY_COORDINATOR_FIRMWARE = "coordinator_firmware" KEY_COORDINATOR_UTIL = "coordinator_utilization" KEY_COORDINATOR_LINK = "coordinator_link" diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 57b0efa1bf2..a4f8a4df14e 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -250,6 +250,16 @@ class NetgearRouter: async with self._api_lock: await self.hass.async_add_executor_job(self._api.reboot) + async def async_check_new_firmware(self) -> None: + """Check for new firmware of the router.""" + async with self._api_lock: + return await self.hass.async_add_executor_job(self._api.check_new_firmware) + + async def async_update_new_firmware(self) -> None: + """Update the router to the latest firmware.""" + async with self._api_lock: + await self.hass.async_add_executor_job(self._api.update_new_firmware) + @property def port(self) -> int: """Port used by the API.""" diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py new file mode 100644 index 00000000000..8d4a9b4912a --- /dev/null +++ b/homeassistant/components/netgear/update.py @@ -0,0 +1,81 @@ +"""Update entities for Netgear devices.""" +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER +from .router import NetgearRouter, NetgearRouterEntity + +LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up update entities for Netgear component.""" + router = hass.data[DOMAIN][entry.entry_id][KEY_ROUTER] + coordinator = hass.data[DOMAIN][entry.entry_id][KEY_COORDINATOR_FIRMWARE] + entities = [NetgearUpdateEntity(coordinator, router)] + + async_add_entities(entities) + + +class NetgearUpdateEntity(NetgearRouterEntity, UpdateEntity): + """Update entity for a Netgear device.""" + + _attr_device_class = UpdateDeviceClass.FIRMWARE + _attr_supported_features = UpdateEntityFeature.INSTALL + + def __init__( + self, + coordinator: DataUpdateCoordinator, + router: NetgearRouter, + ) -> None: + """Initialize a Netgear device.""" + super().__init__(coordinator, router) + self._name = f"{router.device_name} Update" + self._unique_id = f"{router.serial_number}-update" + + @property + def installed_version(self) -> str | None: + """Version currently in use.""" + if self.coordinator.data is not None: + return self.coordinator.data.get("CurrentVersion") + return None + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if self.coordinator.data is not None: + new_version = self.coordinator.data.get("NewVersion") + if new_version is not None: + return new_version + return self.installed_version + + @property + def release_summary(self) -> str | None: + """Release summary.""" + if self.coordinator.data is not None: + return self.coordinator.data.get("ReleaseNote") + return None + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install the latest firmware version.""" + await self._router.async_update_new_firmware() + + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" From 75efb54cc29f7eae34de6b8d8f8a011cdd6c80d2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:45:31 +0200 Subject: [PATCH 842/947] Adjust async_step_reauth in apple_tv (#74166) --- homeassistant/components/apple_tv/config_flow.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 1f3133d7e16..b78add3260e 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -3,9 +3,11 @@ from __future__ import annotations import asyncio from collections import deque +from collections.abc import Mapping from ipaddress import ip_address import logging from random import randrange +from typing import Any from pyatv import exceptions, pair, scan from pyatv.const import DeviceModel, PairingRequirement, Protocol @@ -13,10 +15,11 @@ from pyatv.convert import model_str, protocol_str from pyatv.helpers import get_unique_id import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import zeroconf from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_PIN from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.network import is_ipv6_address @@ -118,10 +121,10 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return entry.unique_id return None - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle initial step when updating invalid credentials.""" self.context["title_placeholders"] = { - "name": user_input[CONF_NAME], + "name": entry_data[CONF_NAME], "type": "Apple TV", } self.scan_filter = self.unique_id @@ -166,7 +169,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle device found via zeroconf.""" host = discovery_info.host if is_ipv6_address(host): @@ -250,7 +253,7 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ): # Add potentially new identifiers from this device to the existing flow context["all_identifiers"].append(unique_id) - raise data_entry_flow.AbortFlow("already_in_progress") + raise AbortFlow("already_in_progress") async def async_found_zeroconf_device(self, user_input=None): """Handle device found after Zeroconf discovery.""" From 1b85929617c2852854bfc4b5992f1b856a076122 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:46:59 +0200 Subject: [PATCH 843/947] Adjust async_step_reauth in samsungtv (#74165) --- .../components/samsungtv/config_flow.py | 63 ++++++++----------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index 130b1d28d5f..099f0afbae8 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -11,7 +11,7 @@ import getmac from samsungtvws.encrypted.authenticator import SamsungTVEncryptedWSAsyncAuthenticator import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import dhcp, ssdp, zeroconf from homeassistant.const import ( CONF_HOST, @@ -23,6 +23,7 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac @@ -123,7 +124,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_SSDP_MAIN_TV_AGENT_LOCATION: self._ssdp_main_tv_agent_location, } - def _get_entry_from_bridge(self) -> data_entry_flow.FlowResult: + def _get_entry_from_bridge(self) -> FlowResult: """Get device entry.""" assert self._bridge data = self._base_config_entry() @@ -137,7 +138,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_set_device_unique_id(self, raise_on_progress: bool = True) -> None: """Set device unique_id.""" if not await self._async_get_and_check_device_info(): - raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) + raise AbortFlow(RESULT_NOT_SUPPORTED) await self._async_set_unique_id_from_udn(raise_on_progress) self._async_update_and_abort_for_matching_unique_id() @@ -156,7 +157,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._ssdp_rendering_control_location, self._ssdp_main_tv_agent_location, ): - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") # Now that we have updated the config entry, we can raise # if another one is progressing if raise_on_progress: @@ -184,7 +185,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): result, method, _info = await self._async_get_device_info_and_method() if result not in SUCCESSFUL_RESULTS: LOGGER.debug("No working config found for %s", self._host) - raise data_entry_flow.AbortFlow(result) + raise AbortFlow(result) assert method is not None self._bridge = SamsungTVBridge.get_bridge(self.hass, method, self._host) return @@ -207,7 +208,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Try to get the device info.""" result, _method, info = await self._async_get_device_info_and_method() if result not in SUCCESSFUL_RESULTS: - raise data_entry_flow.AbortFlow(result) + raise AbortFlow(result) if not info: return False dev_info = info.get("device", {}) @@ -216,7 +217,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug( "Host:%s has type: %s which is not supported", self._host, device_type ) - raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) + raise AbortFlow(RESULT_NOT_SUPPORTED) self._model = dev_info.get("modelName") name = dev_info.get("name") self._name = name.replace("[TV] ", "") if name else device_type @@ -230,9 +231,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._mac = mac return True - async def async_step_import( - self, user_input: dict[str, Any] - ) -> data_entry_flow.FlowResult: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle configuration by yaml file.""" # We need to import even if we cannot validate # since the TV may be off at startup @@ -257,13 +256,13 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): socket.gethostbyname, user_input[CONF_HOST] ) except socket.gaierror as err: - raise data_entry_flow.AbortFlow(RESULT_UNKNOWN_HOST) from err + raise AbortFlow(RESULT_UNKNOWN_HOST) from err self._name = user_input.get(CONF_NAME, self._host) or "" self._title = self._name async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a flow initialized by the user.""" if user_input is not None: await self._async_set_name_host_from_input(user_input) @@ -281,7 +280,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_pairing( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a pairing by accepting the message on the TV.""" assert self._bridge is not None errors: dict[str, str] = {} @@ -290,7 +289,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if result == RESULT_SUCCESS: return self._get_entry_from_bridge() if result != RESULT_AUTH_MISSING: - raise data_entry_flow.AbortFlow(result) + raise AbortFlow(result) errors = {"base": RESULT_AUTH_MISSING} self.context["title_placeholders"] = {"device": self._title} @@ -303,7 +302,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_encrypted_pairing( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a encrypted pairing.""" assert self._host is not None await self._async_start_encrypted_pairing(self._host) @@ -421,7 +420,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if (entry := self._async_update_existing_matching_entry()) and entry.unique_id: # If we have the unique id and the mac we abort # as we do not need anything else - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") self._async_abort_if_host_already_in_progress() @callback @@ -429,18 +428,16 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context[CONF_HOST] = self._host for progress in self._async_in_progress(): if progress.get("context", {}).get(CONF_HOST) == self._host: - raise data_entry_flow.AbortFlow("already_in_progress") + raise AbortFlow("already_in_progress") @callback def _abort_if_manufacturer_is_not_samsung(self) -> None: if not self._manufacturer or not self._manufacturer.lower().startswith( "samsung" ): - raise data_entry_flow.AbortFlow(RESULT_NOT_SUPPORTED) + raise AbortFlow(RESULT_NOT_SUPPORTED) - async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a flow initialized by ssdp discovery.""" LOGGER.debug("Samsung device found via SSDP: %s", discovery_info) model_name: str = discovery_info.upnp.get(ssdp.ATTR_UPNP_MODEL_NAME) or "" @@ -485,9 +482,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"device": self._title} return await self.async_step_confirm() - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle a flow initialized by dhcp discovery.""" LOGGER.debug("Samsung device found via DHCP: %s", discovery_info) self._mac = discovery_info.macaddress @@ -499,7 +494,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" LOGGER.debug("Samsung device found via ZEROCONF: %s", discovery_info) self._mac = format_mac(discovery_info.properties["deviceid"]) @@ -511,7 +506,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_confirm( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle user-confirmation of discovered node.""" if user_input is not None: await self._async_create_bridge() @@ -524,24 +519,20 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="confirm", description_placeholders={"device": self._title} ) - async def async_step_reauth( - self, data: Mapping[str, Any] - ) -> data_entry_flow.FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) - assert self._reauth_entry - data = self._reauth_entry.data - if data.get(CONF_MODEL) and data.get(CONF_NAME): - self._title = f"{data[CONF_NAME]} ({data[CONF_MODEL]})" + if entry_data.get(CONF_MODEL) and entry_data.get(CONF_NAME): + self._title = f"{entry_data[CONF_NAME]} ({entry_data[CONF_MODEL]})" else: - self._title = data.get(CONF_NAME) or data[CONF_HOST] + self._title = entry_data.get(CONF_NAME) or entry_data[CONF_HOST] return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Confirm reauth.""" errors = {} assert self._reauth_entry @@ -586,7 +577,7 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_reauth_confirm_encrypted( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Confirm reauth (encrypted method).""" errors = {} assert self._reauth_entry From 306486edfda581e9b30de8bcea0be58f7d66936f Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:51:57 +0200 Subject: [PATCH 844/947] Adjust async_step_reauth in smarttub (#74170) --- homeassistant/components/smarttub/config_flow.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/smarttub/config_flow.py b/homeassistant/components/smarttub/config_flow.py index 88ec38e8d63..ec3dcb9c97f 100644 --- a/homeassistant/components/smarttub/config_flow.py +++ b/homeassistant/components/smarttub/config_flow.py @@ -1,9 +1,15 @@ """Config flow to configure the SmartTub integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + from smarttub import LoginFailed import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import FlowResult from .const import DOMAIN from .controller import SmartTubController @@ -21,8 +27,8 @@ class SmartTubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Instantiate config flow.""" super().__init__() - self._reauth_input = None - self._reauth_entry = None + self._reauth_input: Mapping[str, Any] | None = None + self._reauth_entry: config_entries.ConfigEntry | None = None async def async_step_user(self, user_input=None): """Handle a flow initiated by the user.""" @@ -60,9 +66,9 @@ class SmartTubConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=DATA_SCHEMA, errors=errors ) - async def async_step_reauth(self, user_input=None): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Get new credentials if the current ones don't work anymore.""" - self._reauth_input = dict(user_input) + self._reauth_input = entry_data self._reauth_entry = self.hass.config_entries.async_get_entry( self.context["entry_id"] ) From 9c991d9c6fcd6bc7683f21342c0dcaf8fbd6e091 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:53:08 +0200 Subject: [PATCH 845/947] Adjust async_step_reauth in isy994 (#74169) --- .../components/isy994/config_flow.py | 29 +++++++------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/isy994/config_flow.py b/homeassistant/components/isy994/config_flow.py index ff14c9bfc33..0dab84878b0 100644 --- a/homeassistant/components/isy994/config_flow.py +++ b/homeassistant/components/isy994/config_flow.py @@ -13,10 +13,11 @@ from pyisy.configuration import Configuration from pyisy.connection import Connection import voluptuous as vol -from homeassistant import config_entries, core, data_entry_flow, exceptions +from homeassistant import config_entries, core, exceptions from homeassistant.components import dhcp, ssdp from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import aiohttp_client from .const import ( @@ -132,7 +133,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle the initial step.""" errors = {} info: dict[str, str] = {} @@ -160,9 +161,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import( - self, user_input: dict[str, Any] - ) -> data_entry_flow.FlowResult: + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: """Handle import.""" return await self.async_step_user(user_input) @@ -174,7 +173,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if not existing_entry: return if existing_entry.source == config_entries.SOURCE_IGNORE: - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") parsed_url = urlparse(existing_entry.data[CONF_HOST]) if parsed_url.hostname != ip_address: new_netloc = ip_address @@ -198,11 +197,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ), }, ) - raise data_entry_flow.AbortFlow("already_configured") + raise AbortFlow("already_configured") - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle a discovered isy994 via dhcp.""" friendly_name = discovery_info.hostname if friendly_name.startswith("polisy"): @@ -223,9 +220,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() - async def async_step_ssdp( - self, discovery_info: ssdp.SsdpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_ssdp(self, discovery_info: ssdp.SsdpServiceInfo) -> FlowResult: """Handle a discovered isy994.""" friendly_name = discovery_info.upnp[ssdp.ATTR_UPNP_FRIENDLY_NAME] url = discovery_info.ssdp_location @@ -254,16 +249,14 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = self.discovered_conf return await self.async_step_user() - async def async_step_reauth( - self, data: Mapping[str, Any] - ) -> data_entry_flow.FlowResult: + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle reauth.""" self._existing_entry = await self.async_set_unique_id(self.context["unique_id"]) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle reauth input.""" errors = {} assert self._existing_entry is not None @@ -315,7 +308,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init( self, user_input: dict[str, Any] | None = None - ) -> data_entry_flow.FlowResult: + ) -> FlowResult: """Handle options flow.""" if user_input is not None: return self.async_create_entry(title="", data=user_input) From 2fce301b34c8cf37822cc120958a6024c41ff391 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:53:35 +0200 Subject: [PATCH 846/947] Adjust async_step_reauth in broadlink (#74168) --- .../components/broadlink/config_flow.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/broadlink/config_flow.py b/homeassistant/components/broadlink/config_flow.py index 8a32ba02ee8..5a0ed45b2ba 100644 --- a/homeassistant/components/broadlink/config_flow.py +++ b/homeassistant/components/broadlink/config_flow.py @@ -1,8 +1,10 @@ """Config flow for Broadlink devices.""" +from collections.abc import Mapping import errno from functools import partial import logging import socket +from typing import Any import broadlink as blk from broadlink.exceptions import ( @@ -12,9 +14,10 @@ from broadlink.exceptions import ( ) import voluptuous as vol -from homeassistant import config_entries, data_entry_flow +from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_TIMEOUT, CONF_TYPE +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from .const import DEFAULT_PORT, DEFAULT_TIMEOUT, DEVICE_TYPES, DOMAIN @@ -40,7 +43,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "an issue at https://github.com/home-assistant/core/issues", hex(device.devtype), ) - raise data_entry_flow.AbortFlow("not_supported") + raise AbortFlow("not_supported") await self.async_set_unique_id( device.mac.hex(), raise_on_progress=raise_on_progress @@ -53,9 +56,7 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "host": device.host[0], } - async def async_step_dhcp( - self, discovery_info: dhcp.DhcpServiceInfo - ) -> data_entry_flow.FlowResult: + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" host = discovery_info.ip unique_id = discovery_info.macaddress.lower().replace(":", "") @@ -300,14 +301,14 @@ class BroadlinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._async_abort_entries_match({CONF_HOST: import_info[CONF_HOST]}) return await self.async_step_user(import_info) - async def async_step_reauth(self, data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Reauthenticate to the device.""" device = blk.gendevice( - data[CONF_TYPE], - (data[CONF_HOST], DEFAULT_PORT), - bytes.fromhex(data[CONF_MAC]), - name=data[CONF_NAME], + entry_data[CONF_TYPE], + (entry_data[CONF_HOST], DEFAULT_PORT), + bytes.fromhex(entry_data[CONF_MAC]), + name=entry_data[CONF_NAME], ) - device.timeout = data[CONF_TIMEOUT] + device.timeout = entry_data[CONF_TIMEOUT] await self.async_set_device(device) return await self.async_step_reset() From 078c5cea86816031e4ce0a92e6b23fdd0ad11faf Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 11:54:21 +0200 Subject: [PATCH 847/947] Adjust async_step_reauth in blink (#74167) --- homeassistant/components/blink/config_flow.py | 7 +++++-- tests/components/blink/test_config_flow.py | 4 +++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index b62c7414f46..4f1c1997cad 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -1,7 +1,9 @@ """Config flow to configure Blink.""" from __future__ import annotations +from collections.abc import Mapping import logging +from typing import Any from blinkpy.auth import Auth, LoginError, TokenRefreshFailed from blinkpy.blinkpy import Blink, BlinkSetupError @@ -15,6 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult from .const import DEFAULT_SCAN_INTERVAL, DEVICE_ID, DOMAIN @@ -120,9 +123,9 @@ class BlinkConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_reauth(self, entry_data): + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon migration of old entries.""" - return await self.async_step_user(entry_data) + return await self.async_step_user(dict(entry_data)) @callback def _async_finish_flow(self): diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py index 5e3b89002bf..5ea03eb2b62 100644 --- a/tests/components/blink/test_config_flow.py +++ b/tests/components/blink/test_config_flow.py @@ -246,7 +246,9 @@ async def test_form_unknown_error(hass): async def test_reauth_shows_user_step(hass): """Test reauth shows the user form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + DOMAIN, + context={"source": config_entries.SOURCE_REAUTH}, + data={"username": "blink@example.com", "password": "invalid_password"}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" From b5af96e4bb201c9bb43515ea11283bdc8c4212b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Huryn?= Date: Wed, 29 Jun 2022 11:57:55 +0200 Subject: [PATCH 848/947] Bump blebox_uniapi to 2.0.0 and adapt integration (#73834) --- CODEOWNERS | 4 +- homeassistant/components/blebox/__init__.py | 8 +- .../components/blebox/air_quality.py | 4 + homeassistant/components/blebox/climate.py | 4 + .../components/blebox/config_flow.py | 6 +- homeassistant/components/blebox/light.py | 122 +++++++++++++++--- homeassistant/components/blebox/manifest.json | 4 +- homeassistant/components/blebox/switch.py | 4 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/blebox/conftest.py | 12 +- tests/components/blebox/test_config_flow.py | 4 +- tests/components/blebox/test_light.py | 9 +- 13 files changed, 140 insertions(+), 45 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e2d0cbdaa3f..e349ebeacf0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -129,8 +129,8 @@ build.json @home-assistant/supervisor /homeassistant/components/binary_sensor/ @home-assistant/core /tests/components/binary_sensor/ @home-assistant/core /homeassistant/components/bizkaibus/ @UgaitzEtxebarria -/homeassistant/components/blebox/ @bbx-a @bbx-jp -/tests/components/blebox/ @bbx-a @bbx-jp +/homeassistant/components/blebox/ @bbx-a @bbx-jp @riokuu +/tests/components/blebox/ @bbx-a @bbx-jp @riokuu /homeassistant/components/blink/ @fronzbot /tests/components/blink/ @fronzbot /homeassistant/components/blueprint/ @home-assistant/core diff --git a/homeassistant/components/blebox/__init__.py b/homeassistant/components/blebox/__init__.py index b6a0045940d..a8f93dd0122 100644 --- a/homeassistant/components/blebox/__init__.py +++ b/homeassistant/components/blebox/__init__.py @@ -1,8 +1,8 @@ """The BleBox devices integration.""" import logging +from blebox_uniapi.box import Box from blebox_uniapi.error import Error -from blebox_uniapi.products import Products from blebox_uniapi.session import ApiHost from homeassistant.config_entries import ConfigEntry @@ -30,7 +30,6 @@ PARALLEL_UPDATES = 0 async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up BleBox devices from a config entry.""" - websession = async_get_clientsession(hass) host = entry.data[CONF_HOST] @@ -40,7 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api_host = ApiHost(host, port, timeout, websession, hass.loop) try: - product = await Products.async_from_host(api_host) + product = await Box.async_from_host(api_host) except Error as ex: _LOGGER.error("Identify failed at %s:%d (%s)", api_host.host, api_host.port, ex) raise ConfigEntryNotReady from ex @@ -50,7 +49,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: product = domain_entry.setdefault(PRODUCT, product) hass.config_entries.async_setup_platforms(entry, PLATFORMS) - return True @@ -71,8 +69,8 @@ def create_blebox_entities( """Create entities from a BleBox product's features.""" product = hass.data[DOMAIN][config_entry.entry_id][PRODUCT] - entities = [] + if entity_type in product.features: for feature in product.features[entity_type]: entities.append(entity_klass(feature)) diff --git a/homeassistant/components/blebox/air_quality.py b/homeassistant/components/blebox/air_quality.py index 31efd797678..daadbc831b6 100644 --- a/homeassistant/components/blebox/air_quality.py +++ b/homeassistant/components/blebox/air_quality.py @@ -1,4 +1,6 @@ """BleBox air quality entity.""" +from datetime import timedelta + from homeassistant.components.air_quality import AirQualityEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -6,6 +8,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BleBoxEntity, create_blebox_entities +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/blebox/climate.py b/homeassistant/components/blebox/climate.py index 20a019ec0ec..e279991df20 100644 --- a/homeassistant/components/blebox/climate.py +++ b/homeassistant/components/blebox/climate.py @@ -1,4 +1,6 @@ """BleBox climate entity.""" +from datetime import timedelta + from homeassistant.components.climate import ClimateEntity from homeassistant.components.climate.const import ( ClimateEntityFeature, @@ -12,6 +14,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BleBoxEntity, create_blebox_entities +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/blebox/config_flow.py b/homeassistant/components/blebox/config_flow.py index 17dffe154d1..5ae975f83d9 100644 --- a/homeassistant/components/blebox/config_flow.py +++ b/homeassistant/components/blebox/config_flow.py @@ -1,8 +1,8 @@ """Config flow for BleBox devices integration.""" import logging +from blebox_uniapi.box import Box from blebox_uniapi.error import Error, UnsupportedBoxVersion -from blebox_uniapi.products import Products from blebox_uniapi.session import ApiHost import voluptuous as vol @@ -65,7 +65,6 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, step, exception, schema, host, port, message_id, log_fn ): """Handle step exceptions.""" - log_fn("%s at %s:%d (%s)", LOG_MSG[message_id], host, port, exception) return self.async_show_form( @@ -101,9 +100,8 @@ class BleBoxConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): websession = async_get_clientsession(hass) api_host = ApiHost(*addr, DEFAULT_SETUP_TIMEOUT, websession, hass.loop, _LOGGER) - try: - product = await Products.async_from_host(api_host) + product = await Box.async_from_host(api_host) except UnsupportedBoxVersion as ex: return self.handle_step_exception( diff --git a/homeassistant/components/blebox/light.py b/homeassistant/components/blebox/light.py index a582fe133c1..32cc6360db1 100644 --- a/homeassistant/components/blebox/light.py +++ b/homeassistant/components/blebox/light.py @@ -1,23 +1,34 @@ """BleBox light entities implementation.""" +from __future__ import annotations + +from datetime import timedelta import logging from blebox_uniapi.error import BadOnValueError +import blebox_uniapi.light +from blebox_uniapi.light import BleboxColorMode from homeassistant.components.light import ( ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP, + ATTR_EFFECT, + ATTR_RGB_COLOR, ATTR_RGBW_COLOR, + ATTR_RGBWW_COLOR, ColorMode, LightEntity, + LightEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util.color import color_rgb_to_hex, rgb_hex_to_rgb_list from . import BleBoxEntity, create_blebox_entities _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, @@ -31,6 +42,17 @@ async def async_setup_entry( ) +COLOR_MODE_MAP = { + BleboxColorMode.RGBW: ColorMode.RGBW, + BleboxColorMode.RGB: ColorMode.RGB, + BleboxColorMode.MONO: ColorMode.BRIGHTNESS, + BleboxColorMode.RGBorW: ColorMode.RGBW, # white hex is prioritised over RGB channel + BleboxColorMode.CT: ColorMode.COLOR_TEMP, + BleboxColorMode.CTx2: ColorMode.COLOR_TEMP, # two instances + BleboxColorMode.RGBWW: ColorMode.RGBWW, +} + + class BleBoxLightEntity(BleBoxEntity, LightEntity): """Representation of BleBox lights.""" @@ -38,6 +60,7 @@ class BleBoxLightEntity(BleBoxEntity, LightEntity): """Initialize a BleBox light.""" super().__init__(feature) self._attr_supported_color_modes = {self.color_mode} + self._attr_supported_features = LightEntityFeature.EFFECT @property def is_on(self) -> bool: @@ -49,46 +72,105 @@ class BleBoxLightEntity(BleBoxEntity, LightEntity): """Return the name.""" return self._feature.brightness + @property + def color_temp(self): + """Return color temperature.""" + return self._feature.color_temp + @property def color_mode(self): - """Return the color mode.""" - if self._feature.supports_white and self._feature.supports_color: - return ColorMode.RGBW - if self._feature.supports_brightness: - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF + """Return the color mode. + + Set values to _attr_ibutes if needed. + """ + color_mode_tmp = COLOR_MODE_MAP.get(self._feature.color_mode, ColorMode.ONOFF) + if color_mode_tmp == ColorMode.COLOR_TEMP: + self._attr_min_mireds = 1 + self._attr_max_mireds = 255 + + return color_mode_tmp + + @property + def effect_list(self) -> list[str] | None: + """Return the list of supported effects.""" + return self._feature.effect_list + + @property + def effect(self) -> str | None: + """Return the current effect.""" + return self._feature.effect + + @property + def rgb_color(self): + """Return value for rgb.""" + if (rgb_hex := self._feature.rgb_hex) is None: + return None + return tuple( + blebox_uniapi.light.Light.normalise_elements_of_rgb( + blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgb_hex)[0:3] + ) + ) @property def rgbw_color(self): """Return the hue and saturation.""" if (rgbw_hex := self._feature.rgbw_hex) is None: return None + return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbw_hex)[0:4]) - return tuple(rgb_hex_to_rgb_list(rgbw_hex)[0:4]) + @property + def rgbww_color(self): + """Return value for rgbww.""" + if (rgbww_hex := self._feature.rgbww_hex) is None: + return None + return tuple(blebox_uniapi.light.Light.rgb_hex_to_rgb_list(rgbww_hex)) async def async_turn_on(self, **kwargs): """Turn the light on.""" rgbw = kwargs.get(ATTR_RGBW_COLOR) brightness = kwargs.get(ATTR_BRIGHTNESS) - + effect = kwargs.get(ATTR_EFFECT) + color_temp = kwargs.get(ATTR_COLOR_TEMP) + rgbww = kwargs.get(ATTR_RGBWW_COLOR) feature = self._feature value = feature.sensible_on_value - - if brightness is not None: - value = feature.apply_brightness(value, brightness) + rgb = kwargs.get(ATTR_RGB_COLOR) if rgbw is not None: - value = feature.apply_white(value, rgbw[3]) - value = feature.apply_color(value, color_rgb_to_hex(*rgbw[0:3])) - - try: - await self._feature.async_on(value) - except BadOnValueError as ex: - _LOGGER.error( - "Turning on '%s' failed: Bad value %s (%s)", self.name, value, ex + value = list(rgbw) + if color_temp is not None: + value = feature.return_color_temp_with_brightness( + int(color_temp), self.brightness ) + if rgbww is not None: + value = list(rgbww) + + if rgb is not None: + if self.color_mode == ColorMode.RGB and brightness is None: + brightness = self.brightness + value = list(rgb) + + if brightness is not None: + if self.color_mode == ATTR_COLOR_TEMP: + value = feature.return_color_temp_with_brightness( + self.color_temp, brightness + ) + else: + value = feature.apply_brightness(value, brightness) + + if effect is not None: + effect_value = self.effect_list.index(effect) + await self._feature.async_api_command("effect", effect_value) + else: + try: + await self._feature.async_on(value) + except BadOnValueError as ex: + _LOGGER.error( + "Turning on '%s' failed: Bad value %s (%s)", self.name, value, ex + ) + async def async_turn_off(self, **kwargs): """Turn the light off.""" await self._feature.async_off() diff --git a/homeassistant/components/blebox/manifest.json b/homeassistant/components/blebox/manifest.json index d9c0481fff6..5c57d5f6b9f 100644 --- a/homeassistant/components/blebox/manifest.json +++ b/homeassistant/components/blebox/manifest.json @@ -3,8 +3,8 @@ "name": "BleBox devices", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/blebox", - "requirements": ["blebox_uniapi==1.3.3"], - "codeowners": ["@bbx-a", "@bbx-jp"], + "requirements": ["blebox_uniapi==2.0.0"], + "codeowners": ["@bbx-a", "@bbx-jp", "@riokuu"], "iot_class": "local_polling", "loggers": ["blebox_uniapi"] } diff --git a/homeassistant/components/blebox/switch.py b/homeassistant/components/blebox/switch.py index 9586b37558f..50eba1d2c4a 100644 --- a/homeassistant/components/blebox/switch.py +++ b/homeassistant/components/blebox/switch.py @@ -1,4 +1,6 @@ """BleBox switch implementation.""" +from datetime import timedelta + from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -7,6 +9,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import BleBoxEntity, create_blebox_entities from .const import BLEBOX_TO_HASS_DEVICE_CLASSES +SCAN_INTERVAL = timedelta(seconds=5) + async def async_setup_entry( hass: HomeAssistant, diff --git a/requirements_all.txt b/requirements_all.txt index 11ed4a2113a..58d8cfb604a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -399,7 +399,7 @@ bimmer_connected==0.9.6 bizkaibus==0.1.1 # homeassistant.components.blebox -blebox_uniapi==1.3.3 +blebox_uniapi==2.0.0 # homeassistant.components.blink blinkpy==0.19.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dc7366f151b..af336175e95 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -311,7 +311,7 @@ bellows==0.31.0 bimmer_connected==0.9.6 # homeassistant.components.blebox -blebox_uniapi==1.3.3 +blebox_uniapi==2.0.0 # homeassistant.components.blink blinkpy==0.19.0 diff --git a/tests/components/blebox/conftest.py b/tests/components/blebox/conftest.py index a63a0090c3a..548c7a5dc38 100644 --- a/tests/components/blebox/conftest.py +++ b/tests/components/blebox/conftest.py @@ -16,12 +16,11 @@ from tests.components.light.conftest import mock_light_profiles # noqa: F401 def patch_product_identify(path=None, **kwargs): """Patch the blebox_uniapi Products class.""" - if path is None: - path = "homeassistant.components.blebox.Products" - patcher = patch(path, mock.DEFAULT, blebox_uniapi.products.Products, True, True) - products_class = patcher.start() - products_class.async_from_host = AsyncMock(**kwargs) - return products_class + patcher = patch.object( + blebox_uniapi.box.Box, "async_from_host", AsyncMock(**kwargs) + ) + patcher.start() + return blebox_uniapi.box.Box def setup_product_mock(category, feature_mocks, path=None): @@ -84,7 +83,6 @@ async def async_setup_entities(hass, config, entity_ids): config_entry.add_to_hass(hass) assert await async_setup_component(hass, DOMAIN, config) await hass.async_block_till_done() - entity_registry = er.async_get(hass) return [entity_registry.async_get(entity_id) for entity_id in entity_ids] diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 03f5d0b4f2a..3f40880abf7 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -77,8 +77,8 @@ async def test_flow_works(hass, valid_feature_mock, flow_feature_mock): @pytest.fixture(name="product_class_mock") def product_class_mock_fixture(): """Return a mocked feature.""" - path = "homeassistant.components.blebox.config_flow.Products" - patcher = patch(path, DEFAULT, blebox_uniapi.products.Products, True, True) + path = "homeassistant.components.blebox.config_flow.Box" + patcher = patch(path, DEFAULT, blebox_uniapi.box.Box, True, True) yield patcher diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py index a546424e14b..4663c216136 100644 --- a/tests/components/blebox/test_light.py +++ b/tests/components/blebox/test_light.py @@ -39,6 +39,8 @@ def dimmer_fixture(): is_on=True, supports_color=False, supports_white=False, + color_mode=blebox_uniapi.light.BleboxColorMode.MONO, + effect_list=None, ) product = feature.product type(product).name = PropertyMock(return_value="My dimmer") @@ -210,6 +212,8 @@ def wlightboxs_fixture(): is_on=None, supports_color=False, supports_white=False, + color_mode=blebox_uniapi.light.BleboxColorMode.MONO, + effect_list=["NONE", "PL", "RELAX"], ) product = feature.product type(product).name = PropertyMock(return_value="My wLightBoxS") @@ -310,6 +314,9 @@ def wlightbox_fixture(): supports_white=True, white_value=None, rgbw_hex=None, + color_mode=blebox_uniapi.light.BleboxColorMode.RGBW, + effect="NONE", + effect_list=["NONE", "PL", "POLICE"], ) product = feature.product type(product).name = PropertyMock(return_value="My wLightBox") @@ -379,7 +386,7 @@ async def test_wlightbox_on_rgbw(wlightbox, hass, config): def turn_on(value): feature_mock.is_on = True - assert value == "c1d2f3c7" + assert value == [193, 210, 243, 199] feature_mock.white_value = 0xC7 # on feature_mock.rgbw_hex = "c1d2f3c7" From c186a73e572137e96b832d50a72bb670fdac8620 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 12:01:09 +0200 Subject: [PATCH 849/947] Tweak speed util (#74160) --- homeassistant/util/speed.py | 20 ++++++++++---------- tests/util/test_speed.py | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py index f3fc652e90f..14b28bde676 100644 --- a/homeassistant/util/speed.py +++ b/homeassistant/util/speed.py @@ -15,27 +15,27 @@ from homeassistant.const import ( ) VALID_UNITS: tuple[str, ...] = ( - SPEED_METERS_PER_SECOND, - SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, - SPEED_MILLIMETERS_PER_DAY, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_MILLIMETERS_PER_DAY, ) HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds +IN_TO_M = 0.0254 KM_TO_M = 1000 # 1 km = 1000 m -KM_TO_MILE = 0.62137119 # 1 km = 0.62137119 mi -M_TO_IN = 39.3700787 # 1 m = 39.3700787 in +MILE_TO_M = 1609.344 # Units in terms of m/s UNIT_CONVERSION: dict[str, float] = { - SPEED_METERS_PER_SECOND: 1, + SPEED_INCHES_PER_DAY: (24 * HRS_TO_SECS) / IN_TO_M, + SPEED_INCHES_PER_HOUR: HRS_TO_SECS / IN_TO_M, SPEED_KILOMETERS_PER_HOUR: HRS_TO_SECS / KM_TO_M, - SPEED_MILES_PER_HOUR: HRS_TO_SECS * KM_TO_MILE / KM_TO_M, + SPEED_METERS_PER_SECOND: 1, + SPEED_MILES_PER_HOUR: HRS_TO_SECS / MILE_TO_M, SPEED_MILLIMETERS_PER_DAY: (24 * HRS_TO_SECS) * 1000, - SPEED_INCHES_PER_DAY: (24 * HRS_TO_SECS) * M_TO_IN, - SPEED_INCHES_PER_HOUR: HRS_TO_SECS * M_TO_IN, } diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py index 7f52c67ed50..f0a17e6ae15 100644 --- a/tests/util/test_speed.py +++ b/tests/util/test_speed.py @@ -59,7 +59,7 @@ def test_convert_nonnumeric_value(): (5, SPEED_INCHES_PER_HOUR, 3048, SPEED_MILLIMETERS_PER_DAY), # 5 m/s * 39.3701 in/m * 3600 s/hr = 708661 (5, SPEED_METERS_PER_SECOND, 708661, SPEED_INCHES_PER_HOUR), - # 5000 in/hr / 39.3701 in/m / 3600 s/hr = 0.03528 m/s + # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s (5000, SPEED_INCHES_PER_HOUR, 0.03528, SPEED_METERS_PER_SECOND), ], ) From 21e765207cf225a0e61fd93964917642498353f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Wed, 29 Jun 2022 12:03:32 +0200 Subject: [PATCH 850/947] Bump pyatv to 0.10.2 (#74119) --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 26a8e2737c8..dec195fddee 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -3,7 +3,7 @@ "name": "Apple TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/apple_tv", - "requirements": ["pyatv==0.10.0"], + "requirements": ["pyatv==0.10.2"], "dependencies": ["zeroconf"], "zeroconf": [ "_mediaremotetv._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 58d8cfb604a..f63ff73eccf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1381,7 +1381,7 @@ pyatmo==6.2.4 pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.10.0 +pyatv==0.10.2 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af336175e95..1e25b245d52 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -938,7 +938,7 @@ pyatag==0.3.5.3 pyatmo==6.2.4 # homeassistant.components.apple_tv -pyatv==0.10.0 +pyatv==0.10.2 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 From 21d28dd35629a7f4fc086bf9ff4f65ee9270873b Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Wed, 29 Jun 2022 20:13:33 +1000 Subject: [PATCH 851/947] Migrate usgs_earthquakes_feed to async library (#68370) * use new async integration library * migrate to new async integration library * updated unit tests * updated logger * fix tests and improve test coverage * fix test * fix requirements * time control to fix tests --- .../usgs_earthquakes_feed/geo_location.py | 68 +++++++++++-------- .../usgs_earthquakes_feed/manifest.json | 4 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- .../test_geo_location.py | 33 +++++---- 5 files changed, 66 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index d26f97b295d..6e3eb9b2337 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -4,9 +4,7 @@ from __future__ import annotations from datetime import timedelta import logging -from geojson_client.usgs_earthquake_hazards_program_feed import ( - UsgsEarthquakeHazardsProgramFeedManager, -) +from aio_geojson_usgs_earthquakes import UsgsEarthquakeHazardsProgramFeedManager import voluptuous as vol from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent @@ -21,10 +19,14 @@ from homeassistant.const import ( LENGTH_KILOMETERS, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) @@ -87,10 +89,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the USGS Earthquake Hazards Program Feed platform.""" @@ -103,21 +105,22 @@ def setup_platform( radius_in_km = config[CONF_RADIUS] minimum_magnitude = config[CONF_MINIMUM_MAGNITUDE] # Initialize the entity manager. - feed = UsgsEarthquakesFeedEntityManager( + manager = UsgsEarthquakesFeedEntityManager( hass, - add_entities, + async_add_entities, scan_interval, coordinates, feed_type, radius_in_km, minimum_magnitude, ) + await manager.async_init() - def start_feed_manager(event): + async def start_feed_manager(event=None): """Start feed manager.""" - feed.startup() + await manager.async_update() - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) class UsgsEarthquakesFeedEntityManager: @@ -126,7 +129,7 @@ class UsgsEarthquakesFeedEntityManager: def __init__( self, hass, - add_entities, + async_add_entities, scan_interval, coordinates, feed_type, @@ -136,7 +139,9 @@ class UsgsEarthquakesFeedEntityManager: """Initialize the Feed Entity Manager.""" self._hass = hass + websession = aiohttp_client.async_get_clientsession(hass) self._feed_manager = UsgsEarthquakeHazardsProgramFeedManager( + websession, self._generate_entity, self._update_entity, self._remove_entity, @@ -145,37 +150,42 @@ class UsgsEarthquakesFeedEntityManager: filter_radius=radius_in_km, filter_minimum_magnitude=minimum_magnitude, ) - self._add_entities = add_entities + self._async_add_entities = async_add_entities self._scan_interval = scan_interval - def startup(self): - """Start up this manager.""" - self._feed_manager.update() - self._init_regular_updates() + async def async_init(self): + """Schedule initial and regular updates based on configured time interval.""" - def _init_regular_updates(self): - """Schedule regular updates at the specified interval.""" - track_time_interval( - self._hass, lambda now: self._feed_manager.update(), self._scan_interval - ) + async def update(event_time): + """Update.""" + await self.async_update() + + # Trigger updates at regular intervals. + async_track_time_interval(self._hass, update, self._scan_interval) + _LOGGER.debug("Feed entity manager initialized") + + async def async_update(self): + """Refresh data.""" + await self._feed_manager.update() + _LOGGER.debug("Feed entity manager updated") def get_entry(self, external_id): """Get feed entry by external id.""" return self._feed_manager.feed_entries.get(external_id) - def _generate_entity(self, external_id): + async def _generate_entity(self, external_id): """Generate new entity.""" new_entity = UsgsEarthquakesEvent(self, external_id) # Add new entities to HA. - self._add_entities([new_entity], True) + self._async_add_entities([new_entity], True) - def _update_entity(self, external_id): + async def _update_entity(self, external_id): """Update entity.""" - dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) - def _remove_entity(self, external_id): + async def _remove_entity(self, external_id): """Remove entity.""" - dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) class UsgsEarthquakesEvent(GeolocationEvent): diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json index 9c1f4566dc3..bd8ec9633bd 100644 --- a/homeassistant/components/usgs_earthquakes_feed/manifest.json +++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json @@ -2,8 +2,8 @@ "domain": "usgs_earthquakes_feed", "name": "U.S. Geological Survey Earthquake Hazards (USGS)", "documentation": "https://www.home-assistant.io/integrations/usgs_earthquakes_feed", - "requirements": ["geojson_client==0.6"], + "requirements": ["aio_geojson_usgs_earthquakes==0.1"], "codeowners": ["@exxamalte"], "iot_class": "cloud_polling", - "loggers": ["geojson_client"] + "loggers": ["aio_geojson_usgs_earthquakes"] } diff --git a/requirements_all.txt b/requirements_all.txt index f63ff73eccf..d758df0a97c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -106,6 +106,9 @@ aio_geojson_geonetnz_volcano==0.6 # homeassistant.components.nsw_rural_fire_service_feed aio_geojson_nsw_rfs_incidents==0.4 +# homeassistant.components.usgs_earthquakes_feed +aio_geojson_usgs_earthquakes==0.1 + # homeassistant.components.gdacs aio_georss_gdacs==0.7 @@ -699,9 +702,6 @@ geniushub-client==0.6.30 # homeassistant.components.geocaching geocachingapi==0.2.1 -# homeassistant.components.usgs_earthquakes_feed -geojson_client==0.6 - # homeassistant.components.aprs geopy==2.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1e25b245d52..c2cf31965f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -93,6 +93,9 @@ aio_geojson_geonetnz_volcano==0.6 # homeassistant.components.nsw_rural_fire_service_feed aio_geojson_nsw_rfs_incidents==0.4 +# homeassistant.components.usgs_earthquakes_feed +aio_geojson_usgs_earthquakes==0.1 + # homeassistant.components.gdacs aio_georss_gdacs==0.7 @@ -499,9 +502,6 @@ gcal-sync==0.10.0 # homeassistant.components.geocaching geocachingapi==0.2.1 -# homeassistant.components.usgs_earthquakes_feed -geojson_client==0.6 - # homeassistant.components.aprs geopy==2.1.0 diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py index ee845701d81..b8fcbbcbe7d 100644 --- a/tests/components/usgs_earthquakes_feed/test_geo_location.py +++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py @@ -1,6 +1,9 @@ """The tests for the USGS Earthquake Hazards Program Feed platform.""" import datetime -from unittest.mock import MagicMock, call, patch +from unittest.mock import ANY, MagicMock, call, patch + +from aio_geojson_usgs_earthquakes import UsgsEarthquakeHazardsProgramFeed +from freezegun import freeze_time from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE @@ -111,11 +114,10 @@ async def test_setup(hass): # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "geojson_client.usgs_earthquake_hazards_program_feed." - "UsgsEarthquakeHazardsProgramFeed" - ) as mock_feed: - mock_feed.return_value.update.return_value = ( + with freeze_time(utcnow), patch( + "aio_geojson_client.feed.GeoJsonFeed.update" + ) as mock_feed_update: + mock_feed_update.return_value = ( "OK", [mock_entry_1, mock_entry_2, mock_entry_3], ) @@ -184,9 +186,9 @@ async def test_setup(hass): } assert round(abs(float(state.state) - 25.5), 7) == 0 - # Simulate an update - one existing, one new entry, + # Simulate an update - two existing, one new entry, # one outdated entry - mock_feed.return_value.update.return_value = ( + mock_feed_update.return_value = ( "OK", [mock_entry_1, mock_entry_4, mock_entry_3], ) @@ -198,7 +200,7 @@ async def test_setup(hass): # Simulate an update - empty data, but successful update, # so no changes to entities. - mock_feed.return_value.update.return_value = "OK_NO_DATA", None + mock_feed_update.return_value = "OK_NO_DATA", None async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL) await hass.async_block_till_done() @@ -206,7 +208,7 @@ async def test_setup(hass): assert len(all_states) == 3 # Simulate an update - empty data, removes all entities - mock_feed.return_value.update.return_value = "ERROR", None + mock_feed_update.return_value = "ERROR", None async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL) await hass.async_block_till_done() @@ -220,10 +222,12 @@ async def test_setup_with_custom_location(hass): mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 20.5, (-31.1, 150.1)) with patch( - "geojson_client.usgs_earthquake_hazards_program_feed." - "UsgsEarthquakeHazardsProgramFeed" - ) as mock_feed: - mock_feed.return_value.update.return_value = "OK", [mock_entry_1] + "aio_geojson_usgs_earthquakes.feed_manager.UsgsEarthquakeHazardsProgramFeed", + wraps=UsgsEarthquakeHazardsProgramFeed, + ) as mock_feed, patch( + "aio_geojson_client.feed.GeoJsonFeed.update" + ) as mock_feed_update: + mock_feed_update.return_value = "OK", [mock_entry_1] with assert_setup_component(1, geo_location.DOMAIN): assert await async_setup_component( @@ -240,6 +244,7 @@ async def test_setup_with_custom_location(hass): assert len(all_states) == 1 assert mock_feed.call_args == call( + ANY, (15.1, 25.2), "past_hour_m25_earthquakes", filter_minimum_magnitude=0.0, From 29a546f4e8a85cdfa83bf46ef8a9077127861343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 29 Jun 2022 12:29:47 +0200 Subject: [PATCH 852/947] Remove deprecated YAML import for Tautulli (#74172) --- .../components/tautulli/config_flow.py | 54 ++----------------- homeassistant/components/tautulli/const.py | 4 -- homeassistant/components/tautulli/sensor.py | 42 +-------------- tests/components/tautulli/test_config_flow.py | 36 +------------ 4 files changed, 7 insertions(+), 129 deletions(-) diff --git a/homeassistant/components/tautulli/config_flow.py b/homeassistant/components/tautulli/config_flow.py index d70384c5485..f06405825c9 100644 --- a/homeassistant/components/tautulli/config_flow.py +++ b/homeassistant/components/tautulli/config_flow.py @@ -4,36 +4,15 @@ from __future__ import annotations from collections.abc import Mapping from typing import Any -from pytautulli import ( - PyTautulli, - PyTautulliException, - PyTautulliHostConfiguration, - exceptions, -) +from pytautulli import PyTautulli, PyTautulliException, exceptions import voluptuous as vol -from homeassistant.components.sensor import _LOGGER from homeassistant.config_entries import ConfigFlow -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_PATH, - CONF_PORT, - CONF_SSL, - CONF_URL, - CONF_VERIFY_SSL, -) +from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import ( - DEFAULT_NAME, - DEFAULT_PATH, - DEFAULT_PORT, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import DEFAULT_NAME, DOMAIN class TautulliConfigFlow(ConfigFlow, domain=DOMAIN): @@ -95,33 +74,6 @@ class TautulliConfigFlow(ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - _LOGGER.warning( - "Configuration of the Tautulli platform in YAML is deprecated and will be " - "removed in Home Assistant 2022.6; Your existing configuration for host %s" - "has been imported into the UI automatically and can be safely removed " - "from your configuration.yaml file", - config[CONF_HOST], - ) - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - host_configuration = PyTautulliHostConfiguration( - config[CONF_API_KEY], - ipaddress=config[CONF_HOST], - port=config.get(CONF_PORT, DEFAULT_PORT), - ssl=config.get(CONF_SSL, DEFAULT_SSL), - verify_ssl=config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), - base_api_path=config.get(CONF_PATH, DEFAULT_PATH), - ) - return await self.async_step_user( - { - CONF_API_KEY: host_configuration.api_token, - CONF_URL: host_configuration.base_url, - CONF_VERIFY_SSL: host_configuration.verify_ssl, - } - ) - async def validate_input(self, user_input: dict[str, Any]) -> str | None: """Try connecting to Tautulli.""" try: diff --git a/homeassistant/components/tautulli/const.py b/homeassistant/components/tautulli/const.py index 49b86ec6ef7..c0ca923c3e5 100644 --- a/homeassistant/components/tautulli/const.py +++ b/homeassistant/components/tautulli/const.py @@ -5,9 +5,5 @@ ATTR_TOP_USER = "top_user" CONF_MONITORED_USERS = "monitored_users" DEFAULT_NAME = "Tautulli" -DEFAULT_PATH = "" -DEFAULT_PORT = "8181" -DEFAULT_SSL = False -DEFAULT_VERIFY_SSL = True DOMAIN = "tautulli" LOGGER: Logger = getLogger(__package__) diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py index 5981992c946..1d5efde7cc7 100644 --- a/homeassistant/components/tautulli/sensor.py +++ b/homeassistant/components/tautulli/sensor.py @@ -11,61 +11,23 @@ from pytautulli import ( PyTautulliApiSession, PyTautulliApiUser, ) -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - CONF_PATH, - CONF_PORT, - CONF_SSL, - CONF_VERIFY_SSL, - DATA_KILOBITS, - PERCENTAGE, -) +from homeassistant.const import DATA_KILOBITS, PERCENTAGE from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import EntityCategory, EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from . import TautulliEntity -from .const import ( - ATTR_TOP_USER, - CONF_MONITORED_USERS, - DEFAULT_NAME, - DEFAULT_PATH, - DEFAULT_PORT, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import ATTR_TOP_USER, DOMAIN from .coordinator import TautulliDataUpdateCoordinator -# Deprecated in Home Assistant 2022.4 -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_MONITORED_USERS): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, - vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - } -) - def get_top_stats( home_stats: PyTautulliApiHomeStats, activity: PyTautulliApiActivity, key: str diff --git a/tests/components/tautulli/test_config_flow.py b/tests/components/tautulli/test_config_flow.py index 95ccfbaa9b7..d37e4401275 100644 --- a/tests/components/tautulli/test_config_flow.py +++ b/tests/components/tautulli/test_config_flow.py @@ -2,11 +2,10 @@ from unittest.mock import AsyncMock, patch from pytautulli import exceptions -from pytest import LogCaptureFixture from homeassistant import data_entry_flow -from homeassistant.components.tautulli.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.components.tautulli.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_SOURCE from homeassistant.core import HomeAssistant @@ -127,37 +126,6 @@ async def test_flow_user_unknown_error(hass: HomeAssistant) -> None: assert result2["data"] == CONF_DATA -async def test_flow_import(hass: HomeAssistant, caplog: LogCaptureFixture) -> None: - """Test import step.""" - with patch_config_flow_tautulli(AsyncMock()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_IMPORT_DATA, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"] == CONF_DATA - assert "Tautulli platform in YAML" in caplog.text - - -async def test_flow_import_single_instance_allowed(hass: HomeAssistant) -> None: - """Test import step single instance allowed.""" - entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) - entry.add_to_hass(hass) - - with patch_config_flow_tautulli(AsyncMock()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_IMPORT_DATA, - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "single_instance_allowed" - - async def test_flow_reauth( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: From fd89108483e958b8e5328a29af887f312c9009aa Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 29 Jun 2022 12:31:50 +0200 Subject: [PATCH 853/947] Move add/remove logic of deCONZ groups to gateway class (#73952) --- homeassistant/components/deconz/gateway.py | 53 +++++++++++++--------- homeassistant/components/deconz/light.py | 31 ++----------- 2 files changed, 36 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index c94b2c6d86d..25471d1448a 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any, cast import async_timeout from pydeconz import DeconzSession, errors from pydeconz.interfaces.api import APIItems, GroupedAPIItems +from pydeconz.interfaces.groups import Groups from pydeconz.models.event import EventType from homeassistant.config_entries import SOURCE_HASSIO, ConfigEntry @@ -59,18 +60,18 @@ class DeconzGateway: self.ignore_state_updates = False self.signal_reachable = f"deconz-reachable-{config_entry.entry_id}" - self.signal_reload_groups = f"deconz_reload_group_{config_entry.entry_id}" self.signal_reload_clip_sensors = f"deconz_reload_clip_{config_entry.entry_id}" self.deconz_ids: dict[str, str] = {} self.entities: dict[str, set[str]] = {} self.events: list[DeconzAlarmEvent | DeconzEvent] = [] self.ignored_devices: set[tuple[Callable[[EventType, str], None], str]] = set() + self.deconz_groups: set[tuple[Callable[[EventType, str], None], str]] = set() - self._option_allow_deconz_groups = self.config_entry.options.get( + self.option_allow_deconz_groups = config_entry.options.get( CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS ) - self.option_allow_new_devices = self.config_entry.options.get( + self.option_allow_new_devices = config_entry.options.get( CONF_ALLOW_NEW_DEVICES, DEFAULT_ALLOW_NEW_DEVICES ) @@ -98,13 +99,6 @@ class DeconzGateway: CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR ) - @property - def option_allow_deconz_groups(self) -> bool: - """Allow loading deCONZ groups from gateway.""" - return self.config_entry.options.get( - CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS - ) - @callback def register_platform_add_device_callback( self, @@ -113,16 +107,28 @@ class DeconzGateway: ) -> None: """Wrap add_device_callback to check allow_new_devices option.""" - def async_add_device(event: EventType, device_id: str) -> None: + initializing = True + + def async_add_device(_: EventType, device_id: str) -> None: """Add device or add it to ignored_devices set. If ignore_state_updates is True means device_refresh service is used. Device_refresh is expected to load new devices. """ - if not self.option_allow_new_devices and not self.ignore_state_updates: + if ( + not initializing + and not self.option_allow_new_devices + and not self.ignore_state_updates + ): self.ignored_devices.add((async_add_device, device_id)) return - add_device_callback(event, device_id) + + if isinstance(deconz_device_interface, Groups): + self.deconz_groups.add((async_add_device, device_id)) + if not self.option_allow_deconz_groups: + return + + add_device_callback(EventType.ADDED, device_id) self.config_entry.async_on_unload( deconz_device_interface.subscribe( @@ -132,7 +138,9 @@ class DeconzGateway: ) for device_id in deconz_device_interface: - add_device_callback(EventType.ADDED, device_id) + async_add_device(EventType.ADDED, device_id) + + initializing = False @callback def load_ignored_devices(self) -> None: @@ -216,13 +224,16 @@ class DeconzGateway: # Allow Groups - if self.option_allow_deconz_groups: - if not self._option_allow_deconz_groups: - async_dispatcher_send(self.hass, self.signal_reload_groups) - else: - deconz_ids += [group.deconz_id for group in self.api.groups.values()] - - self._option_allow_deconz_groups = self.option_allow_deconz_groups + option_allow_deconz_groups = self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ) + if option_allow_deconz_groups != self.option_allow_deconz_groups: + self.option_allow_deconz_groups = option_allow_deconz_groups + if option_allow_deconz_groups: + for add_device, device_id in self.deconz_groups: + add_device(EventType.ADDED, device_id) + else: + deconz_ids += [group.deconz_id for group in self.api.groups.values()] # Allow adding new devices diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 0cca007742a..669800e2662 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -33,7 +33,6 @@ from homeassistant.components.light import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.color import color_hs_to_xy @@ -109,11 +108,7 @@ async def async_setup_entry( Update group states based on its sum of related lights. """ - if ( - not gateway.option_allow_deconz_groups - or (group := gateway.api.groups[group_id]) - and not group.lights - ): + if (group := gateway.api.groups[group_id]) and not group.lights: return first = True @@ -128,29 +123,11 @@ async def async_setup_entry( async_add_entities([DeconzGroup(group, gateway)]) - config_entry.async_on_unload( - gateway.api.groups.subscribe( - async_add_group, - EventType.ADDED, - ) + gateway.register_platform_add_device_callback( + async_add_group, + gateway.api.groups, ) - @callback - def async_load_groups() -> None: - """Load deCONZ groups.""" - for group_id in gateway.api.groups: - async_add_group(EventType.ADDED, group_id) - - config_entry.async_on_unload( - async_dispatcher_connect( - hass, - gateway.signal_reload_groups, - async_load_groups, - ) - ) - - async_load_groups() - class DeconzBaseLight(Generic[_L], DeconzDevice, LightEntity): """Representation of a deCONZ light.""" From a6ef330b63eaa1b683c4940b8df66183ac46eb66 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 12:36:57 +0200 Subject: [PATCH 854/947] Add ButtonEntity to pylint checks (#74171) --- pylint/plugins/hass_enforce_type_hints.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index f08e6d932e7..c6e2fd1a553 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -697,6 +697,26 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "button": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="ButtonEntity", + matches=[ + TypeHintMatch( + function_name="device_class", + return_type=["ButtonDeviceClass", "str", None], + ), + TypeHintMatch( + function_name="press", + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], "cover": [ ClassTypeHintMatch( base_class="Entity", From f6f7fa1c2d0dfb8fd25c93f72f8ad89305c88f1d Mon Sep 17 00:00:00 2001 From: Khole Date: Wed, 29 Jun 2022 11:39:35 +0100 Subject: [PATCH 855/947] Add Hive power usage sensor (#74011) --- homeassistant/components/hive/sensor.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py index bab8648407a..5bac23fdb3d 100644 --- a/homeassistant/components/hive/sensor.py +++ b/homeassistant/components/hive/sensor.py @@ -5,9 +5,10 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PERCENTAGE +from homeassistant.const import PERCENTAGE, POWER_KILO_WATT from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,6 +24,12 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + SensorEntityDescription( + key="Power", + native_unit_of_measurement=POWER_KILO_WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), ) From e6daed971927836a3940dd19aada689b50d5ba20 Mon Sep 17 00:00:00 2001 From: Frank <46161394+BraveChicken1@users.noreply.github.com> Date: Wed, 29 Jun 2022 12:45:55 +0200 Subject: [PATCH 856/947] Add support for services to Home Connect (#58768) Co-authored-by: Erik Montnemery --- .../components/home_connect/__init__.py | 209 +++++++++++++++++- homeassistant/components/home_connect/api.py | 1 + .../components/home_connect/const.py | 12 + .../components/home_connect/entity.py | 1 + .../components/home_connect/services.yaml | 169 ++++++++++++++ 5 files changed, 388 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/home_connect/services.yaml diff --git a/homeassistant/components/home_connect/__init__.py b/homeassistant/components/home_connect/__init__.py index 345eeeddaaa..f57c7aeb8af 100644 --- a/homeassistant/components/home_connect/__init__.py +++ b/homeassistant/components/home_connect/__init__.py @@ -1,4 +1,5 @@ """Support for BSH Home Connect appliances.""" +from __future__ import annotations from datetime import timedelta import logging @@ -11,14 +12,39 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, Platform +from homeassistant.const import ( + ATTR_DEVICE_ID, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_DEVICE, + Platform, +) from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers import ( + config_entry_oauth2_flow, + config_validation as cv, + device_registry as dr, +) from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle from . import api -from .const import DOMAIN +from .const import ( + ATTR_KEY, + ATTR_PROGRAM, + ATTR_UNIT, + ATTR_VALUE, + BSH_PAUSE, + BSH_RESUME, + DOMAIN, + SERVICE_OPTION_ACTIVE, + SERVICE_OPTION_SELECTED, + SERVICE_PAUSE_PROGRAM, + SERVICE_RESUME_PROGRAM, + SERVICE_SELECT_PROGRAM, + SERVICE_SETTING, + SERVICE_START_PROGRAM, +) _LOGGER = logging.getLogger(__name__) @@ -39,9 +65,55 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) +SERVICE_SETTING_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + } +) + +SERVICE_OPTION_SCHEMA = vol.Schema( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): vol.Any(str, int, bool), + vol.Optional(ATTR_UNIT): str, + } +) + +SERVICE_PROGRAM_SCHEMA = vol.Any( + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): str, + vol.Required(ATTR_KEY): str, + vol.Required(ATTR_VALUE): vol.Any(int, str), + vol.Optional(ATTR_UNIT): str, + }, + { + vol.Required(ATTR_DEVICE_ID): str, + vol.Required(ATTR_PROGRAM): str, + }, +) + +SERVICE_COMMAND_SCHEMA = vol.Schema({vol.Required(ATTR_DEVICE_ID): str}) + PLATFORMS = [Platform.BINARY_SENSOR, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH] +def _get_appliance_by_device_id( + hass: HomeAssistant, device_id: str +) -> api.HomeConnectDevice | None: + """Return a Home Connect appliance instance given an device_id.""" + for hc_api in hass.data[DOMAIN].values(): + for dev_dict in hc_api.devices: + device = dev_dict[CONF_DEVICE] + if device.device_id == device_id: + return device.appliance + _LOGGER.error("Appliance for device id %s not found", device_id) + return None + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Home Connect component.""" hass.data[DOMAIN] = {} @@ -65,6 +137,121 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "configuration.yaml file" ) + async def _async_service_program(call, method): + """Execute calls to services taking a program.""" + program = call.data[ATTR_PROGRAM] + device_id = call.data[ATTR_DEVICE_ID] + options = { + ATTR_KEY: call.data.get(ATTR_KEY), + ATTR_VALUE: call.data.get(ATTR_VALUE), + ATTR_UNIT: call.data.get(ATTR_UNIT), + } + + appliance = _get_appliance_by_device_id(hass, device_id) + if appliance is not None: + await hass.async_add_executor_job( + getattr(appliance, method), program, options + ) + + async def _async_service_command(call, command): + """Execute calls to services executing a command.""" + device_id = call.data[ATTR_DEVICE_ID] + + appliance = _get_appliance_by_device_id(hass, device_id) + if appliance is not None: + await hass.async_add_executor_job(appliance.execute_command, command) + + async def _async_service_key_value(call, method): + """Execute calls to services taking a key and value.""" + key = call.data[ATTR_KEY] + value = call.data[ATTR_VALUE] + unit = call.data.get(ATTR_UNIT) + device_id = call.data[ATTR_DEVICE_ID] + + appliance = _get_appliance_by_device_id(hass, device_id) + if appliance is not None: + if unit is not None: + await hass.async_add_executor_job( + getattr(appliance, method), + key, + value, + unit, + ) + else: + await hass.async_add_executor_job( + getattr(appliance, method), + key, + value, + ) + + async def async_service_option_active(call): + """Service for setting an option for an active program.""" + await _async_service_key_value(call, "set_options_active_program") + + async def async_service_option_selected(call): + """Service for setting an option for a selected program.""" + await _async_service_key_value(call, "set_options_selected_program") + + async def async_service_setting(call): + """Service for changing a setting.""" + await _async_service_key_value(call, "set_setting") + + async def async_service_pause_program(call): + """Service for pausing a program.""" + await _async_service_command(call, BSH_PAUSE) + + async def async_service_resume_program(call): + """Service for resuming a paused program.""" + await _async_service_command(call, BSH_RESUME) + + async def async_service_select_program(call): + """Service for selecting a program.""" + await _async_service_program(call, "select_program") + + async def async_service_start_program(call): + """Service for starting a program.""" + await _async_service_program(call, "start_program") + + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_ACTIVE, + async_service_option_active, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_OPTION_SELECTED, + async_service_option_selected, + schema=SERVICE_OPTION_SCHEMA, + ) + hass.services.async_register( + DOMAIN, SERVICE_SETTING, async_service_setting, schema=SERVICE_SETTING_SCHEMA + ) + hass.services.async_register( + DOMAIN, + SERVICE_PAUSE_PROGRAM, + async_service_pause_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_RESUME_PROGRAM, + async_service_resume_program, + schema=SERVICE_COMMAND_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_SELECT_PROGRAM, + async_service_select_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + hass.services.async_register( + DOMAIN, + SERVICE_START_PROGRAM, + async_service_start_program, + schema=SERVICE_PROGRAM_SCHEMA, + ) + return True @@ -101,9 +288,23 @@ async def update_all_devices(hass, entry): """Update all the devices.""" data = hass.data[DOMAIN] hc_api = data[entry.entry_id] + + device_registry = dr.async_get(hass) try: await hass.async_add_executor_job(hc_api.get_devices) for device_dict in hc_api.devices: - await hass.async_add_executor_job(device_dict["device"].initialize) + device = device_dict["device"] + + device_entry = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, device.appliance.haId)}, + name=device.appliance.name, + manufacturer=device.appliance.brand, + model=device.appliance.vib, + ) + + device.device_id = device_entry.id + + await hass.async_add_executor_job(device.initialize) except HTTPError as err: _LOGGER.warning("Cannot update devices: %s", err.response.status_code) diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index f3c98e618b8..00d759b47d5 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -113,6 +113,7 @@ class HomeConnectDevice: """Initialize the device class.""" self.hass = hass self.appliance = appliance + self.entities = [] def initialize(self): """Fetch the info needed to initialize the device.""" diff --git a/homeassistant/components/home_connect/const.py b/homeassistant/components/home_connect/const.py index 438ee5ace16..9eabc9b5d43 100644 --- a/homeassistant/components/home_connect/const.py +++ b/homeassistant/components/home_connect/const.py @@ -30,12 +30,24 @@ BSH_DOOR_STATE_CLOSED = "BSH.Common.EnumType.DoorState.Closed" BSH_DOOR_STATE_LOCKED = "BSH.Common.EnumType.DoorState.Locked" BSH_DOOR_STATE_OPEN = "BSH.Common.EnumType.DoorState.Open" +BSH_PAUSE = "BSH.Common.Command.PauseProgram" +BSH_RESUME = "BSH.Common.Command.ResumeProgram" + SIGNAL_UPDATE_ENTITIES = "home_connect.update_entities" +SERVICE_OPTION_ACTIVE = "set_option_active" +SERVICE_OPTION_SELECTED = "set_option_selected" +SERVICE_PAUSE_PROGRAM = "pause_program" +SERVICE_RESUME_PROGRAM = "resume_program" +SERVICE_SELECT_PROGRAM = "select_program" +SERVICE_SETTING = "change_setting" +SERVICE_START_PROGRAM = "start_program" + ATTR_AMBIENT = "ambient" ATTR_DESC = "desc" ATTR_DEVICE = "device" ATTR_KEY = "key" +ATTR_PROGRAM = "program" ATTR_SENSOR_TYPE = "sensor_type" ATTR_SIGN = "sign" ATTR_UNIT = "unit" diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index b27988f997d..60a0c3974cd 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -20,6 +20,7 @@ class HomeConnectEntity(Entity): self.device = device self.desc = desc self._name = f"{self.device.appliance.name} {desc}" + self.device.entities.append(self) async def async_added_to_hass(self): """Register callbacks.""" diff --git a/homeassistant/components/home_connect/services.yaml b/homeassistant/components/home_connect/services.yaml new file mode 100644 index 00000000000..06a646dd481 --- /dev/null +++ b/homeassistant/components/home_connect/services.yaml @@ -0,0 +1,169 @@ +start_program: + name: Start program + description: Selects a program and starts it. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + program: + name: Program + description: Program to select + example: "Dishcare.Dishwasher.Program.Auto2" + required: true + selector: + text: + key: + name: Option key + description: Key of the option. + example: "BSH.Common.Option.StartInRelative" + selector: + text: + value: + name: Option value + description: Value of the option. + example: 1800 + selector: + object: + unit: + name: Option unit + description: Unit for the option. + example: "seconds" + selector: + text: +select_program: + name: Select program + description: Selects a program without starting it. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + program: + name: Program + description: Program to select + example: "Dishcare.Dishwasher.Program.Auto2" + required: true + selector: + text: + key: + name: Option key + description: Key of the option. + example: "BSH.Common.Option.StartInRelative" + selector: + text: + value: + name: Option value + description: Value of the option. + example: 1800 + selector: + object: + unit: + name: Option unit + description: Unit for the option. + example: "seconds" + selector: + text: +pause_program: + name: Pause program + description: Pauses the current running program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect +resume_program: + name: Resume program + description: Resumes a paused program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect +set_option_active: + name: Set active program option + description: Sets an option for the active program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + key: + name: Key + description: Key of the option. + example: "LaundryCare.Dryer.Option.DryingTarget" + required: true + selector: + text: + value: + name: Value + description: Value of the option. + example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" + required: true + selector: + object: +set_option_selected: + name: Set selected program option + description: Sets an option for the selected program. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + key: + name: Key + description: Key of the option. + example: "LaundryCare.Dryer.Option.DryingTarget" + required: true + selector: + text: + value: + name: Value + description: Value of the option. + example: "LaundryCare.Dryer.EnumType.DryingTarget.IronDry" + required: true + selector: + object: +change_setting: + name: Change setting + description: Changes a setting. + fields: + device_id: + name: Device ID + description: Id of the device. + required: true + selector: + device: + integration: home_connect + key: + name: Key + description: Key of the setting. + example: "BSH.Common.Setting.ChildLock" + required: true + selector: + text: + value: + name: Value + description: Value of the setting. + example: "true" + required: true + selector: + object: From 5167535b03d4184983071b9c9050018d62a9ca0b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 29 Jun 2022 12:51:37 +0200 Subject: [PATCH 857/947] Add LightEntity type hint checks to pylint plugin (#73826) --- pylint/plugins/hass_enforce_type_hints.py | 104 ++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index c6e2fd1a553..5f4f641cbae 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -893,6 +893,110 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ], ), ], + "light": [ + ClassTypeHintMatch( + base_class="Entity", + matches=_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="ToggleEntity", + matches=_TOGGLE_ENTITY_MATCH, + ), + ClassTypeHintMatch( + base_class="LightEntity", + matches=[ + TypeHintMatch( + function_name="brightness", + return_type=["int", None], + ), + TypeHintMatch( + function_name="color_mode", + return_type=["ColorMode", "str", None], + ), + TypeHintMatch( + function_name="hs_color", + return_type=["tuple[float, float]", None], + ), + TypeHintMatch( + function_name="xy_color", + return_type=["tuple[float, float]", None], + ), + TypeHintMatch( + function_name="rgb_color", + return_type=["tuple[int, int, int]", None], + ), + TypeHintMatch( + function_name="rgbw_color", + return_type=["tuple[int, int, int, int]", None], + ), + TypeHintMatch( + function_name="rgbww_color", + return_type=["tuple[int, int, int, int, int]", None], + ), + TypeHintMatch( + function_name="color_temp", + return_type=["int", None], + ), + TypeHintMatch( + function_name="min_mireds", + return_type="int", + ), + TypeHintMatch( + function_name="max_mireds", + return_type="int", + ), + TypeHintMatch( + function_name="white_value", + return_type=["int", None], + ), + TypeHintMatch( + function_name="effect_list", + return_type=["list[str]", None], + ), + TypeHintMatch( + function_name="effect", + return_type=["str", None], + ), + TypeHintMatch( + function_name="capability_attributes", + return_type=["dict[str, Any]", None], + ), + TypeHintMatch( + function_name="supported_color_modes", + return_type=["set[ColorMode]", "set[str]", None], + ), + TypeHintMatch( + function_name="supported_features", + return_type="int", + ), + TypeHintMatch( + function_name="turn_on", + named_arg_types={ + "brightness": "int | None", + "brightness_pct": "float | None", + "brightness_step": "int | None", + "brightness_step_pct": "float | None", + "color_name": "str | None", + "color_temp": "int | None", + "effect": "str | None", + "flash": "str | None", + "kelvin": "int | None", + "hs_color": "tuple[float, float] | None", + "rgb_color": "tuple[int, int, int] | None", + "rgbw_color": "tuple[int, int, int, int] | None", + "rgbww_color": "tuple[int, int, int, int, int] | None", + "transition": "float | None", + "xy_color": "tuple[float, float] | None", + "white": "int | None", + "white_value": "int | None", + }, + kwargs_type="Any", + return_type=None, + has_async_counterpart=True, + ), + ], + ), + ], "lock": [ ClassTypeHintMatch( base_class="Entity", From e32694c146bec200c6583f48959d058899581db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Sv=C3=A4rd?= Date: Wed, 29 Jun 2022 12:53:55 +0200 Subject: [PATCH 858/947] Make SolarEdge energy value validation a bit less aggressive (#69998) * Make energy value validation a bit less aggressive Attempt to solve issue 69600 introduced by previous fix for issue 59285. - Introduce a tolerance factor for energy value validation. - Only skip update the specific invalid energy entity. An energy entity with invalid values will now show "State unknown". * Remove the tolerance factor. Let's just ignore the specific invalid energy entity. --- .../components/solaredge/coordinator.py | 6 ++++-- tests/components/solaredge/test_coordinator.py | 16 ++++++++++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/solaredge/coordinator.py b/homeassistant/components/solaredge/coordinator.py index 4e93571f8a4..fe8f2f86a8e 100644 --- a/homeassistant/components/solaredge/coordinator.py +++ b/homeassistant/components/solaredge/coordinator.py @@ -94,8 +94,10 @@ class SolarEdgeOverviewDataService(SolarEdgeDataService): for index, key in enumerate(energy_keys, start=1): # All coming values in list should be larger than the current value. if any(self.data[k] > self.data[key] for k in energy_keys[index:]): - self.data = {} - raise UpdateFailed("Invalid energy values, skipping update") + LOGGER.info( + "Ignoring invalid energy value %s for %s", self.data[key], key + ) + self.data.pop(key) LOGGER.debug("Updated SolarEdge overview: %s", self.data) diff --git a/tests/components/solaredge/test_coordinator.py b/tests/components/solaredge/test_coordinator.py index b3c9227648e..eb5d033f112 100644 --- a/tests/components/solaredge/test_coordinator.py +++ b/tests/components/solaredge/test_coordinator.py @@ -6,8 +6,9 @@ from homeassistant.components.solaredge.const import ( DEFAULT_NAME, DOMAIN, OVERVIEW_UPDATE_DELAY, + SENSOR_TYPES, ) -from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNAVAILABLE +from homeassistant.const import CONF_API_KEY, CONF_NAME, STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -29,6 +30,9 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( ) mock_solaredge().get_details.return_value = {"details": {"status": "active"}} mock_config_entry.add_to_hass(hass) + for description in SENSOR_TYPES: + description.entity_registry_enabled_default = True + await hass.config_entries.async_setup(mock_config_entry.entry_id) # Valid energy values update @@ -56,7 +60,7 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( state = hass.states.get("sensor.solaredge_lifetime_energy") assert state - assert state.state == STATE_UNAVAILABLE + assert state.state == STATE_UNKNOWN # New valid energy values update mock_overview_data["overview"]["lifeTimeData"]["energy"] = 100001 @@ -74,9 +78,13 @@ async def test_solaredgeoverviewdataservice_energy_values_validity( async_fire_time_changed(hass, dt_util.utcnow() + OVERVIEW_UPDATE_DELAY) await hass.async_block_till_done() - state = hass.states.get("sensor.solaredge_lifetime_energy") + state = hass.states.get("sensor.solaredge_energy_this_year") assert state - assert state.state == STATE_UNAVAILABLE + assert state.state == STATE_UNKNOWN + # Check that the valid lastMonthData is still available + state = hass.states.get("sensor.solaredge_energy_this_month") + assert state + assert state.state == str(mock_overview_data["overview"]["lastMonthData"]["energy"]) # All zero energy values should also be valid. mock_overview_data["overview"]["lifeTimeData"]["energy"] = 0.0 From 4bdec1589d8a54ca8f8ba1059f4e40d6e956a6df Mon Sep 17 00:00:00 2001 From: beren12 Date: Wed, 29 Jun 2022 07:10:48 -0400 Subject: [PATCH 859/947] Ambient sensors are not diagnostic/internal (#73928) --- homeassistant/components/nut/const.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/homeassistant/components/nut/const.py b/homeassistant/components/nut/const.py index 9f6b43974b7..64dc95d7b95 100644 --- a/homeassistant/components/nut/const.py +++ b/homeassistant/components/nut/const.py @@ -555,8 +555,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, ), "ambient.temperature": SensorEntityDescription( key="ambient.temperature", @@ -564,8 +562,6 @@ SENSOR_TYPES: Final[dict[str, SensorEntityDescription]] = { native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, - entity_category=EntityCategory.DIAGNOSTIC, - entity_registry_enabled_default=False, ), "watts": SensorEntityDescription( key="watts", From 9392f599136f9e30767751393c97cca62c15e350 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 29 Jun 2022 07:56:02 -0400 Subject: [PATCH 860/947] Trigger Alexa routines from toggles and buttons (#67889) --- .../components/alexa/capabilities.py | 10 ++- homeassistant/components/alexa/entities.py | 8 ++- tests/components/alexa/test_capabilities.py | 51 +++++++++++++++ tests/components/alexa/test_smart_home.py | 62 +++++++++++++++++-- 4 files changed, 122 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 5b675779a22..818b4b794cf 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -4,9 +4,11 @@ from __future__ import annotations import logging from homeassistant.components import ( + button, cover, fan, image_processing, + input_button, input_number, light, timer, @@ -1891,7 +1893,10 @@ class AlexaEventDetectionSensor(AlexaCapability): if self.entity.domain == image_processing.DOMAIN: if int(state): human_presence = "DETECTED" - elif state == STATE_ON: + elif state == STATE_ON or self.entity.domain in [ + input_button.DOMAIN, + button.DOMAIN, + ]: human_presence = "DETECTED" return {"value": human_presence} @@ -1903,7 +1908,8 @@ class AlexaEventDetectionSensor(AlexaCapability): "detectionModes": { "humanPresence": { "featureAvailability": "ENABLED", - "supportsNotDetected": True, + "supportsNotDetected": self.entity.domain + not in [input_button.DOMAIN, button.DOMAIN], } }, } diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 9ee4ad3411f..f380f990449 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -382,7 +382,6 @@ def async_get_entities(hass, config) -> list[AlexaEntity]: @ENTITY_ADAPTERS.register(alert.DOMAIN) @ENTITY_ADAPTERS.register(automation.DOMAIN) @ENTITY_ADAPTERS.register(group.DOMAIN) -@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) class GenericCapabilities(AlexaEntity): """A generic, on/off device. @@ -405,12 +404,16 @@ class GenericCapabilities(AlexaEntity): ] +@ENTITY_ADAPTERS.register(input_boolean.DOMAIN) @ENTITY_ADAPTERS.register(switch.DOMAIN) class SwitchCapabilities(AlexaEntity): """Class to represent Switch capabilities.""" def default_display_categories(self): """Return the display categories for this entity.""" + if self.entity.domain == input_boolean.DOMAIN: + return [DisplayCategory.OTHER] + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) if device_class == switch.SwitchDeviceClass.OUTLET: return [DisplayCategory.SMARTPLUG] @@ -421,6 +424,7 @@ class SwitchCapabilities(AlexaEntity): """Yield the supported interfaces.""" return [ AlexaPowerController(self.entity), + AlexaContactSensor(self.hass, self.entity), AlexaEndpointHealth(self.hass, self.entity), Alexa(self.hass), ] @@ -439,6 +443,8 @@ class ButtonCapabilities(AlexaEntity): """Yield the supported interfaces.""" return [ AlexaSceneController(self.entity, supports_deactivation=False), + AlexaEventDetectionSensor(self.hass, self.entity), + AlexaEndpointHealth(self.hass, self.entity), Alexa(self.hass), ] diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 4cccae1f083..3e176b0fb8c 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -846,6 +846,57 @@ async def test_report_image_processing(hass): ) +@pytest.mark.parametrize("domain", ["button", "input_button"]) +async def test_report_button_pressed(hass, domain): + """Test button presses report human presence detection events to trigger routines.""" + hass.states.async_set( + f"{domain}.test_button", "now", {"friendly_name": "Test button"} + ) + + properties = await reported_properties(hass, f"{domain}#test_button") + properties.assert_equal( + "Alexa.EventDetectionSensor", + "humanPresenceDetectionState", + {"value": "DETECTED"}, + ) + + +@pytest.mark.parametrize("domain", ["switch", "input_boolean"]) +async def test_toggle_entities_report_contact_events(hass, domain): + """Test toggles and switches report contact sensor events to trigger routines.""" + hass.states.async_set( + f"{domain}.test_toggle", "on", {"friendly_name": "Test toggle"} + ) + + properties = await reported_properties(hass, f"{domain}#test_toggle") + properties.assert_equal( + "Alexa.PowerController", + "powerState", + "ON", + ) + properties.assert_equal( + "Alexa.ContactSensor", + "detectionState", + "DETECTED", + ) + + hass.states.async_set( + f"{domain}.test_toggle", "off", {"friendly_name": "Test toggle"} + ) + + properties = await reported_properties(hass, f"{domain}#test_toggle") + properties.assert_equal( + "Alexa.PowerController", + "powerState", + "OFF", + ) + properties.assert_equal( + "Alexa.ContactSensor", + "detectionState", + "NOT_DETECTED", + ) + + async def test_get_property_blowup(hass, caplog): """Test we handle a property blowing up.""" hass.states.async_set( diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 37888a2c415..0169eeff9d5 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -182,8 +182,12 @@ async def test_switch(hass, events): assert appliance["endpointId"] == "switch#test" assert appliance["displayCategories"][0] == "SWITCH" assert appliance["friendlyName"] == "Test switch" - assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ContactSensor", + "Alexa.EndpointHealth", + "Alexa", ) await assert_power_controller_works( @@ -192,6 +196,14 @@ async def test_switch(hass, events): properties = await reported_properties(hass, "switch#test") properties.assert_equal("Alexa.PowerController", "powerState", "ON") + properties.assert_equal("Alexa.ContactSensor", "detectionState", "DETECTED") + properties.assert_equal("Alexa.EndpointHealth", "connectivity", {"value": "OK"}) + + contact_sensor_capability = get_capability(capabilities, "Alexa.ContactSensor") + assert contact_sensor_capability is not None + properties = contact_sensor_capability["properties"] + assert properties["retrievable"] is True + assert {"name": "detectionState"} in properties["supported"] async def test_outlet(hass, events): @@ -207,7 +219,11 @@ async def test_outlet(hass, events): assert appliance["displayCategories"][0] == "SMARTPLUG" assert appliance["friendlyName"] == "Test switch" assert_endpoint_capabilities( - appliance, "Alexa", "Alexa.PowerController", "Alexa.EndpointHealth" + appliance, + "Alexa", + "Alexa.PowerController", + "Alexa.EndpointHealth", + "Alexa.ContactSensor", ) @@ -335,8 +351,12 @@ async def test_input_boolean(hass): assert appliance["endpointId"] == "input_boolean#test" assert appliance["displayCategories"][0] == "OTHER" assert appliance["friendlyName"] == "Test input boolean" - assert_endpoint_capabilities( - appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa" + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ContactSensor", + "Alexa.EndpointHealth", + "Alexa", ) await assert_power_controller_works( @@ -347,6 +367,17 @@ async def test_input_boolean(hass): "2022-04-19T07:53:05Z", ) + properties = await reported_properties(hass, "input_boolean#test") + properties.assert_equal("Alexa.PowerController", "powerState", "OFF") + properties.assert_equal("Alexa.ContactSensor", "detectionState", "NOT_DETECTED") + properties.assert_equal("Alexa.EndpointHealth", "connectivity", {"value": "OK"}) + + contact_sensor_capability = get_capability(capabilities, "Alexa.ContactSensor") + assert contact_sensor_capability is not None + properties = contact_sensor_capability["properties"] + assert properties["retrievable"] is True + assert {"name": "detectionState"} in properties["supported"] + @freeze_time("2022-04-19 07:53:05") async def test_scene(hass): @@ -4003,7 +4034,11 @@ async def test_button(hass, domain): assert appliance["friendlyName"] == "Ring Doorbell" capabilities = assert_endpoint_capabilities( - appliance, "Alexa.SceneController", "Alexa" + appliance, + "Alexa.SceneController", + "Alexa.EventDetectionSensor", + "Alexa.EndpointHealth", + "Alexa", ) scene_capability = get_capability(capabilities, "Alexa.SceneController") assert scene_capability["supportsDeactivation"] is False @@ -4016,6 +4051,21 @@ async def test_button(hass, domain): "2022-04-19T07:53:05Z", ) + event_detection_capability = get_capability( + capabilities, "Alexa.EventDetectionSensor" + ) + assert event_detection_capability is not None + properties = event_detection_capability["properties"] + assert properties["proactivelyReported"] is True + assert not properties["retrievable"] + assert {"name": "humanPresenceDetectionState"} in properties["supported"] + assert ( + event_detection_capability["configuration"]["detectionModes"]["humanPresence"][ + "supportsNotDetected" + ] + is False + ) + async def test_api_message_sets_authorized(hass): """Test an incoming API messages sets the authorized flag.""" From 46b4be5b41c22751dc45de869e4c5057ac502915 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 29 Jun 2022 13:24:50 +0100 Subject: [PATCH 861/947] Add boot time sensor to System Bridge (#73039) * Add boot time to System Bridge * Update homeassistant/components/system_bridge/sensor.py Co-authored-by: Paulus Schoutsen * Add missing import * Update homeassistant/components/system_bridge/sensor.py Co-authored-by: Paulus Schoutsen Co-authored-by: Paulus Schoutsen Co-authored-by: Franck Nijhof --- homeassistant/components/system_bridge/sensor.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/system_bridge/sensor.py b/homeassistant/components/system_bridge/sensor.py index b37ff66896e..bdfe5047e56 100644 --- a/homeassistant/components/system_bridge/sensor.py +++ b/homeassistant/components/system_bridge/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Final, cast from homeassistant.components.sensor import ( @@ -125,6 +125,15 @@ def memory_used(data: SystemBridgeCoordinatorData) -> float | None: BASE_SENSOR_TYPES: tuple[SystemBridgeSensorEntityDescription, ...] = ( + SystemBridgeSensorEntityDescription( + key="boot_time", + name="Boot Time", + device_class=SensorDeviceClass.TIMESTAMP, + icon="mdi:av-timer", + value=lambda data: datetime.fromtimestamp( + data.system.boot_time, tz=timezone.utc + ), + ), SystemBridgeSensorEntityDescription( key="cpu_speed", name="CPU Speed", From 329ecc74c4ed47385e8625e485b2d1a14fc8f7e0 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 29 Jun 2022 08:23:22 -0500 Subject: [PATCH 862/947] Optimize Sonos join behavior when using `media_player.join` (#74174) Optimize Sonos media_player.join service --- .../components/sonos/media_player.py | 19 +++++++------------ homeassistant/components/sonos/speaker.py | 4 ++-- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 7b18b102919..c68110d9763 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -763,19 +763,14 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity): async def async_join_players(self, group_members): """Join `group_members` as a player group with the current player.""" - async with self.hass.data[DATA_SONOS].topology_condition: - speakers = [] - for entity_id in group_members: - if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get( - entity_id - ): - speakers.append(speaker) - else: - raise HomeAssistantError( - f"Not a known Sonos entity_id: {entity_id}" - ) + speakers = [] + for entity_id in group_members: + if speaker := self.hass.data[DATA_SONOS].entity_id_mappings.get(entity_id): + speakers.append(speaker) + else: + raise HomeAssistantError(f"Not a known Sonos entity_id: {entity_id}") - await self.hass.async_add_executor_job(self.speaker.join, speakers) + await SonosSpeaker.join_multi(self.hass, self.speaker, speakers) async def async_unjoin_player(self): """Remove this player from any group. diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index f4d1d89aa2f..0c5bec06dfb 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -883,9 +883,9 @@ class SonosSpeaker: for speaker in speakers: if speaker.soco.uid != self.soco.uid: - speaker.soco.join(self.soco) - speaker.coordinator = self if speaker not in group: + speaker.soco.join(self.soco) + speaker.coordinator = self group.append(speaker) return group From 8905e6f7266e54ffbc4273da7240440b44f20971 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 29 Jun 2022 16:32:29 +0300 Subject: [PATCH 863/947] Use DataUpdateCoordinator for `mikrotik` (#72954) --- homeassistant/components/mikrotik/__init__.py | 9 +- .../components/mikrotik/device_tracker.py | 46 ++-------- homeassistant/components/mikrotik/hub.py | 52 +++-------- .../mikrotik/test_device_tracker.py | 4 +- tests/components/mikrotik/test_hub.py | 72 +-------------- tests/components/mikrotik/test_init.py | 92 +++++++++---------- 6 files changed, 81 insertions(+), 194 deletions(-) diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py index 25aa2eb1468..856495dc0f2 100644 --- a/homeassistant/components/mikrotik/__init__.py +++ b/homeassistant/components/mikrotik/__init__.py @@ -4,7 +4,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, device_registry as dr from .const import ATTR_MANUFACTURER, DOMAIN, PLATFORMS -from .hub import MikrotikHub +from .hub import MikrotikDataUpdateCoordinator CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @@ -12,11 +12,16 @@ CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Set up the Mikrotik component.""" - hub = MikrotikHub(hass, config_entry) + hub = MikrotikDataUpdateCoordinator(hass, config_entry) if not await hub.async_setup(): return False + await hub.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = hub + + hass.config_entries.async_setup_platforms(config_entry, PLATFORMS) + device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py index 16c3ed233d8..9389d3bea5c 100644 --- a/homeassistant/components/mikrotik/device_tracker.py +++ b/homeassistant/components/mikrotik/device_tracker.py @@ -1,8 +1,6 @@ """Support for Mikrotik routers as device tracker.""" from __future__ import annotations -import logging - from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import ( DOMAIN as DEVICE_TRACKER, @@ -11,13 +9,12 @@ from homeassistant.components.device_tracker.const import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity import homeassistant.util.dt as dt_util from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .hub import MikrotikDataUpdateCoordinator # These are normalized to ATTR_IP and ATTR_MAC to conform # to device_tracker @@ -32,7 +29,7 @@ async def async_setup_entry( """Set up device tracker for Mikrotik component.""" hub = hass.data[DOMAIN][config_entry.entry_id] - tracked: dict[str, MikrotikHubTracker] = {} + tracked: dict[str, MikrotikDataUpdateCoordinatorTracker] = {} registry = entity_registry.async_get(hass) @@ -56,7 +53,7 @@ async def async_setup_entry( """Update the status of the device.""" update_items(hub, async_add_entities, tracked) - async_dispatcher_connect(hass, hub.signal_update, update_hub) + config_entry.async_on_unload(hub.async_add_listener(update_hub)) update_hub() @@ -67,21 +64,22 @@ def update_items(hub, async_add_entities, tracked): new_tracked = [] for mac, device in hub.api.devices.items(): if mac not in tracked: - tracked[mac] = MikrotikHubTracker(device, hub) + tracked[mac] = MikrotikDataUpdateCoordinatorTracker(device, hub) new_tracked.append(tracked[mac]) if new_tracked: async_add_entities(new_tracked) -class MikrotikHubTracker(ScannerEntity): +class MikrotikDataUpdateCoordinatorTracker(CoordinatorEntity, ScannerEntity): """Representation of network device.""" + coordinator: MikrotikDataUpdateCoordinator + def __init__(self, device, hub): """Initialize the tracked device.""" + super().__init__(hub) self.device = device - self.hub = hub - self.unsub_dispatcher = None @property def is_connected(self): @@ -89,7 +87,7 @@ class MikrotikHubTracker(ScannerEntity): if ( self.device.last_seen and (dt_util.utcnow() - self.device.last_seen) - < self.hub.option_detection_time + < self.coordinator.option_detection_time ): return True return False @@ -125,33 +123,9 @@ class MikrotikHubTracker(ScannerEntity): """Return a unique identifier for this device.""" return self.device.mac - @property - def available(self) -> bool: - """Return if controller is available.""" - return self.hub.available - @property def extra_state_attributes(self): """Return the device state attributes.""" if self.is_connected: return {k: v for k, v in self.device.attrs.items() if k not in FILTER_ATTRS} return None - - async def async_added_to_hass(self): - """Client entity created.""" - _LOGGER.debug("New network device tracker %s (%s)", self.name, self.unique_id) - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, self.hub.signal_update, self.async_write_ha_state - ) - - async def async_update(self): - """Synchronize state with hub.""" - _LOGGER.debug( - "Updating Mikrotik tracked client %s (%s)", self.entity_id, self.unique_id - ) - await self.hub.request_update() - - async def will_remove_from_hass(self): - """Disconnect from dispatcher.""" - if self.unsub_dispatcher: - self.unsub_dispatcher() diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 63be0a4a358..7f2314bd057 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -9,7 +9,7 @@ from librouteros.login import plain as login_plain, token as login_token from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import slugify import homeassistant.util.dt as dt_util @@ -25,13 +25,13 @@ from .const import ( CONF_FORCE_DHCP, DEFAULT_DETECTION_TIME, DHCP, + DOMAIN, IDENTITY, INFO, IS_CAPSMAN, IS_WIRELESS, MIKROTIK_SERVICES, NAME, - PLATFORMS, WIRELESS, ) from .errors import CannotConnect, LoginError @@ -154,10 +154,8 @@ class MikrotikData: """Connect to hub.""" try: self.api = get_api(self.hass, self.config_entry.data) - self.available = True return True except (LoginError, CannotConnect): - self.available = False return False def get_list_from_interface(self, interface): @@ -194,9 +192,8 @@ class MikrotikData: # get new hub firmware version if updated self.firmware = self.get_info(ATTR_FIRMWARE) - except (CannotConnect, socket.timeout, OSError): - self.available = False - return + except (CannotConnect, socket.timeout, OSError) as err: + raise UpdateFailed from err if not device_list: return @@ -263,7 +260,8 @@ class MikrotikData: socket.timeout, ) as api_error: _LOGGER.error("Mikrotik %s connection error %s", self._host, api_error) - raise CannotConnect from api_error + if not self.connect_to_hub(): + raise CannotConnect from api_error except librouteros.exceptions.ProtocolError as api_error: _LOGGER.warning( "Mikrotik %s failed to retrieve data. cmd=[%s] Error: %s", @@ -275,15 +273,8 @@ class MikrotikData: return response if response else None - def update(self): - """Update device_tracker from Mikrotik API.""" - if (not self.available or not self.api) and not self.connect_to_hub(): - return - _LOGGER.debug("updating network devices for host: %s", self._host) - self.update_devices() - -class MikrotikHub: +class MikrotikDataUpdateCoordinator(DataUpdateCoordinator): """Mikrotik Hub Object.""" def __init__(self, hass, config_entry): @@ -291,7 +282,13 @@ class MikrotikHub: self.hass = hass self.config_entry = config_entry self._mk_data = None - self.progress = None + super().__init__( + self.hass, + _LOGGER, + name=f"{DOMAIN} - {self.host}", + update_method=self.async_update, + update_interval=timedelta(seconds=10), + ) @property def host(self): @@ -328,11 +325,6 @@ class MikrotikHub: """Config entry option defining number of seconds from last seen to away.""" return timedelta(seconds=self.config_entry.options[CONF_DETECTION_TIME]) - @property - def signal_update(self): - """Event specific per Mikrotik entry to signal updates.""" - return f"mikrotik-update-{self.host}" - @property def api(self): """Represent Mikrotik data object.""" @@ -354,21 +346,9 @@ class MikrotikHub: self.config_entry, data=data, options=options ) - async def request_update(self): - """Request an update.""" - if self.progress is not None: - await self.progress - return - - self.progress = self.hass.async_create_task(self.async_update()) - await self.progress - - self.progress = None - async def async_update(self): """Update Mikrotik devices information.""" - await self.hass.async_add_executor_job(self._mk_data.update) - async_dispatcher_send(self.hass, self.signal_update) + await self.hass.async_add_executor_job(self._mk_data.update_devices) async def async_setup(self): """Set up the Mikrotik hub.""" @@ -384,9 +364,7 @@ class MikrotikHub: self._mk_data = MikrotikData(self.hass, self.config_entry, api) await self.async_add_options() await self.hass.async_add_executor_job(self._mk_data.get_hub_details) - await self.hass.async_add_executor_job(self._mk_data.update) - self.hass.config_entries.async_setup_platforms(self.config_entry, PLATFORMS) return True diff --git a/tests/components/mikrotik/test_device_tracker.py b/tests/components/mikrotik/test_device_tracker.py index fd2f71b2589..fbbb016d09f 100644 --- a/tests/components/mikrotik/test_device_tracker.py +++ b/tests/components/mikrotik/test_device_tracker.py @@ -90,7 +90,7 @@ async def test_device_trackers(hass, mock_device_registry_devices): # test device_2 is added after connecting to wireless network WIRELESS_DATA.append(DEVICE_2_WIRELESS) - await hub.async_update() + await hub.async_refresh() await hass.async_block_till_done() device_2 = hass.states.get("device_tracker.device_2") @@ -117,7 +117,7 @@ async def test_device_trackers(hass, mock_device_registry_devices): hub.api.devices["00:00:00:00:00:02"]._last_seen = dt_util.utcnow() - timedelta( minutes=5 ) - await hub.async_update() + await hub.async_refresh() await hass.async_block_till_done() device_2 = hass.states.get("device_tracker.device_2") diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index 2116d73826f..1e056071236 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -1,18 +1,7 @@ """Test Mikrotik hub.""" from unittest.mock import patch -import librouteros - -from homeassistant import config_entries from homeassistant.components import mikrotik -from homeassistant.const import ( - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_PORT, - CONF_USERNAME, - CONF_VERIFY_SSL, -) from . import ARP_DATA, DHCP_DATA, MOCK_DATA, MOCK_OPTIONS, WIRELESS_DATA @@ -55,63 +44,6 @@ async def setup_mikrotik_entry(hass, **kwargs): return hass.data[mikrotik.DOMAIN][config_entry.entry_id] -async def test_hub_setup_successful(hass): - """Successful setup of Mikrotik hub.""" - with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", - return_value=True, - ) as forward_entry_setup: - hub = await setup_mikrotik_entry(hass) - - assert hub.config_entry.data == { - CONF_NAME: "Mikrotik", - CONF_HOST: "0.0.0.0", - CONF_USERNAME: "user", - CONF_PASSWORD: "pass", - CONF_PORT: 8278, - CONF_VERIFY_SSL: False, - } - assert hub.config_entry.options == { - mikrotik.const.CONF_FORCE_DHCP: False, - mikrotik.const.CONF_ARP_PING: False, - mikrotik.const.CONF_DETECTION_TIME: 300, - } - - assert hub.api.available is True - assert hub.signal_update == "mikrotik-update-0.0.0.0" - assert forward_entry_setup.mock_calls[0][1] == (hub.config_entry, "device_tracker") - - -async def test_hub_setup_failed(hass): - """Failed setup of Mikrotik hub.""" - - config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) - config_entry.add_to_hass(hass) - # error when connection fails - with patch( - "librouteros.connect", side_effect=librouteros.exceptions.ConnectionClosed - ): - - await hass.config_entries.async_setup(config_entry.entry_id) - - assert config_entry.state is config_entries.ConfigEntryState.SETUP_RETRY - - # error when username or password is invalid - config_entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA) - config_entry.add_to_hass(hass) - with patch( - "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup" - ) as forward_entry_setup, patch( - "librouteros.connect", - side_effect=librouteros.exceptions.TrapError("invalid user name or password"), - ): - - result = await hass.config_entries.async_setup(config_entry.entry_id) - - assert result is False - assert len(forward_entry_setup.mock_calls) == 0 - - async def test_update_failed(hass): """Test failing to connect during update.""" @@ -120,9 +52,9 @@ async def test_update_failed(hass): with patch.object( mikrotik.hub.MikrotikData, "command", side_effect=mikrotik.errors.CannotConnect ): - await hub.async_update() + await hub.async_refresh() - assert hub.api.available is False + assert not hub.last_update_success async def test_hub_not_support_wireless(hass): diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py index bc00602789c..5ac408928d8 100644 --- a/tests/components/mikrotik/test_init.py +++ b/tests/components/mikrotik/test_init.py @@ -1,7 +1,12 @@ """Test Mikrotik setup process.""" -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import patch + +from librouteros.exceptions import ConnectionClosed, LibRouterosError +import pytest from homeassistant.components import mikrotik +from homeassistant.components.mikrotik.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.setup import async_setup_component from . import MOCK_DATA @@ -9,6 +14,15 @@ from . import MOCK_DATA from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +def mock_api(): + """Mock api.""" + with patch("librouteros.create_transport"), patch( + "librouteros.Api.readResponse" + ) as mock_api: + yield mock_api + + async def test_setup_with_no_config(hass): """Test that we do not discover anything or try to set up a hub.""" assert await async_setup_component(hass, mikrotik.DOMAIN, {}) is True @@ -22,37 +36,13 @@ async def test_successful_config_entry(hass): data=MOCK_DATA, ) entry.add_to_hass(hass) - mock_registry = Mock() - with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( - "homeassistant.components.mikrotik.dr.async_get", - return_value=mock_registry, - ): - mock_hub.return_value.async_setup = AsyncMock(return_value=True) - mock_hub.return_value.serial_num = "12345678" - mock_hub.return_value.model = "RB750" - mock_hub.return_value.hostname = "mikrotik" - mock_hub.return_value.firmware = "3.65" - assert await mikrotik.async_setup_entry(hass, entry) is True - - assert len(mock_hub.mock_calls) == 2 - p_hass, p_entry = mock_hub.mock_calls[0][1] - - assert p_hass is hass - assert p_entry is entry - - assert len(mock_registry.mock_calls) == 1 - assert mock_registry.mock_calls[0][2] == { - "config_entry_id": entry.entry_id, - "connections": {("mikrotik", "12345678")}, - "manufacturer": mikrotik.ATTR_MANUFACTURER, - "model": "RB750", - "name": "mikrotik", - "sw_version": "3.65", - } + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.LOADED + assert hass.data[DOMAIN][entry.entry_id] -async def test_hub_fail_setup(hass): +async def test_hub_conn_error(hass, mock_api): """Test that a failed setup will not store the hub.""" entry = MockConfigEntry( domain=mikrotik.DOMAIN, @@ -60,14 +50,29 @@ async def test_hub_fail_setup(hass): ) entry.add_to_hass(hass) - with patch.object(mikrotik, "MikrotikHub") as mock_hub: - mock_hub.return_value.async_setup = AsyncMock(return_value=False) - assert await mikrotik.async_setup_entry(hass, entry) is False + mock_api.side_effect = ConnectionClosed - assert mikrotik.DOMAIN not in hass.data + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass): +async def test_hub_auth_error(hass, mock_api): + """Test that a failed setup will not store the hub.""" + entry = MockConfigEntry( + domain=mikrotik.DOMAIN, + data=MOCK_DATA, + ) + entry.add_to_hass(hass) + + mock_api.side_effect = LibRouterosError("invalid user name or password") + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.SETUP_ERROR + + +async def test_unload_entry(hass) -> None: """Test being able to unload an entry.""" entry = MockConfigEntry( domain=mikrotik.DOMAIN, @@ -75,18 +80,11 @@ async def test_unload_entry(hass): ) entry.add_to_hass(hass) - with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch( - "homeassistant.helpers.device_registry.async_get", - return_value=Mock(), - ): - mock_hub.return_value.async_setup = AsyncMock(return_value=True) - mock_hub.return_value.serial_num = "12345678" - mock_hub.return_value.model = "RB750" - mock_hub.return_value.hostname = "mikrotik" - mock_hub.return_value.firmware = "3.65" - assert await mikrotik.async_setup_entry(hass, entry) is True + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() - assert len(mock_hub.return_value.mock_calls) == 1 + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() - assert await mikrotik.async_unload_entry(hass, entry) - assert entry.entry_id not in hass.data[mikrotik.DOMAIN] + assert entry.state == ConfigEntryState.NOT_LOADED + assert entry.entry_id not in hass.data[DOMAIN] From 8dd5f25da988dc933a95ee176703b887def3047c Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Wed, 29 Jun 2022 15:46:32 +0200 Subject: [PATCH 864/947] Add cover tests for devolo_home_control (#72428) --- .coveragerc | 1 - tests/components/devolo_home_control/mocks.py | 26 ++++- .../devolo_home_control/test_cover.py | 103 ++++++++++++++++++ 3 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 tests/components/devolo_home_control/test_cover.py diff --git a/.coveragerc b/.coveragerc index 02c643ae757..eae8060449a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -210,7 +210,6 @@ omit = homeassistant/components/denonavr/media_player.py homeassistant/components/denonavr/receiver.py homeassistant/components/deutsche_bahn/sensor.py - homeassistant/components/devolo_home_control/cover.py homeassistant/components/devolo_home_control/light.py homeassistant/components/devolo_home_control/sensor.py homeassistant/components/devolo_home_control/switch.py diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index b43cb77ad71..e9dae0b70b1 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -51,7 +51,6 @@ class MultiLevelSwitchPropertyMock(MultiLevelSwitchProperty): self.element_uid = "Test" self.min = 4 self.max = 24 - self.switch_type = "temperature" self._value = 20 self._logger = MagicMock() @@ -120,9 +119,21 @@ class ClimateMock(DeviceMock): super().__init__() self.device_model_uid = "devolo.model.Room:Thermostat" self.multi_level_switch_property = {"Test": MultiLevelSwitchPropertyMock()} + self.multi_level_switch_property["Test"].switch_type = "temperature" self.multi_level_sensor_property = {"Test": MultiLevelSensorPropertyMock()} +class CoverMock(DeviceMock): + """devolo Home Control cover device mock.""" + + def __init__(self) -> None: + """Initialize the mock.""" + super().__init__() + self.multi_level_switch_property = { + "devolo.Blinds": MultiLevelSwitchPropertyMock() + } + + class RemoteControlMock(DeviceMock): """devolo Home Control remote control device mock.""" @@ -195,6 +206,19 @@ class HomeControlMockClimate(HomeControlMock): self.publisher.unregister = MagicMock() +class HomeControlMockCover(HomeControlMock): + """devolo Home Control gateway mock with cover devices.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the mock.""" + super().__init__() + self.devices = { + "Test": CoverMock(), + } + self.publisher = Publisher(self.devices.keys()) + self.publisher.unregister = MagicMock() + + class HomeControlMockRemoteControl(HomeControlMock): """devolo Home Control gateway mock with remote control device.""" diff --git a/tests/components/devolo_home_control/test_cover.py b/tests/components/devolo_home_control/test_cover.py new file mode 100644 index 00000000000..1c05c00370b --- /dev/null +++ b/tests/components/devolo_home_control/test_cover.py @@ -0,0 +1,103 @@ +"""Tests for the devolo Home Control cover platform.""" +from unittest.mock import patch + +from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + STATE_CLOSED, + STATE_OPEN, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockCover + + +async def test_cover(hass: HomeAssistant): + """Test setup and state change of a cover device.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockCover() + test_gateway.devices["Test"].multi_level_switch_property["devolo.Blinds"].value = 20 + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + assert state.state == STATE_OPEN + assert ( + state.attributes[ATTR_CURRENT_POSITION] + == test_gateway.devices["Test"] + .multi_level_switch_property["devolo.Blinds"] + .value + ) + + # Emulate websocket message: position changed + test_gateway.publisher.dispatch("Test", ("devolo.Blinds", 0.0)) + await hass.async_block_till_done() + state = hass.states.get(f"{DOMAIN}.test") + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_CURRENT_POSITION] == 0.0 + + # Test setting position + with patch( + "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" + ) as set_value: + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(100) + + set_value.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: f"{DOMAIN}.test"}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(0) + + set_value.reset_mock() + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: f"{DOMAIN}.test", ATTR_POSITION: 50}, + blocking=True, + ) # In reality, this leads to a websocket message like already tested above + set_value.assert_called_once_with(50) + + # Emulate websocket message: device went offline + test_gateway.devices["Test"].status = 1 + test_gateway.publisher.dispatch("Test", ("Status", False, "status")) + await hass.async_block_till_done() + assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE + + +async def test_remove_from_hass(hass: HomeAssistant): + """Test removing entity.""" + entry = configure_integration(hass) + test_gateway = HomeControlMockCover() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[test_gateway, HomeControlMock()], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(f"{DOMAIN}.test") + assert state is not None + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 0 + assert test_gateway.publisher.unregister.call_count == 1 From f5d8487768cebe137a22c8db0cbe5664c895822e Mon Sep 17 00:00:00 2001 From: Anders Liljekvist Date: Wed, 29 Jun 2022 16:08:58 +0200 Subject: [PATCH 865/947] Add send_poll to telegram bot (#68666) --- .../components/telegram_bot/__init__.py | 53 ++++++++++++++++++ .../components/telegram_bot/services.yaml | 54 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index 29dbabcbbfe..be1c8325c5f 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -80,6 +80,12 @@ ATTR_VERIFY_SSL = "verify_ssl" ATTR_TIMEOUT = "timeout" ATTR_MESSAGE_TAG = "message_tag" ATTR_CHANNEL_POST = "channel_post" +ATTR_QUESTION = "question" +ATTR_OPTIONS = "options" +ATTR_ANSWERS = "answers" +ATTR_OPEN_PERIOD = "open_period" +ATTR_IS_ANONYMOUS = "is_anonymous" +ATTR_ALLOWS_MULTIPLE_ANSWERS = "allows_multiple_answers" CONF_ALLOWED_CHAT_IDS = "allowed_chat_ids" CONF_PROXY_URL = "proxy_url" @@ -96,6 +102,7 @@ SERVICE_SEND_VIDEO = "send_video" SERVICE_SEND_VOICE = "send_voice" SERVICE_SEND_DOCUMENT = "send_document" SERVICE_SEND_LOCATION = "send_location" +SERVICE_SEND_POLL = "send_poll" SERVICE_EDIT_MESSAGE = "edit_message" SERVICE_EDIT_CAPTION = "edit_caption" SERVICE_EDIT_REPLYMARKUP = "edit_replymarkup" @@ -184,6 +191,19 @@ SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend( } ) +SERVICE_SCHEMA_SEND_POLL = vol.Schema( + { + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), + vol.Required(ATTR_QUESTION): cv.string, + vol.Required(ATTR_OPTIONS): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(ATTR_OPEN_PERIOD): cv.positive_int, + vol.Optional(ATTR_IS_ANONYMOUS, default=True): cv.boolean, + vol.Optional(ATTR_ALLOWS_MULTIPLE_ANSWERS, default=False): cv.boolean, + vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, + vol.Optional(ATTR_TIMEOUT): cv.positive_int, + } +) + SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend( { vol.Required(ATTR_MESSAGEID): vol.Any( @@ -246,6 +266,7 @@ SERVICE_MAP = { SERVICE_SEND_VOICE: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE, SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION, + SERVICE_SEND_POLL: SERVICE_SCHEMA_SEND_POLL, SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE, SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION, SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP, @@ -399,6 +420,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await hass.async_add_executor_job( partial(notify_service.send_location, **kwargs) ) + elif msgtype == SERVICE_SEND_POLL: + await hass.async_add_executor_job( + partial(notify_service.send_poll, **kwargs) + ) elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY: await hass.async_add_executor_job( partial(notify_service.answer_callback_query, **kwargs) @@ -847,6 +872,34 @@ class TelegramNotificationService: timeout=params[ATTR_TIMEOUT], ) + def send_poll( + self, + question, + options, + is_anonymous, + allows_multiple_answers, + target=None, + **kwargs, + ): + """Send a poll.""" + params = self._get_msg_kwargs(kwargs) + openperiod = kwargs.get(ATTR_OPEN_PERIOD) + for chat_id in self._get_target_chat_ids(target): + _LOGGER.debug("Send poll '%s' to chat ID %s", question, chat_id) + self._send_msg( + self.bot.send_poll, + "Error sending poll", + params[ATTR_MESSAGE_TAG], + chat_id=chat_id, + question=question, + options=options, + is_anonymous=is_anonymous, + allows_multiple_answers=allows_multiple_answers, + open_period=openperiod, + disable_notification=params[ATTR_DISABLE_NOTIF], + timeout=params[ATTR_TIMEOUT], + ) + def leave_chat(self, chat_id=None): """Remove bot from chat.""" chat_id = self._get_target_chat_ids(chat_id)[0] diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml index 6afd42dffb8..31876bd542d 100644 --- a/homeassistant/components/telegram_bot/services.yaml +++ b/homeassistant/components/telegram_bot/services.yaml @@ -678,6 +678,60 @@ send_location: selector: text: +send_poll: + name: Send poll + description: Send a poll. + fields: + target: + name: Target + description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default. + example: "[12345, 67890] or 12345" + selector: + object: + question: + name: Question + description: Poll question, 1-300 characters + required: true + selector: + text: + options: + name: Options + description: List of answer options, 2-10 strings 1-100 characters each + required: true + selector: + object: + is_anonymous: + name: Is Anonymous + description: If the poll needs to be anonymous, defaults to True + selector: + boolean: + allows_multiple_answers: + name: Allow Multiple Answers + description: If the poll allows multiple answers, defaults to False + selector: + boolean: + open_period: + name: Open Period + description: Amount of time in seconds the poll will be active after creation, 5-600. + selector: + number: + min: 5 + max: 600 + unit_of_measurement: seconds + disable_notification: + name: Disable notification + description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound. + selector: + boolean: + timeout: + name: Timeout + description: Timeout for send poll. Will help with timeout errors (poor internet connection, etc) + selector: + number: + min: 1 + max: 3600 + unit_of_measurement: seconds + edit_message: name: Edit message description: Edit a previously sent message. From e6d115e765f13c0ac33f6100cdf5dcd11f6e6290 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 29 Jun 2022 08:27:34 -0600 Subject: [PATCH 866/947] Add time remaining sensors for RainMachine programs (#73878) --- .../components/rainmachine/sensor.py | 171 +++++++++++++----- 1 file changed, 129 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 1144ceea159..7550756f8c4 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -3,10 +3,12 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timedelta +from typing import Any, cast from regenmaschine.controller import Controller from homeassistant.components.sensor import ( + RestoreSensor, SensorDeviceClass, SensorEntity, SensorEntityDescription, @@ -24,6 +26,7 @@ from . import RainMachineEntity from .const import ( DATA_CONTROLLER, DATA_COORDINATOR, + DATA_PROGRAMS, DATA_PROVISION_SETTINGS, DATA_RESTRICTIONS_UNIVERSAL, DATA_ZONES, @@ -44,6 +47,7 @@ TYPE_FLOW_SENSOR_CONSUMED_LITERS = "flow_sensor_consumed_liters" TYPE_FLOW_SENSOR_START_INDEX = "flow_sensor_start_index" TYPE_FLOW_SENSOR_WATERING_CLICKS = "flow_sensor_watering_clicks" TYPE_FREEZE_TEMP = "freeze_protect_temp" +TYPE_PROGRAM_RUN_COMPLETION_TIME = "program_run_completion_time" TYPE_ZONE_RUN_COMPLETION_TIME = "zone_run_completion_time" @@ -143,7 +147,26 @@ async def async_setup_entry( ) ] + program_coordinator = coordinators[DATA_PROGRAMS] zone_coordinator = coordinators[DATA_ZONES] + + for uid, program in program_coordinator.data.items(): + sensors.append( + ProgramTimeRemainingSensor( + entry, + program_coordinator, + zone_coordinator, + controller, + RainMachineSensorDescriptionUid( + key=f"{TYPE_PROGRAM_RUN_COMPLETION_TIME}_{uid}", + name=f"{program['name']} Run Completion Time", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + uid=uid, + ), + ) + ) + for uid, zone in zone_coordinator.data.items(): sensors.append( ZoneTimeRemainingSensor( @@ -163,6 +186,106 @@ async def async_setup_entry( async_add_entities(sensors) +class TimeRemainingSensor(RainMachineEntity, RestoreSensor): + """Define a sensor that shows the amount of time remaining for an activity.""" + + entity_description: RainMachineSensorDescriptionUid + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator, + controller: Controller, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, coordinator, controller, description) + + self._current_run_state: RunStates | None = None + self._previous_run_state: RunStates | None = None + + @property + def activity_data(self) -> dict[str, Any]: + """Return the core data for this entity.""" + return cast(dict[str, Any], self.coordinator.data[self.entity_description.uid]) + + @property + def status_key(self) -> str: + """Return the data key that contains the activity status.""" + return "state" + + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + if restored_data := await self.async_get_last_sensor_data(): + self._attr_native_value = restored_data.native_value + await super().async_added_to_hass() + + def calculate_seconds_remaining(self) -> int: + """Calculate the number of seconds remaining.""" + raise NotImplementedError + + @callback + def update_from_latest_data(self) -> None: + """Update the state.""" + self._previous_run_state = self._current_run_state + self._current_run_state = RUN_STATE_MAP.get(self.activity_data[self.status_key]) + + now = utcnow() + + if ( + self._current_run_state == RunStates.NOT_RUNNING + and self._previous_run_state in (RunStates.QUEUED, RunStates.RUNNING) + ): + # If the activity goes from queued/running to not running, update the + # state to be right now (i.e., the time the zone stopped running): + self._attr_native_value = now + elif self._current_run_state == RunStates.RUNNING: + seconds_remaining = self.calculate_seconds_remaining() + new_timestamp = now + timedelta(seconds=seconds_remaining) + + assert isinstance(self._attr_native_value, datetime) + + if ( + self._attr_native_value + and new_timestamp - self._attr_native_value + < DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE + ): + # If the deviation between the previous and new timestamps is less + # than a "wobble tolerance," don't spam the state machine: + return + + self._attr_native_value = new_timestamp + + +class ProgramTimeRemainingSensor(TimeRemainingSensor): + """Define a sensor that shows the amount of time remaining for a program.""" + + def __init__( + self, + entry: ConfigEntry, + program_coordinator: DataUpdateCoordinator, + zone_coordinator: DataUpdateCoordinator, + controller: Controller, + description: EntityDescription, + ) -> None: + """Initialize.""" + super().__init__(entry, program_coordinator, controller, description) + + self._zone_coordinator = zone_coordinator + + @property + def status_key(self) -> str: + """Return the data key that contains the activity status.""" + return "status" + + def calculate_seconds_remaining(self) -> int: + """Calculate the number of seconds remaining.""" + return sum( + self._zone_coordinator.data[zone["id"]]["remaining"] + for zone in [z for z in self.activity_data["wateringTimes"] if z["active"]] + ) + + class ProvisionSettingsSensor(RainMachineEntity, SensorEntity): """Define a sensor that handles provisioning data.""" @@ -203,47 +326,11 @@ class UniversalRestrictionsSensor(RainMachineEntity, SensorEntity): self._attr_native_value = self.coordinator.data.get("freezeProtectTemp") -class ZoneTimeRemainingSensor(RainMachineEntity, SensorEntity): +class ZoneTimeRemainingSensor(TimeRemainingSensor): """Define a sensor that shows the amount of time remaining for a zone.""" - entity_description: RainMachineSensorDescriptionUid - - def __init__( - self, - entry: ConfigEntry, - coordinator: DataUpdateCoordinator, - controller: Controller, - description: EntityDescription, - ) -> None: - """Initialize.""" - super().__init__(entry, coordinator, controller, description) - - self._running_or_queued: bool = False - - @callback - def update_from_latest_data(self) -> None: - """Update the state.""" - data = self.coordinator.data[self.entity_description.uid] - now = utcnow() - - if RUN_STATE_MAP.get(data["state"]) == RunStates.NOT_RUNNING: - if self._running_or_queued: - # If we go from running to not running, update the state to be right - # now (i.e., the time the zone stopped running): - self._attr_native_value = now - self._running_or_queued = False - return - - self._running_or_queued = True - new_timestamp = now + timedelta(seconds=data["remaining"]) - - if self._attr_native_value: - assert isinstance(self._attr_native_value, datetime) - if ( - new_timestamp - self._attr_native_value - ) < DEFAULT_ZONE_COMPLETION_TIME_WOBBLE_TOLERANCE: - # If the deviation between the previous and new timestamps is less than - # a "wobble tolerance," don't spam the state machine: - return - - self._attr_native_value = new_timestamp + def calculate_seconds_remaining(self) -> int: + """Calculate the number of seconds remaining.""" + return cast( + int, self.coordinator.data[self.entity_description.uid]["remaining"] + ) From d3f4108a91bfb8c7607e145ab48cfd489143d972 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 16:34:41 +0200 Subject: [PATCH 867/947] Support knots and ft/s in weather wind speed (#74175) --- homeassistant/components/weather/__init__.py | 6 +++++- homeassistant/const.py | 2 ++ homeassistant/util/speed.py | 8 ++++++++ tests/util/test_speed.py | 6 ++++++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 6f2de6a3cb0..1fdb9173646 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -22,7 +22,9 @@ from homeassistant.const import ( PRESSURE_INHG, PRESSURE_MBAR, PRESSURE_MMHG, + SPEED_FEET_PER_SECOND, SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, @@ -118,8 +120,10 @@ VALID_UNITS_VISIBILITY: tuple[str, ...] = ( LENGTH_MILES, ) VALID_UNITS_WIND_SPEED: tuple[str, ...] = ( - SPEED_METERS_PER_SECOND, + SPEED_FEET_PER_SECOND, SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, + SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, ) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7b91973a930..698f6bee240 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -608,10 +608,12 @@ CONCENTRATION_PARTS_PER_BILLION: Final = "ppb" # Speed units SPEED_MILLIMETERS_PER_DAY: Final = "mm/d" +SPEED_FEET_PER_SECOND: Final = "ft/s" SPEED_INCHES_PER_DAY: Final = "in/d" SPEED_METERS_PER_SECOND: Final = "m/s" SPEED_INCHES_PER_HOUR: Final = "in/h" SPEED_KILOMETERS_PER_HOUR: Final = "km/h" +SPEED_KNOTS: Final = "kn" SPEED_MILES_PER_HOUR: Final = "mph" # Signal_strength units diff --git a/homeassistant/util/speed.py b/homeassistant/util/speed.py index 14b28bde676..12618e020f8 100644 --- a/homeassistant/util/speed.py +++ b/homeassistant/util/speed.py @@ -5,9 +5,11 @@ from numbers import Number from homeassistant.const import ( SPEED, + SPEED_FEET_PER_SECOND, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_MILLIMETERS_PER_DAY, @@ -15,24 +17,30 @@ from homeassistant.const import ( ) VALID_UNITS: tuple[str, ...] = ( + SPEED_FEET_PER_SECOND, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_MILLIMETERS_PER_DAY, ) +FOOT_TO_M = 0.3048 HRS_TO_SECS = 60 * 60 # 1 hr = 3600 seconds IN_TO_M = 0.0254 KM_TO_M = 1000 # 1 km = 1000 m MILE_TO_M = 1609.344 +NAUTICAL_MILE_TO_M = 1852 # 1 nautical mile = 1852 m # Units in terms of m/s UNIT_CONVERSION: dict[str, float] = { + SPEED_FEET_PER_SECOND: 1 / FOOT_TO_M, SPEED_INCHES_PER_DAY: (24 * HRS_TO_SECS) / IN_TO_M, SPEED_INCHES_PER_HOUR: HRS_TO_SECS / IN_TO_M, SPEED_KILOMETERS_PER_HOUR: HRS_TO_SECS / KM_TO_M, + SPEED_KNOTS: HRS_TO_SECS / NAUTICAL_MILE_TO_M, SPEED_METERS_PER_SECOND: 1, SPEED_MILES_PER_HOUR: HRS_TO_SECS / MILE_TO_M, SPEED_MILLIMETERS_PER_DAY: (24 * HRS_TO_SECS) * 1000, diff --git a/tests/util/test_speed.py b/tests/util/test_speed.py index f0a17e6ae15..9c7fd070313 100644 --- a/tests/util/test_speed.py +++ b/tests/util/test_speed.py @@ -2,9 +2,11 @@ import pytest from homeassistant.const import ( + SPEED_FEET_PER_SECOND, SPEED_INCHES_PER_DAY, SPEED_INCHES_PER_HOUR, SPEED_KILOMETERS_PER_HOUR, + SPEED_KNOTS, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, SPEED_MILLIMETERS_PER_DAY, @@ -61,6 +63,10 @@ def test_convert_nonnumeric_value(): (5, SPEED_METERS_PER_SECOND, 708661, SPEED_INCHES_PER_HOUR), # 5000 in/h / 39.3701 in/m / 3600 s/h = 0.03528 m/s (5000, SPEED_INCHES_PER_HOUR, 0.03528, SPEED_METERS_PER_SECOND), + # 5 kt * 1852 m/nmi / 3600 s/h = 2.5722 m/s + (5, SPEED_KNOTS, 2.5722, SPEED_METERS_PER_SECOND), + # 5 ft/s * 0.3048 m/ft = 1.524 m/s + (5, SPEED_FEET_PER_SECOND, 1.524, SPEED_METERS_PER_SECOND), ], ) def test_convert_different_units(from_value, from_unit, expected, to_unit): From 4e079c4417611f6a5cc0ce849556afe321972de1 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 29 Jun 2022 16:50:24 +0200 Subject: [PATCH 868/947] Fix typo in recorder (#74178) --- homeassistant/components/recorder/websocket_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index d0499fbf9cb..45e2cf5620b 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -1,4 +1,4 @@ -"""The Energy websocket API.""" +"""The Recorder websocket API.""" from __future__ import annotations import logging From 97dcfe4445ab049d2db3824f54a24062c657951c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 29 Jun 2022 17:13:07 +0200 Subject: [PATCH 869/947] Smhi reverse change of unique id change (#74176) --- homeassistant/components/smhi/__init__.py | 16 ++--------- homeassistant/components/smhi/config_flow.py | 2 +- homeassistant/components/smhi/weather.py | 2 +- tests/components/smhi/test_config_flow.py | 20 ++++++------- tests/components/smhi/test_init.py | 30 ++++++++++++++++++-- 5 files changed, 39 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/smhi/__init__.py b/homeassistant/components/smhi/__init__.py index e3f55904b77..98c8de87032 100644 --- a/homeassistant/components/smhi/__init__.py +++ b/homeassistant/components/smhi/__init__.py @@ -7,8 +7,7 @@ from homeassistant.const import ( CONF_NAME, Platform, ) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.entity_registry import RegistryEntry, async_migrate_entries +from homeassistant.core import HomeAssistant PLATFORMS = [Platform.WEATHER] @@ -40,21 +39,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: CONF_LONGITUDE: entry.data[CONF_LONGITUDE], }, } - new_unique_id = f"smhi-{entry.data[CONF_LATITUDE]}-{entry.data[CONF_LONGITUDE]}" - if not hass.config_entries.async_update_entry( - entry, data=new_data, unique_id=new_unique_id - ): + if not hass.config_entries.async_update_entry(entry, data=new_data): return False entry.version = 2 - new_unique_id_entity = f"smhi-{entry.data[CONF_LOCATION][CONF_LATITUDE]}-{entry.data[CONF_LOCATION][CONF_LONGITUDE]}" - - @callback - def update_unique_id(entity_entry: RegistryEntry) -> dict[str, str]: - """Update unique ID of entity entry.""" - return {"new_unique_id": new_unique_id_entity} - - await async_migrate_entries(hass, entry.entry_id, update_unique_id) return True diff --git a/homeassistant/components/smhi/config_flow.py b/homeassistant/components/smhi/config_flow.py index 8d338e3eb3e..bfa38f317a9 100644 --- a/homeassistant/components/smhi/config_flow.py +++ b/homeassistant/components/smhi/config_flow.py @@ -57,7 +57,7 @@ class SmhiFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): HOME_LOCATION_NAME if name == HOME_LOCATION_NAME else DEFAULT_NAME ) - await self.async_set_unique_id(f"smhi-{lat}-{lon}") + await self.async_set_unique_id(f"{lat}-{lon}") self._abort_if_unique_id_configured() return self.async_create_entry(title=name, data=user_input) diff --git a/homeassistant/components/smhi/weather.py b/homeassistant/components/smhi/weather.py index 5d1f3e3c87e..d7df54957c0 100644 --- a/homeassistant/components/smhi/weather.py +++ b/homeassistant/components/smhi/weather.py @@ -136,7 +136,7 @@ class SmhiWeather(WeatherEntity): """Initialize the SMHI weather entity.""" self._attr_name = name - self._attr_unique_id = f"smhi-{latitude}-{longitude}" + self._attr_unique_id = f"{latitude}, {longitude}" self._forecasts: list[SmhiForecast] | None = None self._fail_count = 0 self._smhi_api = Smhi(longitude, latitude, session=session) diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py index ab3e36f81de..f33849694c8 100644 --- a/tests/components/smhi/test_config_flow.py +++ b/tests/components/smhi/test_config_flow.py @@ -9,11 +9,7 @@ from homeassistant import config_entries from homeassistant.components.smhi.const import DOMAIN from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import ( - RESULT_TYPE_ABORT, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_FORM, -) +from homeassistant.data_entry_flow import FlowResultType from tests.common import MockConfigEntry @@ -27,7 +23,7 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == RESULT_TYPE_FORM + assert result["type"] == FlowResultType.FORM assert result["errors"] == {} with patch( @@ -48,7 +44,7 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY + assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == "Home" assert result2["data"] == { "location": { @@ -81,7 +77,7 @@ async def test_form(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result4["type"] == RESULT_TYPE_CREATE_ENTRY + assert result4["type"] == FlowResultType.CREATE_ENTRY assert result4["title"] == "Weather 1.0 1.0" assert result4["data"] == { "location": { @@ -113,7 +109,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_FORM + assert result2["type"] == FlowResultType.FORM assert result2["errors"] == {"base": "wrong_location"} # Continue flow with new coordinates @@ -135,7 +131,7 @@ async def test_form_invalid_coordinates(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result3["type"] == RESULT_TYPE_CREATE_ENTRY + assert result3["type"] == FlowResultType.CREATE_ENTRY assert result3["title"] == "Weather 2.0 2.0" assert result3["data"] == { "location": { @@ -150,7 +146,7 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: """Test we handle unique id already exist.""" entry = MockConfigEntry( domain=DOMAIN, - unique_id="smhi-1.0-1.0", + unique_id="1.0-1.0", data={ "location": { "latitude": 1.0, @@ -179,5 +175,5 @@ async def test_form_unique_id_exist(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - assert result2["type"] == RESULT_TYPE_ABORT + assert result2["type"] == FlowResultType.ABORT assert result2["reason"] == "already_configured" diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py index ea6d55fabf6..ec6e4c417bb 100644 --- a/tests/components/smhi/test_init.py +++ b/tests/components/smhi/test_init.py @@ -1,4 +1,6 @@ """Test SMHI component setup process.""" +from unittest.mock import patch + from smhi.smhi_lib import APIURL_TEMPLATE from homeassistant.components.smhi.const import DOMAIN @@ -56,7 +58,7 @@ async def test_remove_entry( async def test_migrate_entry( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str ) -> None: - """Test migrate entry and entities unique id.""" + """Test migrate entry data.""" uri = APIURL_TEMPLATE.format( TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] ) @@ -82,7 +84,29 @@ async def test_migrate_entry( assert state assert entry.version == 2 - assert entry.unique_id == "smhi-17.84197-17.84197" + assert entry.unique_id == "17.84197-17.84197" entity_get = entity_reg.async_get(entity.entity_id) - assert entity_get.unique_id == "smhi-17.84197-17.84197" + assert entity_get.unique_id == "17.84197, 17.84197" + + +async def test_migrate_entry_failed( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, api_response: str +) -> None: + """Test migrate entry data that fails.""" + uri = APIURL_TEMPLATE.format( + TEST_CONFIG_MIGRATE["longitude"], TEST_CONFIG_MIGRATE["latitude"] + ) + aioclient_mock.get(uri, text=api_response) + entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG_MIGRATE) + entry.add_to_hass(hass) + assert entry.version == 1 + + with patch( + "homeassistant.config_entries.ConfigEntries.async_update_entry", + return_value=False, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.version == 1 From 73bff4dee5cd80e69e12d9ae38769e3d8cdbd759 Mon Sep 17 00:00:00 2001 From: Daniel Baulig Date: Wed, 29 Jun 2022 08:30:55 -0700 Subject: [PATCH 870/947] Expose Envisalink's zone number as an attribute (#71468) Co-authored-by: J. Nick Koston --- homeassistant/components/envisalink/binary_sensor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py index 3fd7daeea86..d82f90aa4f4 100644 --- a/homeassistant/components/envisalink/binary_sensor.py +++ b/homeassistant/components/envisalink/binary_sensor.py @@ -93,6 +93,12 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorEntity): last_trip_time = None attr[ATTR_LAST_TRIP_TIME] = last_trip_time + + # Expose the zone number as an attribute to allow + # for easier entity to zone mapping (e.g. to bypass + # the zone). + attr["zone"] = self._zone_number + return attr @property From fa678d04085f443934c388ae2c6b43da3e5461d4 Mon Sep 17 00:00:00 2001 From: Arne Mauer Date: Wed, 29 Jun 2022 17:44:40 +0200 Subject: [PATCH 871/947] New sensors and manufacturer cluster to support IKEA STARKVIND (with Quirk) (#73450) * Add Particulate Matter 2.5 of ZCL concentration clusters to ZHA component * Fixed black and flake8 test * New sensors and manufacturer cluster to support IKEA STARKVIND (with quirk) * Isort and codespell fixes * Instead using the fan cluster, i've created a Ikea air purifier cluster/channel that supports all sensors and fan modes * update sensors to support the new ikea_airpurifier channel * Fix black, flake8, isort * Mylint/mypy fixes + Use a TypedDict for REPORT_CONFIG in zha #73629 * Last fix for test_fan.py * fix fan test Co-authored-by: David F. Mulcahey --- homeassistant/components/zha/binary_sensor.py | 8 + .../zha/core/channels/manufacturerspecific.py | 58 +++++- .../components/zha/core/registries.py | 1 + homeassistant/components/zha/fan.py | 99 ++++++++++ homeassistant/components/zha/number.py | 18 ++ homeassistant/components/zha/sensor.py | 32 +++ homeassistant/components/zha/switch.py | 18 ++ tests/components/zha/test_fan.py | 186 +++++++++++++++++- 8 files changed, 416 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 8130e7d5f98..709515d7ca2 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -186,3 +186,11 @@ class FrostLock(BinarySensor, id_suffix="frost_lock"): SENSOR_ATTR = "frost_lock" _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.LOCK + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class ReplaceFilter(BinarySensor, id_suffix="replace_filter"): + """ZHA BinarySensor.""" + + SENSOR_ATTR = "replace_filter" + _attr_device_class: BinarySensorDeviceClass = BinarySensorDeviceClass.PROBLEM diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 101db65a66e..943d13a57d6 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -2,8 +2,9 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any +from zigpy.exceptions import ZigbeeException import zigpy.zcl from homeassistant.core import callback @@ -14,6 +15,8 @@ from ..const import ( ATTR_ATTRIBUTE_NAME, ATTR_VALUE, REPORT_CONFIG_ASAP, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, SIGNAL_ATTR_UPDATED, @@ -129,3 +132,56 @@ class InovelliCluster(ClientChannel): """Inovelli Button Press Event channel.""" REPORT_CONFIG = () + + +@registries.CHANNEL_ONLY_CLUSTERS.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.IKEA_AIR_PURIFIER_CLUSTER) +class IkeaAirPurifierChannel(ZigbeeChannel): + """IKEA Air Purifier channel.""" + + REPORT_CONFIG = ( + AttrReportConfig(attr="filter_run_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="replace_filter", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="filter_life_time", config=REPORT_CONFIG_DEFAULT), + AttrReportConfig(attr="disable_led", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="air_quality_25pm", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="child_lock", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_mode", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="fan_speed", config=REPORT_CONFIG_IMMEDIATE), + AttrReportConfig(attr="device_run_time", config=REPORT_CONFIG_DEFAULT), + ) + + @property + def fan_mode(self) -> int | None: + """Return current fan mode.""" + return self.cluster.get("fan_mode") + + @property + def fan_mode_sequence(self) -> int | None: + """Return possible fan mode speeds.""" + return self.cluster.get("fan_mode_sequence") + + async def async_set_speed(self, value) -> None: + """Set the speed of the fan.""" + + try: + await self.cluster.write_attributes({"fan_mode": value}) + except ZigbeeException as ex: + self.error("Could not set speed: %s", ex) + return + + async def async_update(self) -> None: + """Retrieve latest state.""" + await self.get_attribute_value("fan_mode", from_cache=False) + + @callback + def attribute_updated(self, attrid: int, value: Any) -> None: + """Handle attribute update from fan cluster.""" + attr_name = self._get_attribute_name(attrid) + self.debug( + "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value + ) + if attr_name == "fan_mode": + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index d271f2ecba3..2480cf1cd43 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -28,6 +28,7 @@ _ZhaGroupEntityT = TypeVar("_ZhaGroupEntityT", bound=type["ZhaGroupEntity"]) GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN] +IKEA_AIR_PURIFIER_CLUSTER = 0xFC7D PHILLIPS_REMOTE_CLUSTER = 0xFC00 SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 8e24427b679..d947fca10ab 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -51,6 +51,7 @@ DEFAULT_ON_PERCENTAGE = 50 STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, Platform.FAN) GROUP_MATCH = functools.partial(ZHA_ENTITIES.group_match, Platform.FAN) +MULTI_MATCH = functools.partial(ZHA_ENTITIES.multipass_match, Platform.FAN) async def async_setup_entry( @@ -228,3 +229,101 @@ class FanGroup(BaseFan, ZhaGroupEntity): """Run when about to be added to hass.""" await self.async_update() await super().async_added_to_hass() + + +IKEA_SPEED_RANGE = (1, 10) # off is not included +IKEA_PRESET_MODES_TO_NAME = { + 1: PRESET_MODE_AUTO, + 2: "Speed 1", + 3: "Speed 1.5", + 4: "Speed 2", + 5: "Speed 2.5", + 6: "Speed 3", + 7: "Speed 3.5", + 8: "Speed 4", + 9: "Speed 4.5", + 10: "Speed 5", +} +IKEA_NAME_TO_PRESET_MODE = {v: k for k, v in IKEA_PRESET_MODES_TO_NAME.items()} +IKEA_PRESET_MODES = list(IKEA_NAME_TO_PRESET_MODE) + + +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) +class IkeaFan(BaseFan, ZhaEntity): + """Representation of a ZHA fan.""" + + def __init__(self, unique_id, zha_device, channels, **kwargs): + """Init this sensor.""" + super().__init__(unique_id, zha_device, channels, **kwargs) + self._fan_channel = self.cluster_channels.get("ikea_airpurifier") + + async def async_added_to_hass(self): + """Run when about to be added to hass.""" + await super().async_added_to_hass() + self.async_accept_signal( + self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state + ) + + @property + def preset_modes(self) -> list[str]: + """Return the available preset modes.""" + return IKEA_PRESET_MODES + + @property + def speed_count(self) -> int: + """Return the number of speeds the fan supports.""" + return int_states_in_range(IKEA_SPEED_RANGE) + + async def async_set_percentage(self, percentage: int | None) -> None: + """Set the speed percenage of the fan.""" + if percentage is None: + percentage = 0 + fan_mode = math.ceil(percentage_to_ranged_value(IKEA_SPEED_RANGE, percentage)) + await self._async_set_fan_mode(fan_mode) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode for the fan.""" + if preset_mode not in self.preset_modes: + raise NotValidPresetModeError( + f"The preset_mode {preset_mode} is not a valid preset_mode: {self.preset_modes}" + ) + await self._async_set_fan_mode(IKEA_NAME_TO_PRESET_MODE[preset_mode]) + + @property + def percentage(self) -> int | None: + """Return the current speed percentage.""" + if ( + self._fan_channel.fan_mode is None + or self._fan_channel.fan_mode > IKEA_SPEED_RANGE[1] + ): + return None + if self._fan_channel.fan_mode == 0: + return 0 + return ranged_value_to_percentage(IKEA_SPEED_RANGE, self._fan_channel.fan_mode) + + @property + def preset_mode(self) -> str | None: + """Return the current preset mode.""" + return IKEA_PRESET_MODES_TO_NAME.get(self._fan_channel.fan_mode) + + async def async_turn_on(self, percentage=None, preset_mode=None, **kwargs) -> None: + """Turn the entity on.""" + if percentage is None: + percentage = (100 / self.speed_count) * IKEA_NAME_TO_PRESET_MODE[ + PRESET_MODE_AUTO + ] + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + await self.async_set_percentage(0) + + @callback + def async_set_state(self, attr_id, attr_name, value): + """Handle state update from channel.""" + self.async_write_ha_state() + + async def _async_set_fan_mode(self, fan_mode: int) -> None: + """Set the fan mode for the fan.""" + await self._fan_channel.async_set_speed(fan_mode) + self.async_set_state(0, "fan_mode", fan_mode) diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 9103dd2e364..c3d7f352318 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -523,3 +523,21 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati _attr_native_max_value: float = 0x257 _attr_unit_of_measurement: str | None = UNITS[72] _zcl_attribute: str = "timer_duration" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_manufacturer", + manufacturers={ + "IKEA of Sweden", + }, + models={"STARKVIND Air purifier"}, +) +class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): + """Representation of a ZHA timer duration configuration entity.""" + + _attr_entity_category = EntityCategory.CONFIG + _attr_icon: str = ICONS[14] + _attr_native_min_value: float = 0x00 + _attr_native_max_value: float = 0xFFFFFFFF + _attr_unit_of_measurement: str | None = UNITS[72] + _zcl_attribute: str = "filter_life_time" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index e66f1569b81..2fe38193ecb 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -808,3 +808,35 @@ class TimeLeft(Sensor, id_suffix="time_left"): _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION _attr_icon = "mdi:timer" _unit = TIME_MINUTES + + +@MULTI_MATCH( + channel_names="ikea_manufacturer", + manufacturers={ + "IKEA of Sweden", + }, + models={"STARKVIND Air purifier"}, +) +class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): + """Sensor that displays device run time (in minutes).""" + + SENSOR_ATTR = "device_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _unit = TIME_MINUTES + + +@MULTI_MATCH( + channel_names="ikea_manufacturer", + manufacturers={ + "IKEA of Sweden", + }, + models={"STARKVIND Air purifier"}, +) +class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): + """Sensor that displays run time of the current filter (in minutes).""" + + SENSOR_ATTR = "filter_run_time" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.DURATION + _attr_icon = "mdi:timer" + _unit = TIME_MINUTES diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index fe7526586f9..3b044bb7646 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -285,3 +285,21 @@ class P1MotionTriggerIndicatorSwitch( """Representation of a ZHA motion triggering configuration entity.""" _zcl_attribute: str = "trigger_indicator" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} +) +class ChildLock(ZHASwitchConfigurationEntity, id_suffix="child_lock"): + """ZHA BinarySensor.""" + + _zcl_attribute: str = "child_lock" + + +@CONFIG_DIAGNOSTIC_MATCH( + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} +) +class DisableLed(ZHASwitchConfigurationEntity, id_suffix="disable_led"): + """ZHA BinarySensor.""" + + _zcl_attribute: str = "disable_led" diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 423634db035..9ebc5ae1c79 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -2,10 +2,10 @@ from unittest.mock import AsyncMock, call, patch import pytest +import zhaquirks.ikea.starkvind from zigpy.exceptions import ZigbeeException -import zigpy.profiles.zha as zha -import zigpy.zcl.clusters.general as general -import zigpy.zcl.clusters.hvac as hvac +from zigpy.profiles import zha +from zigpy.zcl.clusters import general, hvac import zigpy.zcl.foundation as zcl_f from homeassistant.components.fan import ( @@ -57,11 +57,15 @@ def fan_platform_only(): with patch( "homeassistant.components.zha.PLATFORMS", ( + Platform.BUTTON, + Platform.BINARY_SENSOR, Platform.FAN, Platform.LIGHT, Platform.DEVICE_TRACKER, Platform.NUMBER, + Platform.SENSOR, Platform.SELECT, + Platform.SWITCH, ), ): yield @@ -516,3 +520,179 @@ async def test_fan_update_entity( assert cluster.read_attributes.await_count == 4 else: assert cluster.read_attributes.await_count == 6 + + +@pytest.fixture +def zigpy_device_ikea(zigpy_device_mock): + """Device tracker zigpy device.""" + endpoints = { + 1: { + SIG_EP_INPUT: [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.Groups.cluster_id, + general.Scenes.cluster_id, + 64637, + ], + SIG_EP_OUTPUT: [], + SIG_EP_TYPE: zha.DeviceType.COMBINED_INTERFACE, + SIG_EP_PROFILE: zha.PROFILE_ID, + }, + } + return zigpy_device_mock( + endpoints, + manufacturer="IKEA of Sweden", + model="STARKVIND Air purifier", + quirk=zhaquirks.ikea.starkvind.IkeaSTARKVIND, + node_descriptor=b"\x02@\x8c\x02\x10RR\x00\x00\x00R\x00\x00", + ) + + +async def test_fan_ikea(hass, zha_device_joined_restored, zigpy_device_ikea): + """Test zha fan Ikea platform.""" + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + + assert hass.states.get(entity_id).state == STATE_OFF + await async_enable_traffic(hass, [zha_device], enabled=False) + # test that the fan was created and that it is unavailable + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + + # test that the state has changed from unavailable to off + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on at fan + await send_attributes_report(hass, cluster, {6: 1}) + assert hass.states.get(entity_id).state == STATE_ON + + # turn off at fan + await send_attributes_report(hass, cluster, {6: 0}) + assert hass.states.get(entity_id).state == STATE_OFF + + # turn on from HA + cluster.write_attributes.reset_mock() + await async_turn_on(hass, entity_id) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + + # turn off from HA + cluster.write_attributes.reset_mock() + await async_turn_off(hass, entity_id) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 0}) + + # change speed from HA + cluster.write_attributes.reset_mock() + await async_set_percentage(hass, entity_id, percentage=100) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 10}) + + # change preset_mode from HA + cluster.write_attributes.reset_mock() + await async_set_preset_mode(hass, entity_id, preset_mode=PRESET_MODE_AUTO) + assert len(cluster.write_attributes.mock_calls) == 1 + assert cluster.write_attributes.call_args == call({"fan_mode": 1}) + + # set invalid preset_mode from HA + cluster.write_attributes.reset_mock() + with pytest.raises(NotValidPresetModeError): + await async_set_preset_mode( + hass, entity_id, preset_mode="invalid does not exist" + ) + assert len(cluster.write_attributes.mock_calls) == 0 + + # test adding new fan to the network and HA + await async_test_rejoin(hass, zigpy_device_ikea, [cluster], (9,)) + + +@pytest.mark.parametrize( + "ikea_plug_read, ikea_expected_state, ikea_expected_percentage, ikea_preset_mode", + ( + (None, STATE_OFF, None, None), + ({"fan_mode": 0}, STATE_OFF, 0, None), + ({"fan_mode": 1}, STATE_ON, 10, PRESET_MODE_AUTO), + ({"fan_mode": 10}, STATE_ON, 20, "Speed 1"), + ({"fan_mode": 15}, STATE_ON, 30, "Speed 1.5"), + ({"fan_mode": 20}, STATE_ON, 40, "Speed 2"), + ({"fan_mode": 25}, STATE_ON, 50, "Speed 2.5"), + ({"fan_mode": 30}, STATE_ON, 60, "Speed 3"), + ({"fan_mode": 35}, STATE_ON, 70, "Speed 3.5"), + ({"fan_mode": 40}, STATE_ON, 80, "Speed 4"), + ({"fan_mode": 45}, STATE_ON, 90, "Speed 4.5"), + ({"fan_mode": 50}, STATE_ON, 100, "Speed 5"), + ), +) +async def test_fan_ikea_init( + hass, + zha_device_joined_restored, + zigpy_device_ikea, + ikea_plug_read, + ikea_expected_state, + ikea_expected_percentage, + ikea_preset_mode, +): + """Test zha fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = ikea_plug_read + + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == ikea_expected_state + assert ( + hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] + == ikea_expected_percentage + ) + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] == ikea_preset_mode + + +async def test_fan_ikea_update_entity( + hass, + zha_device_joined_restored, + zigpy_device_ikea, +): + """Test zha fan platform.""" + cluster = zigpy_device_ikea.endpoints.get(1).ikea_airpurifier + cluster.PLUGGED_ATTR_READS = {"fan_mode": 0} + + zha_device = await zha_device_joined_restored(zigpy_device_ikea) + entity_id = await find_entity_id(Platform.FAN, zha_device, hass) + assert entity_id is not None + assert hass.states.get(entity_id).state == STATE_OFF + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 0 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is None + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 10 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 3 + else: + assert cluster.read_attributes.await_count == 6 + + await async_setup_component(hass, "homeassistant", {}) + await hass.async_block_till_done() + + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_OFF + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 4 + else: + assert cluster.read_attributes.await_count == 7 + + cluster.PLUGGED_ATTR_READS = {"fan_mode": 1} + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id}, blocking=True + ) + assert hass.states.get(entity_id).state == STATE_ON + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE] == 10 + assert hass.states.get(entity_id).attributes[ATTR_PRESET_MODE] is PRESET_MODE_AUTO + assert hass.states.get(entity_id).attributes[ATTR_PERCENTAGE_STEP] == 100 / 10 + if zha_device_joined_restored.name == "zha_device_joined": + assert cluster.read_attributes.await_count == 5 + else: + assert cluster.read_attributes.await_count == 8 From b6f16f87a786b007e744b214974189e08d4b7e73 Mon Sep 17 00:00:00 2001 From: Jeef Date: Wed, 29 Jun 2022 09:51:39 -0600 Subject: [PATCH 872/947] Bump intellifire4py to 2.0.0 (#72563) * Enable Flame/Pilot switch * Enable Flame/Pilot switch * Update homeassistant/components/intellifire/switch.py Co-authored-by: J. Nick Koston * Update homeassistant/components/intellifire/switch.py Thats a great fix! Co-authored-by: J. Nick Koston * write not update * fixed forced upates * removed data field * Refactor to support update to backing library * pre-push-ninja-style * moving over * fixed coverage * removed tuple junk * re-added description * Update homeassistant/components/intellifire/translations/en.json Co-authored-by: Paulus Schoutsen * adressing PR comments * actually store generated values * Update homeassistant/components/intellifire/__init__.py Way better option! Co-authored-by: J. Nick Koston Co-authored-by: J. Nick Koston Co-authored-by: Paulus Schoutsen --- .../components/intellifire/__init__.py | 54 ++++++++++++++++--- .../components/intellifire/config_flow.py | 35 ++++++------ homeassistant/components/intellifire/const.py | 2 + .../components/intellifire/coordinator.py | 48 +++++++++-------- .../components/intellifire/manifest.json | 2 +- .../components/intellifire/switch.py | 25 ++++----- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/intellifire/conftest.py | 2 +- .../intellifire/test_config_flow.py | 27 ++++++---- 10 files changed, 126 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/intellifire/__init__.py b/homeassistant/components/intellifire/__init__.py index 83c6e05f572..034e74c2aa6 100644 --- a/homeassistant/components/intellifire/__init__.py +++ b/homeassistant/components/intellifire/__init__.py @@ -2,15 +2,22 @@ from __future__ import annotations from aiohttp import ClientConnectionError -from intellifire4py import IntellifireAsync, IntellifireControlAsync +from intellifire4py import IntellifireControlAsync from intellifire4py.exceptions import LoginException +from intellifire4py.intellifire import IntellifireAPICloud, IntellifireAPILocal from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import DOMAIN, LOGGER +from .const import CONF_USER_ID, DOMAIN, LOGGER from .coordinator import IntellifireDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR, Platform.SWITCH] @@ -24,8 +31,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: LOGGER.debug("Old config entry format detected: %s", entry.unique_id) raise ConfigEntryAuthFailed - # Define the API Objects - read_object = IntellifireAsync(entry.data[CONF_HOST]) ift_control = IntellifireControlAsync( fireplace_ip=entry.data[CONF_HOST], ) @@ -42,9 +47,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: finally: await ift_control.close() + # Extract API Key and User_ID from ift_control + # Eventually this will migrate to using IntellifireAPICloud + + if CONF_USER_ID not in entry.data or CONF_API_KEY not in entry.data: + LOGGER.info( + "Updating intellifire config entry for %s with api information", + entry.unique_id, + ) + cloud_api = IntellifireAPICloud() + await cloud_api.login( + username=entry.data[CONF_USERNAME], + password=entry.data[CONF_PASSWORD], + ) + api_key = cloud_api.get_fireplace_api_key() + user_id = cloud_api.get_user_id() + # Update data entry + hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_API_KEY: api_key, + CONF_USER_ID: user_id, + }, + ) + + else: + api_key = entry.data[CONF_API_KEY] + user_id = entry.data[CONF_USER_ID] + + # Instantiate local control + api = IntellifireAPILocal( + fireplace_ip=entry.data[CONF_HOST], + api_key=api_key, + user_id=user_id, + ) + # Define the update coordinator coordinator = IntellifireDataUpdateCoordinator( - hass=hass, read_api=read_object, control_api=ift_control + hass=hass, + api=api, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index df13795a4ed..23bd92b0715 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -6,20 +6,17 @@ from dataclasses import dataclass from typing import Any from aiohttp import ClientConnectionError -from intellifire4py import ( - AsyncUDPFireplaceFinder, - IntellifireAsync, - IntellifireControlAsync, -) +from intellifire4py import AsyncUDPFireplaceFinder from intellifire4py.exceptions import LoginException +from intellifire4py.intellifire import IntellifireAPICloud, IntellifireAPILocal import voluptuous as vol from homeassistant import config_entries from homeassistant.components.dhcp import DhcpServiceInfo -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult -from .const import DOMAIN, LOGGER +from .const import CONF_USER_ID, DOMAIN, LOGGER STEP_USER_DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) @@ -39,7 +36,8 @@ async def validate_host_input(host: str) -> str: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ - api = IntellifireAsync(host) + LOGGER.debug("Instantiating IntellifireAPI with host: [%s]", host) + api = IntellifireAPILocal(fireplace_ip=host) await api.poll() serial = api.data.serial LOGGER.debug("Found a fireplace: %s", serial) @@ -83,17 +81,20 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self, *, host: str, username: str, password: str, serial: str ): """Validate username/password against api.""" - ift_control = IntellifireControlAsync(fireplace_ip=host) - LOGGER.debug("Attempting login to iftapi with: %s", username) - # This can throw an error which will be handled above - try: - await ift_control.login(username=username, password=password) - await ift_control.get_username() - finally: - await ift_control.close() - data = {CONF_HOST: host, CONF_PASSWORD: password, CONF_USERNAME: username} + ift_cloud = IntellifireAPICloud() + await ift_cloud.login(username=username, password=password) + api_key = ift_cloud.get_fireplace_api_key() + user_id = ift_cloud.get_user_id() + + data = { + CONF_HOST: host, + CONF_PASSWORD: password, + CONF_USERNAME: username, + CONF_API_KEY: api_key, + CONF_USER_ID: user_id, + } # Update or Create existing_entry = await self.async_set_unique_id(serial) diff --git a/homeassistant/components/intellifire/const.py b/homeassistant/components/intellifire/const.py index fe715c3ce8a..2e9a2fabc06 100644 --- a/homeassistant/components/intellifire/const.py +++ b/homeassistant/components/intellifire/const.py @@ -5,6 +5,8 @@ import logging DOMAIN = "intellifire" +CONF_USER_ID = "user_id" + LOGGER = logging.getLogger(__package__) CONF_SERIAL = "serial" diff --git a/homeassistant/components/intellifire/coordinator.py b/homeassistant/components/intellifire/coordinator.py index 9b74bd81653..39f197285d4 100644 --- a/homeassistant/components/intellifire/coordinator.py +++ b/homeassistant/components/intellifire/coordinator.py @@ -5,11 +5,8 @@ from datetime import timedelta from aiohttp import ClientConnectionError from async_timeout import timeout -from intellifire4py import ( - IntellifireAsync, - IntellifireControlAsync, - IntellifirePollData, -) +from intellifire4py import IntellifirePollData +from intellifire4py.intellifire import IntellifireAPILocal from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo @@ -24,8 +21,7 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData def __init__( self, hass: HomeAssistant, - read_api: IntellifireAsync, - control_api: IntellifireControlAsync, + api: IntellifireAPILocal, ) -> None: """Initialize the Coordinator.""" super().__init__( @@ -34,27 +30,37 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData name=DOMAIN, update_interval=timedelta(seconds=15), ) - self._read_api = read_api - self._control_api = control_api + self._api = api async def _async_update_data(self) -> IntellifirePollData: - LOGGER.debug("Calling update loop on IntelliFire") - async with timeout(100): - try: - await self._read_api.poll() - except (ConnectionError, ClientConnectionError) as exception: - raise UpdateFailed from exception - return self._read_api.data + + if not self._api.is_polling_in_background: + LOGGER.info("Starting Intellifire Background Polling Loop") + await self._api.start_background_polling() + + # Don't return uninitialized poll data + async with timeout(15): + try: + await self._api.poll() + except (ConnectionError, ClientConnectionError) as exception: + raise UpdateFailed from exception + + LOGGER.info("Failure Count %d", self._api.failed_poll_attempts) + if self._api.failed_poll_attempts > 10: + LOGGER.debug("Too many polling errors - raising exception") + raise UpdateFailed + + return self._api.data @property - def read_api(self) -> IntellifireAsync: + def read_api(self) -> IntellifireAPILocal: """Return the Status API pointer.""" - return self._read_api + return self._api @property - def control_api(self) -> IntellifireControlAsync: + def control_api(self) -> IntellifireAPILocal: """Return the control API.""" - return self._control_api + return self._api @property def device_info(self) -> DeviceInfo: @@ -65,5 +71,5 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntellifirePollData name="IntelliFire Fireplace", identifiers={("IntelliFire", f"{self.read_api.data.serial}]")}, sw_version=self.read_api.data.fw_ver_str, - configuration_url=f"http://{self.read_api.ip}/poll", + configuration_url=f"http://{self._api.fireplace_ip}/poll", ) diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index 388ce0c86cb..cd1a12a36bf 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -3,7 +3,7 @@ "name": "IntelliFire", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/intellifire", - "requirements": ["intellifire4py==1.0.2"], + "requirements": ["intellifire4py==2.0.0"], "codeowners": ["@jeeftor"], "iot_class": "local_polling", "loggers": ["intellifire4py"], diff --git a/homeassistant/components/intellifire/switch.py b/homeassistant/components/intellifire/switch.py index 9c196a59fd4..ef0363696c4 100644 --- a/homeassistant/components/intellifire/switch.py +++ b/homeassistant/components/intellifire/switch.py @@ -5,7 +5,8 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from intellifire4py import IntellifireControlAsync, IntellifirePollData +from intellifire4py import IntellifirePollData +from intellifire4py.intellifire import IntellifireAPILocal from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry @@ -21,8 +22,8 @@ from .entity import IntellifireEntity class IntellifireSwitchRequiredKeysMixin: """Mixin for required keys.""" - on_fn: Callable[[IntellifireControlAsync], Awaitable] - off_fn: Callable[[IntellifireControlAsync], Awaitable] + on_fn: Callable[[IntellifireAPILocal], Awaitable] + off_fn: Callable[[IntellifireAPILocal], Awaitable] value_fn: Callable[[IntellifirePollData], bool] @@ -37,24 +38,16 @@ INTELLIFIRE_SWITCHES: tuple[IntellifireSwitchEntityDescription, ...] = ( IntellifireSwitchEntityDescription( key="on_off", name="Flame", - on_fn=lambda control_api: control_api.flame_on( - fireplace=control_api.default_fireplace - ), - off_fn=lambda control_api: control_api.flame_off( - fireplace=control_api.default_fireplace - ), + on_fn=lambda control_api: control_api.flame_on(), + off_fn=lambda control_api: control_api.flame_off(), value_fn=lambda data: data.is_on, ), IntellifireSwitchEntityDescription( key="pilot", name="Pilot Light", icon="mdi:fire-alert", - on_fn=lambda control_api: control_api.pilot_on( - fireplace=control_api.default_fireplace - ), - off_fn=lambda control_api: control_api.pilot_off( - fireplace=control_api.default_fireplace - ), + on_fn=lambda control_api: control_api.pilot_on(), + off_fn=lambda control_api: control_api.pilot_off(), value_fn=lambda data: data.pilot_on, ), ) @@ -82,10 +75,12 @@ class IntellifireSwitch(IntellifireEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the switch.""" await self.entity_description.on_fn(self.coordinator.control_api) + await self.async_update_ha_state(force_refresh=True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the switch.""" await self.entity_description.off_fn(self.coordinator.control_api) + await self.async_update_ha_state(force_refresh=True) @property def is_on(self) -> bool | None: diff --git a/requirements_all.txt b/requirements_all.txt index d758df0a97c..064f686dc49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -894,7 +894,7 @@ influxdb==5.3.1 insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire -intellifire4py==1.0.2 +intellifire4py==2.0.0 # homeassistant.components.iotawatt iotawattpy==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2cf31965f6..f579b9395d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ influxdb==5.3.1 insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire -intellifire4py==1.0.2 +intellifire4py==2.0.0 # homeassistant.components.iotawatt iotawattpy==0.1.0 diff --git a/tests/components/intellifire/conftest.py b/tests/components/intellifire/conftest.py index 3f73834226c..8940acd9d8e 100644 --- a/tests/components/intellifire/conftest.py +++ b/tests/components/intellifire/conftest.py @@ -44,7 +44,7 @@ def mock_intellifire_config_flow() -> Generator[None, MagicMock, None]: data_mock.serial = "12345" with patch( - "homeassistant.components.intellifire.config_flow.IntellifireAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPILocal", autospec=True, ) as intellifire_mock: intellifire = intellifire_mock.return_value diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index 2f48e645708..06fcbea5bfa 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -6,8 +6,8 @@ from intellifire4py.exceptions import LoginException from homeassistant import config_entries from homeassistant.components import dhcp from homeassistant.components.intellifire.config_flow import MANUAL_ENTRY_STRING -from homeassistant.components.intellifire.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.components.intellifire.const import CONF_USER_ID, DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import ( RESULT_TYPE_ABORT, @@ -20,9 +20,10 @@ from tests.components.intellifire.conftest import mock_api_connection_error @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", login=AsyncMock(), - get_username=AsyncMock(return_value="intellifire"), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_no_discovery( hass: HomeAssistant, @@ -64,14 +65,17 @@ async def test_no_discovery( CONF_HOST: "1.1.1.1", CONF_USERNAME: "test", CONF_PASSWORD: "AROONIE", + CONF_API_KEY: "key", + CONF_USER_ID: "intellifire", } assert len(mock_setup_entry.mock_calls) == 1 @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", login=AsyncMock(side_effect=mock_api_connection_error()), - get_username=AsyncMock(return_value="intellifire"), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_single_discovery( hass: HomeAssistant, @@ -101,8 +105,10 @@ async def test_single_discovery( @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", - login=AsyncMock(side_effect=LoginException()), + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", + login=AsyncMock(side_effect=LoginException), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_single_discovery_loign_error( hass: HomeAssistant, @@ -265,9 +271,10 @@ async def test_picker_already_discovered( @patch.multiple( - "homeassistant.components.intellifire.config_flow.IntellifireControlAsync", + "homeassistant.components.intellifire.config_flow.IntellifireAPICloud", login=AsyncMock(), - get_username=AsyncMock(return_value="intellifire"), + get_user_id=MagicMock(return_value="intellifire"), + get_fireplace_api_key=MagicMock(return_value="key"), ) async def test_reauth_flow( hass: HomeAssistant, From 0a65f53356e124592cae37ea1f1873b789e0726b Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 29 Jun 2022 11:40:02 -0500 Subject: [PATCH 873/947] Convert life360 integration to entity based (#72461) * Convert life360 integration to entity based * Improve config_flow.py type checking * Add tests for config flow Fix form defaults for reauth flow. * Cover reauth when config entry loaded * Update per review (except for dataclasses) * Restore check for missing location information This is in current code but was accidentally removed in this PR. * Fix updates from review * Update tests per review changes * Change IntegData to a dataclass * Use dataclasses to represent fetched Life360 data * Always add extra attributes * Update per review take 2 * Tweak handling of bad last_seen or location_accuracy * Fix type of Life360Member.gps_accuracy * Update per review take 3 * Update .coveragerc * Parametrize successful reauth flow test * Fix test coverage failure * Update per review take 4 * Fix config schema --- .coveragerc | 2 +- CODEOWNERS | 1 + homeassistant/components/life360/__init__.py | 248 +++---- .../components/life360/config_flow.py | 263 +++++--- homeassistant/components/life360/const.py | 27 + .../components/life360/coordinator.py | 201 ++++++ .../components/life360/device_tracker.py | 620 ++++++------------ homeassistant/components/life360/helpers.py | 7 - homeassistant/components/life360/strings.json | 33 +- .../components/life360/translations/en.json | 65 +- requirements_test_all.txt | 3 + tests/components/life360/__init__.py | 1 + tests/components/life360/test_config_flow.py | 309 +++++++++ 13 files changed, 1109 insertions(+), 671 deletions(-) create mode 100644 homeassistant/components/life360/coordinator.py delete mode 100644 homeassistant/components/life360/helpers.py create mode 100644 tests/components/life360/__init__.py create mode 100644 tests/components/life360/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index eae8060449a..f52631a57bd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -640,8 +640,8 @@ omit = homeassistant/components/lg_soundbar/media_player.py homeassistant/components/life360/__init__.py homeassistant/components/life360/const.py + homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py - homeassistant/components/life360/helpers.py homeassistant/components/lifx/__init__.py homeassistant/components/lifx/const.py homeassistant/components/lifx/light.py diff --git a/CODEOWNERS b/CODEOWNERS index e349ebeacf0..1b6d88b5464 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -572,6 +572,7 @@ build.json @home-assistant/supervisor /tests/components/lcn/ @alengwenus /homeassistant/components/lg_netcast/ @Drafteed /homeassistant/components/life360/ @pnbruckner +/tests/components/life360/ @pnbruckner /homeassistant/components/lifx/ @Djelibeybi /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 89e7ee680a5..66c9416a1c1 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -1,11 +1,14 @@ """Life360 integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + import voluptuous as vol -from homeassistant import config_entries from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL -from homeassistant.components.device_tracker.const import ( - SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_EXCLUDE, @@ -16,12 +19,10 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import ( - CONF_AUTHORIZATION, CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD, @@ -30,182 +31,147 @@ from .const import ( CONF_MEMBERS, CONF_SHOW_AS_STATE, CONF_WARNING_THRESHOLD, + DEFAULT_OPTIONS, DOMAIN, + LOGGER, SHOW_DRIVING, SHOW_MOVING, ) -from .helpers import get_api +from .coordinator import Life360DataUpdateCoordinator -DEFAULT_PREFIX = DOMAIN +PLATFORMS = [Platform.DEVICE_TRACKER] CONF_ACCOUNTS = "accounts" SHOW_AS_STATE_OPTS = [SHOW_DRIVING, SHOW_MOVING] -def _excl_incl_list_to_filter_dict(value): - return { - "include": CONF_INCLUDE in value, - "list": value.get(CONF_EXCLUDE) or value.get(CONF_INCLUDE), - } - - -def _prefix(value): - if not value: - return "" - if not value.endswith("_"): - return f"{value}_" - return value - - -def _thresholds(config): - error_threshold = config.get(CONF_ERROR_THRESHOLD) - warning_threshold = config.get(CONF_WARNING_THRESHOLD) - if error_threshold and warning_threshold: - if error_threshold <= warning_threshold: - raise vol.Invalid( - f"{CONF_ERROR_THRESHOLD} must be larger than {CONF_WARNING_THRESHOLD}" +def _show_as_state(config: dict) -> dict: + if opts := config.pop(CONF_SHOW_AS_STATE): + if SHOW_DRIVING in opts: + config[SHOW_DRIVING] = True + if SHOW_MOVING in opts: + LOGGER.warning( + "%s is no longer supported as an option for %s", + SHOW_MOVING, + CONF_SHOW_AS_STATE, ) - elif not error_threshold and warning_threshold: - config[CONF_ERROR_THRESHOLD] = warning_threshold + 1 - elif error_threshold and not warning_threshold: - # Make them the same which effectively prevents warnings. - config[CONF_WARNING_THRESHOLD] = error_threshold - else: - # Log all errors as errors. - config[CONF_ERROR_THRESHOLD] = 1 - config[CONF_WARNING_THRESHOLD] = 1 return config -ACCOUNT_SCHEMA = vol.Schema( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) +def _unsupported(unsupported: set[str]) -> Callable[[dict], dict]: + """Warn about unsupported options and remove from config.""" -_SLUG_LIST = vol.All( - cv.ensure_list, [cv.slugify], vol.Length(min=1, msg="List cannot be empty") -) + def validator(config: dict) -> dict: + if unsupported_keys := unsupported & set(config): + LOGGER.warning( + "The following options are no longer supported: %s", + ", ".join(sorted(unsupported_keys)), + ) + return {k: v for k, v in config.items() if k not in unsupported} -_LOWER_STRING_LIST = vol.All( - cv.ensure_list, - [vol.All(cv.string, vol.Lower)], - vol.Length(min=1, msg="List cannot be empty"), -) + return validator -_EXCL_INCL_SLUG_LIST = vol.All( - vol.Schema( - { - vol.Exclusive(CONF_EXCLUDE, "incl_excl"): _SLUG_LIST, - vol.Exclusive(CONF_INCLUDE, "incl_excl"): _SLUG_LIST, - } - ), - cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), - _excl_incl_list_to_filter_dict, -) - -_EXCL_INCL_LOWER_STRING_LIST = vol.All( - vol.Schema( - { - vol.Exclusive(CONF_EXCLUDE, "incl_excl"): _LOWER_STRING_LIST, - vol.Exclusive(CONF_INCLUDE, "incl_excl"): _LOWER_STRING_LIST, - } - ), - cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE), - _excl_incl_list_to_filter_dict, -) - -_THRESHOLD = vol.All(vol.Coerce(int), vol.Range(min=1)) +ACCOUNT_SCHEMA = { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +} +CIRCLES_MEMBERS = { + vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]), +} LIFE360_SCHEMA = vol.All( vol.Schema( { - vol.Optional(CONF_ACCOUNTS): vol.All( - cv.ensure_list, [ACCOUNT_SCHEMA], vol.Length(min=1) - ), - vol.Optional(CONF_CIRCLES): _EXCL_INCL_LOWER_STRING_LIST, + vol.Optional(CONF_ACCOUNTS): vol.All(cv.ensure_list, [ACCOUNT_SCHEMA]), + vol.Optional(CONF_CIRCLES): CIRCLES_MEMBERS, vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float), - vol.Optional(CONF_ERROR_THRESHOLD): _THRESHOLD, + vol.Optional(CONF_ERROR_THRESHOLD): vol.Coerce(int), vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float), - vol.Optional(CONF_MAX_UPDATE_WAIT): vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_MEMBERS): _EXCL_INCL_SLUG_LIST, - vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): vol.All( - vol.Any(None, cv.string), _prefix - ), - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, + vol.Optional(CONF_MAX_UPDATE_WAIT): cv.time_period, + vol.Optional(CONF_MEMBERS): CIRCLES_MEMBERS, + vol.Optional(CONF_PREFIX): vol.Any(None, cv.string), + vol.Optional(CONF_SCAN_INTERVAL): cv.time_period, vol.Optional(CONF_SHOW_AS_STATE, default=[]): vol.All( cv.ensure_list, [vol.In(SHOW_AS_STATE_OPTS)] ), - vol.Optional(CONF_WARNING_THRESHOLD): _THRESHOLD, + vol.Optional(CONF_WARNING_THRESHOLD): vol.Coerce(int), } ), - _thresholds, + _unsupported( + { + CONF_ACCOUNTS, + CONF_CIRCLES, + CONF_ERROR_THRESHOLD, + CONF_MAX_UPDATE_WAIT, + CONF_MEMBERS, + CONF_PREFIX, + CONF_SCAN_INTERVAL, + CONF_WARNING_THRESHOLD, + } + ), + _show_as_state, +) +CONFIG_SCHEMA = vol.Schema( + vol.All({DOMAIN: LIFE360_SCHEMA}, cv.removed(DOMAIN, raise_if_present=False)), + extra=vol.ALLOW_EXTRA, ) -CONFIG_SCHEMA = vol.Schema({DOMAIN: LIFE360_SCHEMA}, extra=vol.ALLOW_EXTRA) + +@dataclass +class IntegData: + """Integration data.""" + + cfg_options: dict[str, Any] | None = None + # ConfigEntry.unique_id: Life360DataUpdateCoordinator + coordinators: dict[str, Life360DataUpdateCoordinator] = field( + init=False, default_factory=dict + ) + # member_id: ConfigEntry.unique_id + tracked_members: dict[str, str] = field(init=False, default_factory=dict) + logged_circles: list[str] = field(init=False, default_factory=list) + logged_places: list[str] = field(init=False, default_factory=list) + + def __post_init__(self): + """Finish initialization of cfg_options.""" + self.cfg_options = self.cfg_options or {} -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up integration.""" - conf = config.get(DOMAIN, LIFE360_SCHEMA({})) - hass.data[DOMAIN] = {"config": conf, "apis": {}} - discovery.load_platform(hass, Platform.DEVICE_TRACKER, DOMAIN, None, config) - - if CONF_ACCOUNTS not in conf: - return True - - # Check existing config entries. For any that correspond to an entry in - # configuration.yaml, and whose password has not changed, nothing needs to - # be done with that config entry or that account from configuration.yaml. - # But if the config entry was created by import and the account no longer - # exists in configuration.yaml, or if the password has changed, then delete - # that out-of-date config entry. - already_configured = [] - for entry in hass.config_entries.async_entries(DOMAIN): - # Find corresponding configuration.yaml entry and its password. - password = None - for account in conf[CONF_ACCOUNTS]: - if account[CONF_USERNAME] == entry.data[CONF_USERNAME]: - password = account[CONF_PASSWORD] - if password == entry.data[CONF_PASSWORD]: - already_configured.append(entry.data[CONF_USERNAME]) - continue - if ( - not password - and entry.source == config_entries.SOURCE_IMPORT - or password - and password != entry.data[CONF_PASSWORD] - ): - hass.async_create_task(hass.config_entries.async_remove(entry.entry_id)) - - # Create config entries for accounts listed in configuration. - for account in conf[CONF_ACCOUNTS]: - if account[CONF_USERNAME] not in already_configured: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=account, - ) - ) + hass.data.setdefault(DOMAIN, IntegData(config.get(DOMAIN))) return True async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up config entry.""" - hass.data[DOMAIN]["apis"][entry.data[CONF_USERNAME]] = get_api( - entry.data[CONF_AUTHORIZATION] - ) + hass.data.setdefault(DOMAIN, IntegData()) + + # Check if this entry was created when this was a "legacy" tracker. If it was, + # update with missing data. + if not entry.unique_id: + hass.config_entries.async_update_entry( + entry, + unique_id=entry.data[CONF_USERNAME].lower(), + options=DEFAULT_OPTIONS | hass.data[DOMAIN].cfg_options, + ) + + coordinator = Life360DataUpdateCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN].coordinators[entry.entry_id] = coordinator + + # Set up components for our platforms. + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" - try: - hass.data[DOMAIN]["apis"].pop(entry.data[CONF_USERNAME]) - return True - except KeyError: - return False + del hass.data[DOMAIN].coordinators[entry.entry_id] + + # Unload components for our platforms. + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py index 0a200e72097..331882aa991 100644 --- a/homeassistant/components/life360/config_flow.py +++ b/homeassistant/components/life360/config_flow.py @@ -1,108 +1,199 @@ """Config flow to configure Life360 integration.""" -from collections import OrderedDict -import logging -from life360 import Life360Error, LoginError +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +from life360 import Life360, Life360Error, LoginError import voluptuous as vol -from homeassistant import config_entries +from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv -from .const import CONF_AUTHORIZATION, DOMAIN -from .helpers import get_api +from .const import ( + COMM_MAX_RETRIES, + COMM_TIMEOUT, + CONF_AUTHORIZATION, + CONF_DRIVING_SPEED, + CONF_MAX_GPS_ACCURACY, + DEFAULT_OPTIONS, + DOMAIN, + LOGGER, + OPTIONS, + SHOW_DRIVING, +) -_LOGGER = logging.getLogger(__name__) - -DOCS_URL = "https://www.home-assistant.io/integrations/life360" +LIMIT_GPS_ACC = "limit_gps_acc" +SET_DRIVE_SPEED = "set_drive_speed" -class Life360ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): +def account_schema( + def_username: str | vol.UNDEFINED = vol.UNDEFINED, + def_password: str | vol.UNDEFINED = vol.UNDEFINED, +) -> dict[vol.Marker, Any]: + """Return schema for an account with optional default values.""" + return { + vol.Required(CONF_USERNAME, default=def_username): cv.string, + vol.Required(CONF_PASSWORD, default=def_password): cv.string, + } + + +def password_schema( + def_password: str | vol.UNDEFINED = vol.UNDEFINED, +) -> dict[vol.Marker, Any]: + """Return schema for a password with optional default value.""" + return {vol.Required(CONF_PASSWORD, default=def_password): cv.string} + + +class Life360ConfigFlow(ConfigFlow, domain=DOMAIN): """Life360 integration config flow.""" VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize.""" - self._api = get_api() - self._username = vol.UNDEFINED - self._password = vol.UNDEFINED + self._api = Life360(timeout=COMM_TIMEOUT, max_retries=COMM_MAX_RETRIES) + self._username: str | vol.UNDEFINED = vol.UNDEFINED + self._password: str | vol.UNDEFINED = vol.UNDEFINED + self._reauth_entry: ConfigEntry | None = None - @property - def configured_usernames(self): - """Return tuple of configured usernames.""" - entries = self._async_current_entries() - if entries: - return (entry.data[CONF_USERNAME] for entry in entries) - return () + @staticmethod + @callback + def async_get_options_flow(config_entry: ConfigEntry) -> Life360OptionsFlow: + """Get the options flow for this handler.""" + return Life360OptionsFlow(config_entry) - async def async_step_user(self, user_input=None): - """Handle a user initiated config flow.""" - errors = {} - - if user_input is not None: - self._username = user_input[CONF_USERNAME] - self._password = user_input[CONF_PASSWORD] - try: - # pylint: disable=no-value-for-parameter - vol.Email()(self._username) - authorization = await self.hass.async_add_executor_job( - self._api.get_authorization, self._username, self._password - ) - except vol.Invalid: - errors[CONF_USERNAME] = "invalid_username" - except LoginError: - errors["base"] = "invalid_auth" - except Life360Error as error: - _LOGGER.error( - "Unexpected error communicating with Life360 server: %s", error - ) - errors["base"] = "unknown" - else: - if self._username in self.configured_usernames: - errors["base"] = "already_configured" - else: - return self.async_create_entry( - title=self._username, - data={ - CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, - CONF_AUTHORIZATION: authorization, - }, - description_placeholders={"docs_url": DOCS_URL}, - ) - - data_schema = OrderedDict() - data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str - data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str - - return self.async_show_form( - step_id="user", - data_schema=vol.Schema(data_schema), - errors=errors, - description_placeholders={"docs_url": DOCS_URL}, - ) - - async def async_step_import(self, user_input): - """Import a config flow from configuration.""" - username = user_input[CONF_USERNAME] - password = user_input[CONF_PASSWORD] + async def _async_verify(self, step_id: str) -> FlowResult: + """Attempt to authorize the provided credentials.""" + errors: dict[str, str] = {} try: authorization = await self.hass.async_add_executor_job( - self._api.get_authorization, username, password + self._api.get_authorization, self._username, self._password ) - except LoginError: - _LOGGER.error("Invalid credentials for %s", username) - return self.async_abort(reason="invalid_auth") - except Life360Error as error: - _LOGGER.error( - "Unexpected error communicating with Life360 server: %s", error + except LoginError as exc: + LOGGER.debug("Login error: %s", exc) + errors["base"] = "invalid_auth" + except Life360Error as exc: + LOGGER.debug("Unexpected error communicating with Life360 server: %s", exc) + errors["base"] = "cannot_connect" + if errors: + if step_id == "user": + schema = account_schema(self._username, self._password) + else: + schema = password_schema(self._password) + return self.async_show_form( + step_id=step_id, data_schema=vol.Schema(schema), errors=errors ) - return self.async_abort(reason="unknown") + + data = { + CONF_USERNAME: self._username, + CONF_PASSWORD: self._password, + CONF_AUTHORIZATION: authorization, + } + + if self._reauth_entry: + LOGGER.debug("Reauthorization successful") + self.hass.config_entries.async_update_entry(self._reauth_entry, data=data) + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + ) + return self.async_abort(reason="reauth_successful") + return self.async_create_entry( - title=f"{username} (from configuration)", - data={ - CONF_USERNAME: username, - CONF_PASSWORD: password, - CONF_AUTHORIZATION: authorization, - }, + title=cast(str, self.unique_id), data=data, options=DEFAULT_OPTIONS ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a config flow initiated by the user.""" + if not user_input: + return self.async_show_form( + step_id="user", data_schema=vol.Schema(account_schema()) + ) + + self._username = user_input[CONF_USERNAME] + self._password = user_input[CONF_PASSWORD] + + await self.async_set_unique_id(self._username.lower()) + self._abort_if_unique_id_configured() + + return await self._async_verify("user") + + async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: + """Handle reauthorization.""" + self._username = data[CONF_USERNAME] + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + # Always start with current credentials since they may still be valid and a + # simple reauthorization will be successful. + return await self.async_step_reauth_confirm(dict(data)) + + async def async_step_reauth_confirm(self, user_input: dict[str, Any]) -> FlowResult: + """Handle reauthorization completion.""" + self._password = user_input[CONF_PASSWORD] + return await self._async_verify("reauth_confirm") + + +class Life360OptionsFlow(OptionsFlow): + """Life360 integration options flow.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + """Initialize.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle account options.""" + options = self.config_entry.options + + if user_input is not None: + new_options = _extract_account_options(user_input) + return self.async_create_entry(title="", data=new_options) + + return self.async_show_form( + step_id="init", data_schema=vol.Schema(_account_options_schema(options)) + ) + + +def _account_options_schema(options: Mapping[str, Any]) -> dict[vol.Marker, Any]: + """Create schema for account options form.""" + def_limit_gps_acc = options[CONF_MAX_GPS_ACCURACY] is not None + def_max_gps = options[CONF_MAX_GPS_ACCURACY] or vol.UNDEFINED + def_set_drive_speed = options[CONF_DRIVING_SPEED] is not None + def_speed = options[CONF_DRIVING_SPEED] or vol.UNDEFINED + def_show_driving = options[SHOW_DRIVING] + + return { + vol.Required(LIMIT_GPS_ACC, default=def_limit_gps_acc): bool, + vol.Optional(CONF_MAX_GPS_ACCURACY, default=def_max_gps): vol.Coerce(float), + vol.Required(SET_DRIVE_SPEED, default=def_set_drive_speed): bool, + vol.Optional(CONF_DRIVING_SPEED, default=def_speed): vol.Coerce(float), + vol.Optional(SHOW_DRIVING, default=def_show_driving): bool, + } + + +def _extract_account_options(user_input: dict) -> dict[str, Any]: + """Remove options from user input and return as a separate dict.""" + result = {} + + for key in OPTIONS: + value = user_input.pop(key, None) + # Was "include" checkbox (if there was one) corresponding to option key True + # (meaning option should be included)? + incl = user_input.pop( + { + CONF_MAX_GPS_ACCURACY: LIMIT_GPS_ACC, + CONF_DRIVING_SPEED: SET_DRIVE_SPEED, + }.get(key), + True, + ) + result[key] = value if incl else None + + return result diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py index 41f4a990e67..ccaf69877d6 100644 --- a/homeassistant/components/life360/const.py +++ b/homeassistant/components/life360/const.py @@ -1,5 +1,25 @@ """Constants for Life360 integration.""" + +from datetime import timedelta +import logging + DOMAIN = "life360" +LOGGER = logging.getLogger(__package__) + +ATTRIBUTION = "Data provided by life360.com" +COMM_MAX_RETRIES = 2 +COMM_TIMEOUT = 3.05 +SPEED_FACTOR_MPH = 2.25 +SPEED_DIGITS = 1 +UPDATE_INTERVAL = timedelta(seconds=10) + +ATTR_ADDRESS = "address" +ATTR_AT_LOC_SINCE = "at_loc_since" +ATTR_DRIVING = "driving" +ATTR_LAST_SEEN = "last_seen" +ATTR_PLACE = "place" +ATTR_SPEED = "speed" +ATTR_WIFI_ON = "wifi_on" CONF_AUTHORIZATION = "authorization" CONF_CIRCLES = "circles" @@ -13,3 +33,10 @@ CONF_WARNING_THRESHOLD = "warning_threshold" SHOW_DRIVING = "driving" SHOW_MOVING = "moving" + +DEFAULT_OPTIONS = { + CONF_DRIVING_SPEED: None, + CONF_MAX_GPS_ACCURACY: None, + SHOW_DRIVING: False, +} +OPTIONS = list(DEFAULT_OPTIONS.keys()) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py new file mode 100644 index 00000000000..dc7fdb73a8c --- /dev/null +++ b/homeassistant/components/life360/coordinator.py @@ -0,0 +1,201 @@ +"""DataUpdateCoordinator for the Life360 integration.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime +from typing import Any + +from life360 import Life360, Life360Error, LoginError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + LENGTH_FEET, + LENGTH_KILOMETERS, + LENGTH_METERS, + LENGTH_MILES, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.distance import convert +import homeassistant.util.dt as dt_util + +from .const import ( + COMM_MAX_RETRIES, + COMM_TIMEOUT, + CONF_AUTHORIZATION, + DOMAIN, + LOGGER, + SPEED_DIGITS, + SPEED_FACTOR_MPH, + UPDATE_INTERVAL, +) + + +@dataclass +class Life360Place: + """Life360 Place data.""" + + name: str + latitude: float + longitude: float + radius: float + + +@dataclass +class Life360Circle: + """Life360 Circle data.""" + + name: str + places: dict[str, Life360Place] + + +@dataclass +class Life360Member: + """Life360 Member data.""" + + # Don't include address field in eq comparison because it often changes (back and + # forth) between updates. If it was included there would be way more state changes + # and database updates than is useful. + address: str | None = field(compare=False) + at_loc_since: datetime + battery_charging: bool + battery_level: int + driving: bool + entity_picture: str + gps_accuracy: int + last_seen: datetime + latitude: float + longitude: float + name: str + place: str | None + speed: float + wifi_on: bool + + +@dataclass +class Life360Data: + """Life360 data.""" + + circles: dict[str, Life360Circle] = field(init=False, default_factory=dict) + members: dict[str, Life360Member] = field(init=False, default_factory=dict) + + +class Life360DataUpdateCoordinator(DataUpdateCoordinator): + """Life360 data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize data update coordinator.""" + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN} ({entry.unique_id})", + update_interval=UPDATE_INTERVAL, + ) + self._hass = hass + self._api = Life360( + timeout=COMM_TIMEOUT, + max_retries=COMM_MAX_RETRIES, + authorization=entry.data[CONF_AUTHORIZATION], + ) + + async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]: + """Get data from Life360.""" + try: + return await self._hass.async_add_executor_job( + getattr(self._api, func), *args + ) + except LoginError as exc: + LOGGER.debug("Login error: %s", exc) + raise ConfigEntryAuthFailed from exc + except Life360Error as exc: + LOGGER.debug("%s: %s", exc.__class__.__name__, exc) + raise UpdateFailed from exc + + async def _async_update_data(self) -> Life360Data: + """Get & process data from Life360.""" + + data = Life360Data() + + for circle in await self._retrieve_data("get_circles"): + circle_id = circle["id"] + circle_members = await self._retrieve_data("get_circle_members", circle_id) + circle_places = await self._retrieve_data("get_circle_places", circle_id) + + data.circles[circle_id] = Life360Circle( + circle["name"], + { + place["id"]: Life360Place( + place["name"], + float(place["latitude"]), + float(place["longitude"]), + float(place["radius"]), + ) + for place in circle_places + }, + ) + + for member in circle_members: + # Member isn't sharing location. + if not int(member["features"]["shareLocation"]): + continue + + # Note that member may be in more than one circle. If that's the case just + # go ahead and process the newly retrieved data (overwriting the older + # data), since it might be slightly newer than what was retrieved while + # processing another circle. + + first = member["firstName"] + last = member["lastName"] + if first and last: + name = " ".join([first, last]) + else: + name = first or last + + loc = member["location"] + if not loc: + if err_msg := member["issues"]["title"]: + if member["issues"]["dialog"]: + err_msg += f": {member['issues']['dialog']}" + else: + err_msg = "Location information missing" + LOGGER.error("%s: %s", name, err_msg) + continue + + place = loc["name"] or None + + if place: + address: str | None = place + else: + address1 = loc["address1"] or None + address2 = loc["address2"] or None + if address1 and address2: + address = ", ".join([address1, address2]) + else: + address = address1 or address2 + + speed = max(0, float(loc["speed"]) * SPEED_FACTOR_MPH) + if self._hass.config.units.is_metric: + speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS) + + data.members[member["id"]] = Life360Member( + address, + dt_util.utc_from_timestamp(int(loc["since"])), + bool(int(loc["charge"])), + int(float(loc["battery"])), + bool(int(loc["isDriving"])), + member["avatar"], + # Life360 reports accuracy in feet, but Device Tracker expects + # gps_accuracy in meters. + round(convert(float(loc["accuracy"]), LENGTH_FEET, LENGTH_METERS)), + dt_util.utc_from_timestamp(int(loc["timestamp"])), + float(loc["latitude"]), + float(loc["longitude"]), + name, + place, + round(speed, SPEED_DIGITS), + bool(int(loc["wifiState"])), + ) + + return data diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 2451a237a1e..a38181a6830 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -1,432 +1,244 @@ """Support for Life360 device tracking.""" + from __future__ import annotations -from collections.abc import Callable -from datetime import timedelta -import logging +from collections.abc import Mapping +from typing import Any, cast -from life360 import Life360Error -import voluptuous as vol - -from homeassistant.components.device_tracker import ( - CONF_SCAN_INTERVAL, - DOMAIN as DEVICE_TRACKER_DOMAIN, +from homeassistant.components.device_tracker import SOURCE_TYPE_GPS +from homeassistant.components.device_tracker.config_entry import TrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_BATTERY_CHARGING +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, ) -from homeassistant.components.zone import async_active_zone -from homeassistant.const import ( - ATTR_BATTERY_CHARGING, - ATTR_ENTITY_ID, - CONF_PREFIX, - LENGTH_FEET, - LENGTH_KILOMETERS, - LENGTH_METERS, - LENGTH_MILES, - STATE_UNKNOWN, -) -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_time_interval -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.distance import convert -import homeassistant.util.dt as dt_util from .const import ( - CONF_CIRCLES, + ATTR_ADDRESS, + ATTR_AT_LOC_SINCE, + ATTR_DRIVING, + ATTR_LAST_SEEN, + ATTR_PLACE, + ATTR_SPEED, + ATTR_WIFI_ON, + ATTRIBUTION, CONF_DRIVING_SPEED, - CONF_ERROR_THRESHOLD, CONF_MAX_GPS_ACCURACY, - CONF_MAX_UPDATE_WAIT, - CONF_MEMBERS, - CONF_SHOW_AS_STATE, - CONF_WARNING_THRESHOLD, DOMAIN, + LOGGER, SHOW_DRIVING, - SHOW_MOVING, ) -_LOGGER = logging.getLogger(__name__) - -SPEED_FACTOR_MPH = 2.25 -EVENT_DELAY = timedelta(seconds=30) - -ATTR_ADDRESS = "address" -ATTR_AT_LOC_SINCE = "at_loc_since" -ATTR_DRIVING = "driving" -ATTR_LAST_SEEN = "last_seen" -ATTR_MOVING = "moving" -ATTR_PLACE = "place" -ATTR_RAW_SPEED = "raw_speed" -ATTR_SPEED = "speed" -ATTR_WAIT = "wait" -ATTR_WIFI_ON = "wifi_on" - -EVENT_UPDATE_OVERDUE = "life360_update_overdue" -EVENT_UPDATE_RESTORED = "life360_update_restored" +_LOC_ATTRS = ( + "address", + "at_loc_since", + "driving", + "gps_accuracy", + "last_seen", + "latitude", + "longitude", + "place", + "speed", +) -def _include_name(filter_dict, name): - if not name: +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the device tracker platform.""" + coordinator = hass.data[DOMAIN].coordinators[entry.entry_id] + tracked_members = hass.data[DOMAIN].tracked_members + logged_circles = hass.data[DOMAIN].logged_circles + logged_places = hass.data[DOMAIN].logged_places + + @callback + def process_data(new_members_only: bool = True) -> None: + """Process new Life360 data.""" + for circle_id, circle in coordinator.data.circles.items(): + if circle_id not in logged_circles: + logged_circles.append(circle_id) + LOGGER.debug("Circle: %s", circle.name) + + new_places = [] + for place_id, place in circle.places.items(): + if place_id not in logged_places: + logged_places.append(place_id) + new_places.append(place) + if new_places: + msg = f"Places from {circle.name}:" + for place in new_places: + msg += f"\n- name: {place.name}" + msg += f"\n latitude: {place.latitude}" + msg += f"\n longitude: {place.longitude}" + msg += f"\n radius: {place.radius}" + LOGGER.debug(msg) + + new_entities = [] + for member_id, member in coordinator.data.members.items(): + tracked_by_account = tracked_members.get(member_id) + if new_member := not tracked_by_account: + tracked_members[member_id] = entry.unique_id + LOGGER.debug("Member: %s", member.name) + if ( + new_member + or tracked_by_account == entry.unique_id + and not new_members_only + ): + new_entities.append(Life360DeviceTracker(coordinator, member_id)) + if new_entities: + async_add_entities(new_entities) + + process_data(new_members_only=False) + entry.async_on_unload(coordinator.async_add_listener(process_data)) + + +class Life360DeviceTracker(CoordinatorEntity, TrackerEntity): + """Life360 Device Tracker.""" + + _attr_attribution = ATTRIBUTION + + def __init__(self, coordinator: DataUpdateCoordinator, member_id: str) -> None: + """Initialize Life360 Entity.""" + super().__init__(coordinator) + self._attr_unique_id = member_id + + self._data = coordinator.data.members[self.unique_id] + + self._attr_name = self._data.name + self._attr_entity_picture = self._data.entity_picture + + self._prev_data = self._data + + @property + def _options(self) -> Mapping[str, Any]: + """Shortcut to config entry options.""" + return cast(Mapping[str, Any], self.coordinator.config_entry.options) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + # Get a shortcut to this member's data. Can't guarantee it's the same dict every + # update, or that there is even data for this member every update, so need to + # update shortcut each time. + self._data = self.coordinator.data.members.get(self.unique_id) + + if self.available: + # If nothing important has changed, then skip the update altogether. + if self._data == self._prev_data: + return + + # Check if we should effectively throw out new location data. + last_seen = self._data.last_seen + prev_seen = self._prev_data.last_seen + max_gps_acc = self._options.get(CONF_MAX_GPS_ACCURACY) + bad_last_seen = last_seen < prev_seen + bad_accuracy = ( + max_gps_acc is not None and self.location_accuracy > max_gps_acc + ) + if bad_last_seen or bad_accuracy: + if bad_last_seen: + LOGGER.warning( + "%s: Ignoring location update because " + "last_seen (%s) < previous last_seen (%s)", + self.entity_id, + last_seen, + prev_seen, + ) + if bad_accuracy: + LOGGER.warning( + "%s: Ignoring location update because " + "expected GPS accuracy (%0.1f) is not met: %i", + self.entity_id, + max_gps_acc, + self.location_accuracy, + ) + # Overwrite new location related data with previous values. + for attr in _LOC_ATTRS: + setattr(self._data, attr, getattr(self._prev_data, attr)) + + self._prev_data = self._data + + super()._handle_coordinator_update() + + @property + def force_update(self) -> bool: + """Return True if state updates should be forced.""" return False - if not filter_dict: - return True - name = name.lower() - if filter_dict["include"]: - return name in filter_dict["list"] - return name not in filter_dict["list"] + @property + def available(self) -> bool: + """Return if entity is available.""" + # Guard against member not being in last update for some reason. + return super().available and self._data is not None -def _exc_msg(exc): - return f"{exc.__class__.__name__}: {exc}" + @property + def entity_picture(self) -> str | None: + """Return the entity picture to use in the frontend, if any.""" + if self.available: + self._attr_entity_picture = self._data.entity_picture + return super().entity_picture + # All of the following will only be called if self.available is True. -def _dump_filter(filter_dict, desc, func=lambda x: x): - if not filter_dict: - return - _LOGGER.debug( - "%scluding %s: %s", - "In" if filter_dict["include"] else "Ex", - desc, - ", ".join([func(name) for name in filter_dict["list"]]), - ) + @property + def battery_level(self) -> int | None: + """Return the battery level of the device. + Percentage from 0-100. + """ + return self._data.battery_level -def setup_scanner( - hass: HomeAssistant, - config: ConfigType, - see: Callable[..., None], - discovery_info: DiscoveryInfoType | None = None, -) -> bool: - """Set up device scanner.""" - config = hass.data[DOMAIN]["config"] - apis = hass.data[DOMAIN]["apis"] - Life360Scanner(hass, config, see, apis) - return True + @property + def source_type(self) -> str: + """Return the source type, eg gps or router, of the device.""" + return SOURCE_TYPE_GPS + @property + def location_accuracy(self) -> int: + """Return the location accuracy of the device. -def _utc_from_ts(val): - try: - return dt_util.utc_from_timestamp(float(val)) - except (TypeError, ValueError): + Value in meters. + """ + return self._data.gps_accuracy + + @property + def driving(self) -> bool: + """Return if driving.""" + if (driving_speed := self._options.get(CONF_DRIVING_SPEED)) is not None: + if self._data.speed >= driving_speed: + return True + return self._data.driving + + @property + def location_name(self) -> str | None: + """Return a location name for the current location of the device.""" + if self._options.get(SHOW_DRIVING) and self.driving: + return "Driving" return None + @property + def latitude(self) -> float | None: + """Return latitude value of the device.""" + return self._data.latitude -def _dt_attr_from_ts(timestamp): - utc = _utc_from_ts(timestamp) - if utc: - return utc - return STATE_UNKNOWN + @property + def longitude(self) -> float | None: + """Return longitude value of the device.""" + return self._data.longitude - -def _bool_attr_from_int(val): - try: - return bool(int(val)) - except (TypeError, ValueError): - return STATE_UNKNOWN - - -class Life360Scanner: - """Life360 device scanner.""" - - def __init__(self, hass, config, see, apis): - """Initialize Life360Scanner.""" - self._hass = hass - self._see = see - self._max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY) - self._max_update_wait = config.get(CONF_MAX_UPDATE_WAIT) - self._prefix = config[CONF_PREFIX] - self._circles_filter = config.get(CONF_CIRCLES) - self._members_filter = config.get(CONF_MEMBERS) - self._driving_speed = config.get(CONF_DRIVING_SPEED) - self._show_as_state = config[CONF_SHOW_AS_STATE] - self._apis = apis - self._errs = {} - self._error_threshold = config[CONF_ERROR_THRESHOLD] - self._warning_threshold = config[CONF_WARNING_THRESHOLD] - self._max_errs = self._error_threshold + 1 - self._dev_data = {} - self._circles_logged = set() - self._members_logged = set() - - _dump_filter(self._circles_filter, "Circles") - _dump_filter(self._members_filter, "device IDs", self._dev_id) - - self._started = dt_util.utcnow() - self._update_life360() - track_time_interval( - self._hass, self._update_life360, config[CONF_SCAN_INTERVAL] - ) - - def _dev_id(self, name): - return self._prefix + name - - def _ok(self, key): - if self._errs.get(key, 0) >= self._max_errs: - _LOGGER.error("%s: OK again", key) - self._errs[key] = 0 - - def _err(self, key, err_msg): - _errs = self._errs.get(key, 0) - if _errs < self._max_errs: - self._errs[key] = _errs = _errs + 1 - msg = f"{key}: {err_msg}" - if _errs >= self._error_threshold: - if _errs == self._max_errs: - msg = f"Suppressing further errors until OK: {msg}" - _LOGGER.error(msg) - elif _errs >= self._warning_threshold: - _LOGGER.warning(msg) - - def _exc(self, key, exc): - self._err(key, _exc_msg(exc)) - - def _prev_seen(self, dev_id, last_seen): - prev_seen, reported = self._dev_data.get(dev_id, (None, False)) - - if self._max_update_wait: - now = dt_util.utcnow() - most_recent_update = last_seen or prev_seen or self._started - overdue = now - most_recent_update > self._max_update_wait - if overdue and not reported and now - self._started > EVENT_DELAY: - self._hass.bus.fire( - EVENT_UPDATE_OVERDUE, - {ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}"}, - ) - reported = True - elif not overdue and reported: - self._hass.bus.fire( - EVENT_UPDATE_RESTORED, - { - ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}", - ATTR_WAIT: str(last_seen - (prev_seen or self._started)).split( - ".", maxsplit=1 - )[0], - }, - ) - reported = False - - # Don't remember last_seen unless it's really an update. - if not last_seen or prev_seen and last_seen <= prev_seen: - last_seen = prev_seen - self._dev_data[dev_id] = last_seen, reported - - return prev_seen - - def _update_member(self, member, dev_id): - loc = member.get("location") - try: - last_seen = _utc_from_ts(loc.get("timestamp")) - except AttributeError: - last_seen = None - prev_seen = self._prev_seen(dev_id, last_seen) - - if not loc: - if err_msg := member["issues"]["title"]: - if member["issues"]["dialog"]: - err_msg += f": {member['issues']['dialog']}" - else: - err_msg = "Location information missing" - self._err(dev_id, err_msg) - return - - # Only update when we truly have an update. - if not last_seen: - _LOGGER.warning("%s: Ignoring update because timestamp is missing", dev_id) - return - if prev_seen and last_seen < prev_seen: - _LOGGER.warning( - "%s: Ignoring update because timestamp is older than last timestamp", - dev_id, - ) - _LOGGER.debug("%s < %s", last_seen, prev_seen) - return - if last_seen == prev_seen: - return - - lat = loc.get("latitude") - lon = loc.get("longitude") - gps_accuracy = loc.get("accuracy") - try: - lat = float(lat) - lon = float(lon) - # Life360 reports accuracy in feet, but Device Tracker expects - # gps_accuracy in meters. - gps_accuracy = round( - convert(float(gps_accuracy), LENGTH_FEET, LENGTH_METERS) - ) - except (TypeError, ValueError): - self._err(dev_id, f"GPS data invalid: {lat}, {lon}, {gps_accuracy}") - return - - self._ok(dev_id) - - msg = f"Updating {dev_id}" - if prev_seen: - msg += f"; Time since last update: {last_seen - prev_seen}" - _LOGGER.debug(msg) - - if self._max_gps_accuracy is not None and gps_accuracy > self._max_gps_accuracy: - _LOGGER.warning( - "%s: Ignoring update because expected GPS " - "accuracy (%.0f) is not met: %.0f", - dev_id, - self._max_gps_accuracy, - gps_accuracy, - ) - return - - # Get raw attribute data, converting empty strings to None. - place = loc.get("name") or None - address1 = loc.get("address1") or None - address2 = loc.get("address2") or None - if address1 and address2: - address = ", ".join([address1, address2]) - else: - address = address1 or address2 - raw_speed = loc.get("speed") or None - driving = _bool_attr_from_int(loc.get("isDriving")) - moving = _bool_attr_from_int(loc.get("inTransit")) - try: - battery = int(float(loc.get("battery"))) - except (TypeError, ValueError): - battery = None - - # Try to convert raw speed into real speed. - try: - speed = float(raw_speed) * SPEED_FACTOR_MPH - if self._hass.config.units.is_metric: - speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS) - speed = max(0, round(speed)) - except (TypeError, ValueError): - speed = STATE_UNKNOWN - - # Make driving attribute True if it isn't and we can derive that it - # should be True from other data. - if ( - driving in (STATE_UNKNOWN, False) - and self._driving_speed is not None - and speed != STATE_UNKNOWN - ): - driving = speed >= self._driving_speed - - attrs = { - ATTR_ADDRESS: address, - ATTR_AT_LOC_SINCE: _dt_attr_from_ts(loc.get("since")), - ATTR_BATTERY_CHARGING: _bool_attr_from_int(loc.get("charge")), - ATTR_DRIVING: driving, - ATTR_LAST_SEEN: last_seen, - ATTR_MOVING: moving, - ATTR_PLACE: place, - ATTR_RAW_SPEED: raw_speed, - ATTR_SPEED: speed, - ATTR_WIFI_ON: _bool_attr_from_int(loc.get("wifiState")), - } - - # If user wants driving or moving to be shown as state, and current - # location is not in a HA zone, then set location name accordingly. - loc_name = None - active_zone = run_callback_threadsafe( - self._hass.loop, async_active_zone, self._hass, lat, lon, gps_accuracy - ).result() - if not active_zone: - if SHOW_DRIVING in self._show_as_state and driving is True: - loc_name = SHOW_DRIVING - elif SHOW_MOVING in self._show_as_state and moving is True: - loc_name = SHOW_MOVING - - self._see( - dev_id=dev_id, - location_name=loc_name, - gps=(lat, lon), - gps_accuracy=gps_accuracy, - battery=battery, - attributes=attrs, - picture=member.get("avatar"), - ) - - def _update_members(self, members, members_updated): - for member in members: - member_id = member["id"] - if member_id in members_updated: - continue - err_key = "Member data" - try: - first = member.get("firstName") - last = member.get("lastName") - if first and last: - full_name = " ".join([first, last]) - else: - full_name = first or last - slug_name = cv.slugify(full_name) - include_member = _include_name(self._members_filter, slug_name) - dev_id = self._dev_id(slug_name) - if member_id not in self._members_logged: - self._members_logged.add(member_id) - _LOGGER.debug( - "%s -> %s: will%s be tracked, id=%s", - full_name, - dev_id, - "" if include_member else " NOT", - member_id, - ) - sharing = bool(int(member["features"]["shareLocation"])) - except (KeyError, TypeError, ValueError, vol.Invalid): - self._err(err_key, member) - continue - self._ok(err_key) - - if include_member and sharing: - members_updated.append(member_id) - self._update_member(member, dev_id) - - def _update_life360(self, now=None): - circles_updated = [] - members_updated = [] - - for api in self._apis.values(): - err_key = "get_circles" - try: - circles = api.get_circles() - except Life360Error as exc: - self._exc(err_key, exc) - continue - self._ok(err_key) - - for circle in circles: - circle_id = circle["id"] - if circle_id in circles_updated: - continue - circles_updated.append(circle_id) - circle_name = circle["name"] - incl_circle = _include_name(self._circles_filter, circle_name) - if circle_id not in self._circles_logged: - self._circles_logged.add(circle_id) - _LOGGER.debug( - "%s Circle: will%s be included, id=%s", - circle_name, - "" if incl_circle else " NOT", - circle_id, - ) - try: - places = api.get_circle_places(circle_id) - place_data = "Circle's Places:" - for place in places: - place_data += f"\n- name: {place['name']}" - place_data += f"\n latitude: {place['latitude']}" - place_data += f"\n longitude: {place['longitude']}" - place_data += f"\n radius: {place['radius']}" - if not places: - place_data += " None" - _LOGGER.debug(place_data) - except (Life360Error, KeyError): - pass - if incl_circle: - err_key = f'get_circle_members "{circle_name}"' - try: - members = api.get_circle_members(circle_id) - except Life360Error as exc: - self._exc(err_key, exc) - continue - self._ok(err_key) - - self._update_members(members, members_updated) + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return entity specific state attributes.""" + attrs = {} + attrs[ATTR_ADDRESS] = self._data.address + attrs[ATTR_AT_LOC_SINCE] = self._data.at_loc_since + attrs[ATTR_BATTERY_CHARGING] = self._data.battery_charging + attrs[ATTR_DRIVING] = self.driving + attrs[ATTR_LAST_SEEN] = self._data.last_seen + attrs[ATTR_PLACE] = self._data.place + attrs[ATTR_SPEED] = self._data.speed + attrs[ATTR_WIFI_ON] = self._data.wifi_on + return attrs diff --git a/homeassistant/components/life360/helpers.py b/homeassistant/components/life360/helpers.py deleted file mode 100644 index 0eb215743df..00000000000 --- a/homeassistant/components/life360/helpers.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Life360 integration helpers.""" -from life360 import Life360 - - -def get_api(authorization=None): - """Create Life360 api object.""" - return Life360(timeout=3.05, max_retries=2, authorization=authorization) diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json index 06ac88467ef..cc31ca64a08 100644 --- a/homeassistant/components/life360/strings.json +++ b/homeassistant/components/life360/strings.json @@ -2,26 +2,43 @@ "config": { "step": { "user": { - "title": "Life360 Account Info", + "title": "Configure Life360 Account", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" - }, - "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts." + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } } }, "error": { - "invalid_username": "Invalid username", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, - "create_entry": { - "default": "To set advanced options, see [Life360 documentation]({docs_url})." - }, "abort": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + }, + "options": { + "step": { + "init": { + "title": "Account Options", + "data": { + "limit_gps_acc": "Limit GPS accuracy", + "max_gps_accuracy": "Max GPS accuracy (meters)", + "set_drive_speed": "Set driving speed threshold", + "driving_speed": "Driving speed", + "driving": "Show driving as state" + } + } } } } diff --git a/homeassistant/components/life360/translations/en.json b/homeassistant/components/life360/translations/en.json index fa836c62b61..b4c9eb452f6 100644 --- a/homeassistant/components/life360/translations/en.json +++ b/homeassistant/components/life360/translations/en.json @@ -1,27 +1,44 @@ { - "config": { - "abort": { - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "create_entry": { - "default": "To set advanced options, see [Life360 documentation]({docs_url})." - }, - "error": { - "already_configured": "Account is already configured", - "invalid_auth": "Invalid authentication", - "invalid_username": "Invalid username", - "unknown": "Unexpected error" - }, - "step": { - "user": { - "data": { - "password": "Password", - "username": "Username" - }, - "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts.", - "title": "Life360 Account Info" - } + "config": { + "step": { + "user": { + "title": "Configure Life360 Account", + "data": { + "username": "Username", + "password": "Password" } + }, + "reauth_confirm": { + "title": "Reauthenticate Integration", + "data": { + "password": "Password" + } + } + }, + "error": { + "invalid_auth": "Invalid authentication", + "already_configured": "Account is already configured", + "cannot_connect": "Failed to connect", + "unknown": "Unexpected error" + }, + "abort": { + "invalid_auth": "Invalid authentication", + "already_configured": "Account is already configured", + "reauth_successful": "Re-authentication was successful" } -} \ No newline at end of file + }, + "options": { + "step": { + "init": { + "title": "Account Options", + "data": { + "limit_gps_acc": "Limit GPS accuracy", + "max_gps_accuracy": "Max GPS accuracy (meters)", + "set_drive_speed": "Set driving speed threshold", + "driving_speed": "Driving speed", + "driving": "Show driving as state" + } + } + } + } +} diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f579b9395d9..94d59b40526 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -672,6 +672,9 @@ librouteros==3.2.0 # homeassistant.components.soundtouch libsoundtouch==0.8 +# homeassistant.components.life360 +life360==4.1.1 + # homeassistant.components.logi_circle logi_circle==0.2.3 diff --git a/tests/components/life360/__init__.py b/tests/components/life360/__init__.py new file mode 100644 index 00000000000..0f68b4a343c --- /dev/null +++ b/tests/components/life360/__init__.py @@ -0,0 +1 @@ +"""Tests for the Life360 integration.""" diff --git a/tests/components/life360/test_config_flow.py b/tests/components/life360/test_config_flow.py new file mode 100644 index 00000000000..0b5b850ac23 --- /dev/null +++ b/tests/components/life360/test_config_flow.py @@ -0,0 +1,309 @@ +"""Test the Life360 config flow.""" + +from unittest.mock import patch + +from life360 import Life360Error, LoginError +import pytest +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.life360.const import ( + CONF_AUTHORIZATION, + CONF_DRIVING_SPEED, + CONF_MAX_GPS_ACCURACY, + DEFAULT_OPTIONS, + DOMAIN, + SHOW_DRIVING, +) +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +TEST_USER = "Test@Test.com" +TEST_PW = "password" +TEST_PW_3 = "password_3" +TEST_AUTHORIZATION = "authorization_string" +TEST_AUTHORIZATION_2 = "authorization_string_2" +TEST_AUTHORIZATION_3 = "authorization_string_3" +TEST_MAX_GPS_ACCURACY = "300" +TEST_DRIVING_SPEED = "18" +TEST_SHOW_DRIVING = True + +USER_INPUT = {CONF_USERNAME: TEST_USER, CONF_PASSWORD: TEST_PW} + +TEST_CONFIG_DATA = { + CONF_USERNAME: TEST_USER, + CONF_PASSWORD: TEST_PW, + CONF_AUTHORIZATION: TEST_AUTHORIZATION, +} +TEST_CONFIG_DATA_2 = { + CONF_USERNAME: TEST_USER, + CONF_PASSWORD: TEST_PW, + CONF_AUTHORIZATION: TEST_AUTHORIZATION_2, +} +TEST_CONFIG_DATA_3 = { + CONF_USERNAME: TEST_USER, + CONF_PASSWORD: TEST_PW_3, + CONF_AUTHORIZATION: TEST_AUTHORIZATION_3, +} + +USER_OPTIONS = { + "limit_gps_acc": True, + CONF_MAX_GPS_ACCURACY: TEST_MAX_GPS_ACCURACY, + "set_drive_speed": True, + CONF_DRIVING_SPEED: TEST_DRIVING_SPEED, + SHOW_DRIVING: TEST_SHOW_DRIVING, +} +TEST_OPTIONS = { + CONF_MAX_GPS_ACCURACY: float(TEST_MAX_GPS_ACCURACY), + CONF_DRIVING_SPEED: float(TEST_DRIVING_SPEED), + SHOW_DRIVING: TEST_SHOW_DRIVING, +} + + +# ========== Common Fixtures & Functions =============================================== + + +@pytest.fixture(name="life360", autouse=True) +def life360_fixture(): + """Mock life360 config entry setup & unload.""" + with patch( + "homeassistant.components.life360.async_setup_entry", return_value=True + ), patch("homeassistant.components.life360.async_unload_entry", return_value=True): + yield + + +@pytest.fixture +def life360_api(): + """Mock Life360 api.""" + with patch("homeassistant.components.life360.config_flow.Life360") as mock: + yield mock.return_value + + +def create_config_entry(hass, state=None): + """Create mock config entry.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=TEST_CONFIG_DATA, + version=1, + state=state, + options=DEFAULT_OPTIONS, + unique_id=TEST_USER.lower(), + ) + config_entry.add_to_hass(hass) + return config_entry + + +# ========== User Flow Tests =========================================================== + + +async def test_user_show_form(hass, life360_api): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_not_called() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert not result["errors"] + + schema = result["data_schema"].schema + assert set(schema) == set(USER_INPUT) + # username and password fields should be empty. + keys = list(schema) + for key in USER_INPUT: + assert keys[keys.index(key)].default == vol.UNDEFINED + + +async def test_user_config_flow_success(hass, life360_api): + """Test a successful user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.return_value = TEST_AUTHORIZATION + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == TEST_USER.lower() + assert result["data"] == TEST_CONFIG_DATA + assert result["options"] == DEFAULT_OPTIONS + + +@pytest.mark.parametrize( + "exception,error", [(LoginError, "invalid_auth"), (Life360Error, "cannot_connect")] +) +async def test_user_config_flow_error(hass, life360_api, caplog, exception, error): + """Test a user config flow with an error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.side_effect = exception("test reason") + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] + assert result["errors"]["base"] == error + + assert "test reason" in caplog.text + + schema = result["data_schema"].schema + assert set(schema) == set(USER_INPUT) + # username and password fields should be prefilled with current values. + keys = list(schema) + for key, val in USER_INPUT.items(): + default = keys[keys.index(key)].default + assert default != vol.UNDEFINED + assert default() == val + + +async def test_user_config_flow_already_configured(hass, life360_api): + """Test a user config flow with an account already configured.""" + create_config_entry(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], USER_INPUT + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_not_called() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +# ========== Reauth Flow Tests ========================================================= + + +@pytest.mark.parametrize("state", [None, config_entries.ConfigEntryState.LOADED]) +async def test_reauth_config_flow_success(hass, life360_api, caplog, state): + """Test a successful reauthorization config flow.""" + config_entry = create_config_entry(hass, state=state) + + # Simulate current username & password are still valid, but authorization string has + # expired, such that getting a new authorization string from server is successful. + life360_api.get_authorization.return_value = TEST_AUTHORIZATION_2 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + "title_placeholders": {"name": config_entry.title}, + "unique_id": config_entry.unique_id, + }, + data=config_entry.data, + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert "Reauthorization successful" in caplog.text + + assert config_entry.data == TEST_CONFIG_DATA_2 + + +async def test_reauth_config_flow_login_error(hass, life360_api, caplog): + """Test a reauthorization config flow with a login error.""" + config_entry = create_config_entry(hass) + + # Simulate current username & password are invalid, which results in a form + # requesting new password, with old password as default value. + life360_api.get_authorization.side_effect = LoginError("test reason") + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + "title_placeholders": {"name": config_entry.title}, + "unique_id": config_entry.unique_id, + }, + data=config_entry.data, + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] + assert result["errors"]["base"] == "invalid_auth" + + assert "test reason" in caplog.text + + schema = result["data_schema"].schema + assert len(schema) == 1 + assert "password" in schema + key = list(schema)[0] + assert key.default() == TEST_PW + + # Simulate getting a new, valid password. + life360_api.get_authorization.reset_mock(side_effect=True) + life360_api.get_authorization.return_value = TEST_AUTHORIZATION_3 + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_PASSWORD: TEST_PW_3} + ) + await hass.async_block_till_done() + + life360_api.get_authorization.assert_called_once() + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + + assert "Reauthorization successful" in caplog.text + + assert config_entry.data == TEST_CONFIG_DATA_3 + + +# ========== Option flow Tests ========================================================= + + +async def test_options_flow(hass): + """Test an options flow.""" + config_entry = create_config_entry(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + result = await hass.config_entries.options.async_init(config_entry.entry_id) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "init" + assert not result["errors"] + + schema = result["data_schema"].schema + assert set(schema) == set(USER_OPTIONS) + + flow_id = result["flow_id"] + + result = await hass.config_entries.options.async_configure(flow_id, USER_OPTIONS) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["data"] == TEST_OPTIONS + + assert config_entry.options == TEST_OPTIONS From d6e9118f36ff43055b2148744b304a5208398496 Mon Sep 17 00:00:00 2001 From: Jeef Date: Wed, 29 Jun 2022 11:01:18 -0600 Subject: [PATCH 874/947] IntelliFire DHCP Discovery Patch (#72617) Co-authored-by: J. Nick Koston --- homeassistant/components/intellifire/config_flow.py | 12 ++++++++---- homeassistant/components/intellifire/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/intellifire/config_flow.py b/homeassistant/components/intellifire/config_flow.py index 23bd92b0715..4556668b702 100644 --- a/homeassistant/components/intellifire/config_flow.py +++ b/homeassistant/components/intellifire/config_flow.py @@ -31,15 +31,16 @@ class DiscoveredHostInfo: serial: str | None -async def validate_host_input(host: str) -> str: +async def validate_host_input(host: str, dhcp_mode: bool = False) -> str: """Validate the user input allows us to connect. Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ LOGGER.debug("Instantiating IntellifireAPI with host: [%s]", host) api = IntellifireAPILocal(fireplace_ip=host) - await api.poll() + await api.poll(supress_warnings=dhcp_mode) serial = api.data.serial + LOGGER.debug("Found a fireplace: %s", serial) # Return the serial number which will be used to calculate a unique ID for the device/sensors return serial @@ -240,14 +241,17 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_dhcp(self, discovery_info: DhcpServiceInfo) -> FlowResult: """Handle DHCP Discovery.""" - LOGGER.debug("STEP: dhcp") # Run validation logic on ip host = discovery_info.ip + LOGGER.debug("STEP: dhcp for host %s", host) self._async_abort_entries_match({CONF_HOST: host}) try: - self._serial = await validate_host_input(host) + self._serial = await validate_host_input(host, dhcp_mode=True) except (ConnectionError, ClientConnectionError): + LOGGER.debug( + "DHCP Discovery has determined %s is not an IntelliFire device", host + ) return self.async_abort(reason="not_intellifire_device") await self.async_set_unique_id(self._serial) diff --git a/homeassistant/components/intellifire/manifest.json b/homeassistant/components/intellifire/manifest.json index cd1a12a36bf..e2ae4bb8abe 100644 --- a/homeassistant/components/intellifire/manifest.json +++ b/homeassistant/components/intellifire/manifest.json @@ -3,7 +3,7 @@ "name": "IntelliFire", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/intellifire", - "requirements": ["intellifire4py==2.0.0"], + "requirements": ["intellifire4py==2.0.1"], "codeowners": ["@jeeftor"], "iot_class": "local_polling", "loggers": ["intellifire4py"], diff --git a/requirements_all.txt b/requirements_all.txt index 064f686dc49..e67cd8dc3de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -894,7 +894,7 @@ influxdb==5.3.1 insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire -intellifire4py==2.0.0 +intellifire4py==2.0.1 # homeassistant.components.iotawatt iotawattpy==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94d59b40526..9b9681b38bb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -637,7 +637,7 @@ influxdb==5.3.1 insteon-frontend-home-assistant==0.1.1 # homeassistant.components.intellifire -intellifire4py==2.0.0 +intellifire4py==2.0.1 # homeassistant.components.iotawatt iotawattpy==0.1.0 From 4d673278c7a6405a21201015490c8d1dc433b872 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 29 Jun 2022 19:09:52 +0200 Subject: [PATCH 875/947] Fix color transition when turning on a ZHA light (#74024) * Initial implementation of fixing color transition when turning on a ZHA light * Add off_with_transition attribute, very slightly cleanup * Fix unnecessarily using last off_brightness when just turning on light Now it uses the Zigbee on_off call again if possible (instead of always move_to_level_with_on_off) * Use DEFAULT_TRANSITION constant for color transition, add DEFAULT_MIN_BRIGHTNESS constant * Add _DEFAULT_COLOR_FROM_OFF_TRANSITION = 0 but override transition for Sengled lights to 0.1s --- homeassistant/components/zha/light.py | 93 ++++++++++++++++++++++++--- 1 file changed, 83 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2f3379fa6b1..309fdf2699b 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -74,6 +74,7 @@ CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 DEFAULT_TRANSITION = 1 +DEFAULT_MIN_BRIGHTNESS = 2 UPDATE_COLORLOOP_ACTION = 0x1 UPDATE_COLORLOOP_DIRECTION = 0x2 @@ -118,12 +119,14 @@ class BaseLight(LogMixin, light.LightEntity): """Operations common to all light entities.""" _FORCE_ON = False + _DEFAULT_COLOR_FROM_OFF_TRANSITION = 0 def __init__(self, *args, **kwargs): """Initialize the light.""" super().__init__(*args, **kwargs) self._available: bool = False self._brightness: int | None = None + self._off_with_transition: bool = False self._off_brightness: int | None = None self._hs_color: tuple[float, float] | None = None self._color_temp: int | None = None @@ -143,7 +146,10 @@ class BaseLight(LogMixin, light.LightEntity): @property def extra_state_attributes(self) -> dict[str, Any]: """Return state attributes.""" - attributes = {"off_brightness": self._off_brightness} + attributes = { + "off_with_transition": self._off_with_transition, + "off_brightness": self._off_brightness, + } return attributes @property @@ -224,17 +230,53 @@ class BaseLight(LogMixin, light.LightEntity): effect = kwargs.get(light.ATTR_EFFECT) flash = kwargs.get(light.ATTR_FLASH) - if brightness is None and self._off_brightness is not None: + # If the light is currently off but a turn_on call with a color/temperature is sent, + # the light needs to be turned on first at a low brightness level where the light is immediately transitioned + # to the correct color. Afterwards, the transition is only from the low brightness to the new brightness. + # Otherwise, the transition is from the color the light had before being turned on to the new color. + # This can look especially bad with transitions longer than a second. + color_provided_from_off = ( + not self._state + and brightness_supported(self._attr_supported_color_modes) + and (light.ATTR_COLOR_TEMP in kwargs or light.ATTR_HS_COLOR in kwargs) + ) + final_duration = duration + if color_provided_from_off: + # Set the duration for the color changing commands to 0. + duration = 0 + + if ( + brightness is None + and (self._off_with_transition or color_provided_from_off) + and self._off_brightness is not None + ): brightness = self._off_brightness + if brightness is not None: + level = min(254, brightness) + else: + level = self._brightness or 254 + t_log = {} - if (brightness is not None or transition) and brightness_supported( - self._attr_supported_color_modes + + if color_provided_from_off: + # If the light is currently off, we first need to turn it on at a low brightness level with no transition. + # After that, we set it to the desired color/temperature with no transition. + result = await self._level_channel.move_to_level_with_on_off( + DEFAULT_MIN_BRIGHTNESS, self._DEFAULT_COLOR_FROM_OFF_TRANSITION + ) + t_log["move_to_level_with_on_off"] = result + if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + self.debug("turned on: %s", t_log) + return + # Currently only setting it to "on", as the correct level state will be set at the second move_to_level call + self._state = True + + if ( + (brightness is not None or transition) + and not color_provided_from_off + and brightness_supported(self._attr_supported_color_modes) ): - if brightness is not None: - level = min(254, brightness) - else: - level = self._brightness or 254 result = await self._level_channel.move_to_level_with_on_off( level, duration ) @@ -246,7 +288,11 @@ class BaseLight(LogMixin, light.LightEntity): if level: self._brightness = level - if brightness is None or (self._FORCE_ON and brightness): + if ( + brightness is None + and not color_provided_from_off + or (self._FORCE_ON and brightness) + ): # since some lights don't always turn on with move_to_level_with_on_off, # we should call the on command on the on_off cluster if brightness is not 0. result = await self._on_off_channel.on() @@ -255,6 +301,7 @@ class BaseLight(LogMixin, light.LightEntity): self.debug("turned on: %s", t_log) return self._state = True + if light.ATTR_COLOR_TEMP in kwargs: temperature = kwargs[light.ATTR_COLOR_TEMP] result = await self._color_channel.move_to_color_temp(temperature, duration) @@ -280,6 +327,17 @@ class BaseLight(LogMixin, light.LightEntity): self._hs_color = hs_color self._color_temp = None + if color_provided_from_off: + # The light is has the correct color, so we can now transition it to the correct brightness level. + result = await self._level_channel.move_to_level(level, final_duration) + t_log["move_to_level_if_color"] = result + if isinstance(result, Exception) or result[1] is not Status.SUCCESS: + self.debug("turned on: %s", t_log) + return + self._state = bool(level) + if level: + self._brightness = level + if effect == light.EFFECT_COLORLOOP: result = await self._color_channel.color_loop_set( UPDATE_COLORLOOP_ACTION @@ -311,6 +369,7 @@ class BaseLight(LogMixin, light.LightEntity): ) t_log["trigger_effect"] = result + self._off_with_transition = False self._off_brightness = None self.debug("turned on: %s", t_log) self.async_write_ha_state() @@ -331,8 +390,9 @@ class BaseLight(LogMixin, light.LightEntity): return self._state = False - if duration and supports_level: + if supports_level: # store current brightness so that the next turn_on uses it. + self._off_with_transition = bool(duration) self._off_brightness = self._brightness self.async_write_ha_state() @@ -453,6 +513,8 @@ class Light(BaseLight, ZhaEntity): self._state = last_state.state == STATE_ON if "brightness" in last_state.attributes: self._brightness = last_state.attributes["brightness"] + if "off_with_transition" in last_state.attributes: + self._off_with_transition = last_state.attributes["off_with_transition"] if "off_brightness" in last_state.attributes: self._off_brightness = last_state.attributes["off_brightness"] if "color_mode" in last_state.attributes: @@ -556,6 +618,17 @@ class ForceOnLight(Light): _FORCE_ON = True +@STRICT_MATCH( + channel_names=CHANNEL_ON_OFF, + aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}, + manufacturers={"Sengled"}, +) +class SengledLight(Light): + """Representation of a Sengled light which does not react to move_to_color_temp with 0 as a transition.""" + + _DEFAULT_COLOR_FROM_OFF_TRANSITION = 1 + + @GROUP_MATCH() class LightGroup(BaseLight, ZhaGroupEntity): """Representation of a light group.""" From 466ba47b359d46f1fb03a32aa634c06d936de15c Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Wed, 29 Jun 2022 12:10:21 -0500 Subject: [PATCH 876/947] Frontend bump to 20220629.0 (#74180) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b7c1c0ddeff..23f056ba0da 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220624.0"], + "requirements": ["home-assistant-frontend==20220629.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9f849146fe3..11ffd9c4c0c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220624.0 +home-assistant-frontend==20220629.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index e67cd8dc3de..98a6c6f5a95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -828,7 +828,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220624.0 +home-assistant-frontend==20220629.0 # homeassistant.components.home_connect homeconnect==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b9681b38bb..64ad3f769b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -595,7 +595,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220624.0 +home-assistant-frontend==20220629.0 # homeassistant.components.home_connect homeconnect==0.7.1 From 78fe1fb102bc5b514406e0fc9a361ac6959e517d Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 29 Jun 2022 19:29:36 +0200 Subject: [PATCH 877/947] Bumped version to 2022.7.0b0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 698f6bee240..df160e1cc6b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index 59df3967b6e..2cf2db3f240 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.0.dev0" +version = "2022.7.0b0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 71c2f99ee4d1562546cbf3144ec6c0af48b56a13 Mon Sep 17 00:00:00 2001 From: MasonCrawford Date: Fri, 1 Jul 2022 01:00:39 +0800 Subject: [PATCH 878/947] Add config flow to lg_soundbar (#71153) Co-authored-by: Paulus Schoutsen --- .../components/discovery/__init__.py | 2 +- .../components/lg_soundbar/__init__.py | 37 ++++++++ .../components/lg_soundbar/config_flow.py | 78 +++++++++++++++ homeassistant/components/lg_soundbar/const.py | 4 + .../components/lg_soundbar/manifest.json | 3 +- .../components/lg_soundbar/media_player.py | 51 +++++----- .../components/lg_soundbar/strings.json | 18 ++++ .../lg_soundbar/translations/en.json | 18 ++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/lg_soundbar/__init__.py | 1 + .../lg_soundbar/test_config_flow.py | 95 +++++++++++++++++++ 13 files changed, 284 insertions(+), 29 deletions(-) create mode 100644 homeassistant/components/lg_soundbar/config_flow.py create mode 100644 homeassistant/components/lg_soundbar/const.py create mode 100644 homeassistant/components/lg_soundbar/strings.json create mode 100644 homeassistant/components/lg_soundbar/translations/en.json create mode 100644 tests/components/lg_soundbar/__init__.py create mode 100644 tests/components/lg_soundbar/test_config_flow.py diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index a0ffbf235ab..3c3538c1ca0 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -63,7 +63,6 @@ SERVICE_HANDLERS = { "openhome": ServiceDetails("media_player", "openhome"), "bose_soundtouch": ServiceDetails("media_player", "soundtouch"), "bluesound": ServiceDetails("media_player", "bluesound"), - "lg_smart_device": ServiceDetails("media_player", "lg_soundbar"), } OPTIONAL_SERVICE_HANDLERS: dict[str, tuple[str, str | None]] = {} @@ -98,6 +97,7 @@ MIGRATED_SERVICE_HANDLERS = [ SERVICE_YEELIGHT, SERVICE_SABNZBD, "nanoleaf_aurora", + "lg_smart_device", ] DEFAULT_ENABLED = ( diff --git a/homeassistant/components/lg_soundbar/__init__.py b/homeassistant/components/lg_soundbar/__init__.py index 175153556f9..75b2109b22a 100644 --- a/homeassistant/components/lg_soundbar/__init__.py +++ b/homeassistant/components/lg_soundbar/__init__.py @@ -1 +1,38 @@ """The lg_soundbar component.""" +import logging + +from homeassistant import config_entries, core +from homeassistant.const import CONF_HOST, CONF_PORT, Platform +from homeassistant.exceptions import ConfigEntryNotReady + +from .config_flow import test_connect +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Set up platform from a ConfigEntry.""" + hass.data.setdefault(DOMAIN, {}) + # Verify the device is reachable with the given config before setting up the platform + try: + await hass.async_add_executor_job( + test_connect, entry.data[CONF_HOST], entry.data[CONF_PORT] + ) + except ConnectionError as err: + raise ConfigEntryNotReady from err + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +) -> bool: + """Unload a config entry.""" + result = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + return result diff --git a/homeassistant/components/lg_soundbar/config_flow.py b/homeassistant/components/lg_soundbar/config_flow.py new file mode 100644 index 00000000000..bd9a727d1f4 --- /dev/null +++ b/homeassistant/components/lg_soundbar/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow to configure the LG Soundbar integration.""" +from queue import Queue +import socket + +import temescal +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import DEFAULT_PORT, DOMAIN + +DATA_SCHEMA = { + vol.Required(CONF_HOST): str, +} + + +def test_connect(host, port): + """LG Soundbar config flow test_connect.""" + uuid_q = Queue(maxsize=1) + name_q = Queue(maxsize=1) + + def msg_callback(response): + if response["msg"] == "MAC_INFO_DEV" and "s_uuid" in response["data"]: + uuid_q.put_nowait(response["data"]["s_uuid"]) + if ( + response["msg"] == "SPK_LIST_VIEW_INFO" + and "s_user_name" in response["data"] + ): + name_q.put_nowait(response["data"]["s_user_name"]) + + try: + connection = temescal.temescal(host, port=port, callback=msg_callback) + connection.get_mac_info() + connection.get_info() + details = {"name": name_q.get(timeout=10), "uuid": uuid_q.get(timeout=10)} + return details + except socket.timeout as err: + raise ConnectionError(f"Connection timeout with server: {host}:{port}") from err + except OSError as err: + raise ConnectionError(f"Cannot resolve hostname: {host}") from err + + +class LGSoundbarConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """LG Soundbar config flow.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_form() + + errors = {} + try: + details = await self.hass.async_add_executor_job( + test_connect, user_input[CONF_HOST], DEFAULT_PORT + ) + except ConnectionError: + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(details["uuid"]) + self._abort_if_unique_id_configured() + info = { + CONF_HOST: user_input[CONF_HOST], + CONF_PORT: DEFAULT_PORT, + } + return self.async_create_entry(title=details["name"], data=info) + + return self._show_form(errors) + + def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema(DATA_SCHEMA), + errors=errors if errors else {}, + ) diff --git a/homeassistant/components/lg_soundbar/const.py b/homeassistant/components/lg_soundbar/const.py new file mode 100644 index 00000000000..c71e43c0d60 --- /dev/null +++ b/homeassistant/components/lg_soundbar/const.py @@ -0,0 +1,4 @@ +"""Constants for the LG Soundbar integration.""" +DOMAIN = "lg_soundbar" + +DEFAULT_PORT = 9741 diff --git a/homeassistant/components/lg_soundbar/manifest.json b/homeassistant/components/lg_soundbar/manifest.json index f40ad1d194c..c05174a8938 100644 --- a/homeassistant/components/lg_soundbar/manifest.json +++ b/homeassistant/components/lg_soundbar/manifest.json @@ -1,8 +1,9 @@ { "domain": "lg_soundbar", + "config_flow": true, "name": "LG Soundbars", "documentation": "https://www.home-assistant.io/integrations/lg_soundbar", - "requirements": ["temescal==0.3"], + "requirements": ["temescal==0.5"], "codeowners": [], "iot_class": "local_polling", "loggers": ["temescal"] diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py index 569678c8c15..f8f6fcf26fd 100644 --- a/homeassistant/components/lg_soundbar/media_player.py +++ b/homeassistant/components/lg_soundbar/media_player.py @@ -7,26 +7,33 @@ from homeassistant.components.media_player import ( MediaPlayerEntity, MediaPlayerEntityFeature, ) -from homeassistant.const import STATE_ON +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: - """Set up the LG platform.""" - if discovery_info is not None: - add_entities([LGDevice(discovery_info)]) + """Set up media_player from a config entry created in the integrations UI.""" + async_add_entities( + [ + LGDevice( + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.unique_id, + ) + ] + ) class LGDevice(MediaPlayerEntity): """Representation of an LG soundbar device.""" + _attr_should_poll = False _attr_supported_features = ( MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.VOLUME_MUTE @@ -34,13 +41,13 @@ class LGDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOUND_MODE ) - def __init__(self, discovery_info): + def __init__(self, host, port, unique_id): """Initialize the LG speakers.""" - self._host = discovery_info["host"] - self._port = discovery_info["port"] - self._hostname = discovery_info["hostname"] + self._host = host + self._port = port + self._attr_unique_id = unique_id - self._name = self._hostname.split(".")[0] + self._name = None self._volume = 0 self._volume_min = 0 self._volume_max = 0 @@ -68,6 +75,8 @@ class LGDevice(MediaPlayerEntity): self._device = temescal.temescal( self._host, port=self._port, callback=self.handle_event ) + self._device.get_product_info() + self._device.get_mac_info() self.update() def handle_event(self, response): @@ -116,7 +125,8 @@ class LGDevice(MediaPlayerEntity): if "i_curr_eq" in data: self._equaliser = data["i_curr_eq"] if "s_user_name" in data: - self._name = data["s_user_name"] + self._attr_name = data["s_user_name"] + self.schedule_update_ha_state() def update(self): @@ -125,17 +135,6 @@ class LGDevice(MediaPlayerEntity): self._device.get_info() self._device.get_func() self._device.get_settings() - self._device.get_product_info() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the device.""" - return self._name @property def volume_level(self): diff --git a/homeassistant/components/lg_soundbar/strings.json b/homeassistant/components/lg_soundbar/strings.json new file mode 100644 index 00000000000..ef7bf32a051 --- /dev/null +++ b/homeassistant/components/lg_soundbar/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "abort": { + "existing_instance_updated": "Updated existing configuration.", + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/lg_soundbar/translations/en.json b/homeassistant/components/lg_soundbar/translations/en.json new file mode 100644 index 00000000000..a646279203f --- /dev/null +++ b/homeassistant/components/lg_soundbar/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured", + "existing_instance_updated": "Updated existing configuration." + }, + "error": { + "cannot_connect": "Failed to connect" + }, + "step": { + "user": { + "data": { + "host": "Host" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3c6ad94a21f..af4b8481873 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -189,6 +189,7 @@ FLOWS = { "kulersky", "launch_library", "laundrify", + "lg_soundbar", "life360", "lifx", "litejet", diff --git a/requirements_all.txt b/requirements_all.txt index 98a6c6f5a95..6f677d1d092 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2289,7 +2289,7 @@ tellcore-py==1.1.2 tellduslive==0.10.11 # homeassistant.components.lg_soundbar -temescal==0.3 +temescal==0.5 # homeassistant.components.temper temperusb==1.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 64ad3f769b9..d0c9bc54ddd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1521,6 +1521,9 @@ tailscale==0.2.0 # homeassistant.components.tellduslive tellduslive==0.10.11 +# homeassistant.components.lg_soundbar +temescal==0.5 + # homeassistant.components.powerwall tesla-powerwall==0.3.18 diff --git a/tests/components/lg_soundbar/__init__.py b/tests/components/lg_soundbar/__init__.py new file mode 100644 index 00000000000..8756d343130 --- /dev/null +++ b/tests/components/lg_soundbar/__init__.py @@ -0,0 +1 @@ +"""Tests for the lg_soundbar component.""" diff --git a/tests/components/lg_soundbar/test_config_flow.py b/tests/components/lg_soundbar/test_config_flow.py new file mode 100644 index 00000000000..3fafc2c7628 --- /dev/null +++ b/tests/components/lg_soundbar/test_config_flow.py @@ -0,0 +1,95 @@ +"""Test the lg_soundbar config flow.""" +from unittest.mock import MagicMock, patch + +from homeassistant import config_entries +from homeassistant.components.lg_soundbar.const import DEFAULT_PORT, DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_form(hass): + """Test we get the form.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.lg_soundbar.config_flow.temescal", + return_value=MagicMock(), + ), patch( + "homeassistant.components.lg_soundbar.config_flow.test_connect", + return_value={"uuid": "uuid", "name": "name"}, + ), patch( + "homeassistant.components.lg_soundbar.async_setup_entry", return_value=True + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "name" + assert result2["data"] == { + CONF_HOST: "1.1.1.1", + CONF_PORT: DEFAULT_PORT, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.lg_soundbar.config_flow.test_connect", + side_effect=ConnectionError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_already_configured(hass): + """Test we handle already configured error.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "1.1.1.1", + CONF_PORT: 0000, + }, + unique_id="uuid", + ) + mock_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.lg_soundbar.config_flow.test_connect", + return_value={"uuid": "uuid", "name": "name"}, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: "1.1.1.1", + }, + ) + + assert result2["type"] == "abort" + assert result2["reason"] == "already_configured" From 7690ecc4ff533ec333dd276c867c04fc9be9a9cb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Jun 2022 03:43:14 +0200 Subject: [PATCH 879/947] Fix clicksend request content type headers (#74189) --- homeassistant/components/clicksend/notify.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py index 74f1c2e1ae5..ec6bed3c55d 100644 --- a/homeassistant/components/clicksend/notify.py +++ b/homeassistant/components/clicksend/notify.py @@ -3,7 +3,6 @@ from http import HTTPStatus import json import logging -from aiohttp.hdrs import CONTENT_TYPE import requests import voluptuous as vol @@ -23,7 +22,7 @@ BASE_API_URL = "https://rest.clicksend.com/v3" DEFAULT_SENDER = "hass" TIMEOUT = 5 -HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON} +HEADERS = {"Content-Type": CONTENT_TYPE_JSON} PLATFORM_SCHEMA = vol.Schema( From 9d727d2a710c76249b88ceae4a85f4cdde072c8f Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Thu, 30 Jun 2022 02:10:25 +0300 Subject: [PATCH 880/947] Fix Shelly Duo RGBW color mode attribute (#74193) --- homeassistant/components/shelly/light.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py index 79db9c509f4..b75e1ad2377 100644 --- a/homeassistant/components/shelly/light.py +++ b/homeassistant/components/shelly/light.py @@ -215,7 +215,7 @@ class BlockShellyLight(ShellyBlockEntity, LightEntity): def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.mode == "color": - if hasattr(self.block, "white"): + if self.wrapper.model in RGBW_MODELS: return ColorMode.RGBW return ColorMode.RGB From d36643947d43683c53fb2e26a33340b4e9f6547f Mon Sep 17 00:00:00 2001 From: Christopher Bailey Date: Wed, 29 Jun 2022 18:52:54 -0400 Subject: [PATCH 881/947] Fix duplicate key for motion sensor for UniFi Protect (#74202) --- homeassistant/components/unifiprotect/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index d3bf71a4274..62a4893692b 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -150,7 +150,7 @@ CAMERA_SENSORS: tuple[ProtectBinaryEntityDescription, ...] = ( ufp_perm=PermRequired.NO_WRITE, ), ProtectBinaryEntityDescription( - key="motion", + key="motion_enabled", name="Detections: Motion", icon="mdi:run-fast", ufp_value="recording_settings.enable_motion_detection", From dbe552b1a1ea29aed1add941ac05637330213da3 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 1 Jul 2022 05:07:03 +1200 Subject: [PATCH 882/947] ESPHome use dhcp responses to update connection host of known devices (#74206) * ESPHome use dhcp responses to update connection host of known devices * Add test for dhcp * Add another test to cover when there are no changes required --- .../components/esphome/config_flow.py | 45 +++++++++++++- .../components/esphome/manifest.json | 1 + homeassistant/generated/dhcp.py | 1 + tests/components/esphome/test_config_flow.py | 60 ++++++++++++++++++- 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 76359cda4e7..ecfa381bc69 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -16,7 +16,7 @@ from aioesphomeapi import ( ) import voluptuous as vol -from homeassistant.components import zeroconf +from homeassistant.components import dhcp, zeroconf from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback @@ -189,6 +189,49 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN): return await self.async_step_discovery_confirm() + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: + """Handle DHCP discovery.""" + node_name = discovery_info.hostname + + await self.async_set_unique_id(node_name) + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) + + for entry in self._async_current_entries(): + found = False + + if CONF_HOST in entry.data and entry.data[CONF_HOST] in ( + discovery_info.ip, + f"{node_name}.local", + ): + # Is this address or IP address already configured? + found = True + elif DomainData.get(self.hass).is_entry_loaded(entry): + # Does a config entry with this name already exist? + data = DomainData.get(self.hass).get_entry_data(entry) + + # Node names are unique in the network + if data.device_info is not None: + found = data.device_info.name == node_name + + if found: + # Backwards compat, we update old entries + if not entry.unique_id: + self.hass.config_entries.async_update_entry( + entry, + data={ + **entry.data, + CONF_HOST: discovery_info.ip, + }, + unique_id=node_name, + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload(entry.entry_id) + ) + + break + + return self.async_abort(reason="already_configured") + @callback def _async_get_entry(self) -> FlowResult: config_data = { diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index b89671c6f90..a8a76c2b0c8 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -5,6 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/esphome", "requirements": ["aioesphomeapi==10.10.0"], "zeroconf": ["_esphomelib._tcp.local."], + "dhcp": [{ "registered_devices": true }], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], "iot_class": "local_push", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 91398ed00ef..e9cf6ca4c06 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -28,6 +28,7 @@ DHCP: list[dict[str, str | bool]] = [ {'domain': 'elkm1', 'macaddress': '00409D*'}, {'domain': 'emonitor', 'hostname': 'emonitor*', 'macaddress': '0090C2*'}, {'domain': 'emonitor', 'registered_devices': True}, + {'domain': 'esphome', 'registered_devices': True}, {'domain': 'flume', 'hostname': 'flume-gw-*'}, {'domain': 'flux_led', 'registered_devices': True}, {'domain': 'flux_led', 'hostname': '[ba][lk]*', 'macaddress': '18B905*'}, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index f7da5d66bd5..1d2cff051ae 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -12,7 +12,7 @@ from aioesphomeapi import ( import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf +from homeassistant.components import dhcp, zeroconf from homeassistant.components.esphome import CONF_NOISE_PSK, DOMAIN, DomainData from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.data_entry_flow import ( @@ -532,3 +532,61 @@ async def test_reauth_confirm_invalid(hass, mock_client, mock_zeroconf): assert result["step_id"] == "reauth_confirm" assert result["errors"] assert result["errors"]["base"] == "invalid_psk" + + +async def test_discovery_dhcp_updates_host(hass, mock_client): + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + mock_entry_data = MagicMock() + mock_entry_data.device_info.name = "test8266" + domain_data = DomainData.get(hass) + domain_data.set_entry_data(entry, mock_entry_data) + + service_info = dhcp.DhcpServiceInfo( + ip="192.168.43.184", + hostname="test8266", + macaddress="00:00:00:00:00:00", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + assert entry.unique_id == "test8266" + assert entry.data[CONF_HOST] == "192.168.43.184" + + +async def test_discovery_dhcp_no_changes(hass, mock_client): + """Test dhcp discovery updates host and aborts.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.168.43.183", CONF_PORT: 6053, CONF_PASSWORD: ""}, + ) + entry.add_to_hass(hass) + + mock_entry_data = MagicMock() + mock_entry_data.device_info.name = "test8266" + domain_data = DomainData.get(hass) + domain_data.set_entry_data(entry, mock_entry_data) + + service_info = dhcp.DhcpServiceInfo( + ip="192.168.43.183", + hostname="test8266", + macaddress="00:00:00:00:00:00", + ) + result = await hass.config_entries.flow.async_init( + "esphome", context={"source": config_entries.SOURCE_DHCP}, data=service_info + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + assert entry.unique_id == "test8266" + assert entry.data[CONF_HOST] == "192.168.43.183" From b135560274ba666e1a896008fdda5718f02bd84a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 29 Jun 2022 19:14:56 -0500 Subject: [PATCH 883/947] Allow tuple subclasses to be json serialized (#74207) --- homeassistant/helpers/json.py | 2 +- tests/helpers/test_json.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index 8b91f5eb2b5..74a2f542910 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -33,7 +33,7 @@ def json_encoder_default(obj: Any) -> Any: Hand other objects to the original method. """ - if isinstance(obj, set): + if isinstance(obj, (set, tuple)): return list(obj) if isinstance(obj, float): return float(obj) diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py index cfb403ca4a9..54c488690fa 100644 --- a/tests/helpers/test_json.py +++ b/tests/helpers/test_json.py @@ -1,6 +1,7 @@ """Test Home Assistant remote methods and classes.""" import datetime import json +import time import pytest @@ -87,3 +88,11 @@ def test_json_dumps_float_subclass(): """A float subclass.""" assert json_dumps({"c": FloatSubclass(1.2)}) == '{"c":1.2}' + + +def test_json_dumps_tuple_subclass(): + """Test the json dumps a tuple subclass.""" + + tt = time.struct_time((1999, 3, 17, 32, 44, 55, 2, 76, 0)) + + assert json_dumps(tt) == "[1999,3,17,32,44,55,2,76,0]" From 1e8c897702b2f642bdd09f3caf6dc445c9a3e160 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Jun 2022 03:40:58 +0200 Subject: [PATCH 884/947] Update requests to 2.28.1 (#74210) --- homeassistant/package_constraints.txt | 6 +----- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_test.txt | 2 +- script/gen_requirements_all.py | 4 ---- 5 files changed, 4 insertions(+), 12 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 11ffd9c4c0c..9ba945c3e2b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.22.0 pyyaml==6.0 -requests==2.28.0 +requests==2.28.1 scapy==2.4.5 sqlalchemy==1.4.38 typing-extensions>=3.10.0.2,<5.0 @@ -114,7 +114,3 @@ backoff<2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 pydantic!=1.9.1 - -# Pin charset-normalizer to 2.0.12 due to version conflict. -# https://github.com/home-assistant/core/pull/74104 -charset-normalizer==2.0.12 diff --git a/pyproject.toml b/pyproject.toml index 2cf2db3f240..69c5a539f3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,7 +45,7 @@ dependencies = [ "pip>=21.0,<22.2", "python-slugify==4.0.1", "pyyaml==6.0", - "requests==2.28.0", + "requests==2.28.1", "typing-extensions>=3.10.0.2,<5.0", "voluptuous==0.13.1", "voluptuous-serialize==2.5.0", diff --git a/requirements.txt b/requirements.txt index 7506201eae1..98b148fa923 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ orjson==3.7.5 pip>=21.0,<22.2 python-slugify==4.0.1 pyyaml==6.0 -requests==2.28.0 +requests==2.28.1 typing-extensions>=3.10.0.2,<5.0 voluptuous==0.13.1 voluptuous-serialize==2.5.0 diff --git a/requirements_test.txt b/requirements_test.txt index 046d8bfb400..6072ce896ee 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -42,6 +42,6 @@ types-pkg-resources==0.1.3 types-python-slugify==0.1.2 types-pytz==2021.1.2 types-PyYAML==5.4.6 -types-requests==2.27.30 +types-requests==2.28.0 types-toml==0.1.5 types-ujson==0.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index c6b50c6bd32..a2a0eab897a 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -132,10 +132,6 @@ backoff<2.0 # Breaking change in version # https://github.com/samuelcolvin/pydantic/issues/4092 pydantic!=1.9.1 - -# Pin charset-normalizer to 2.0.12 due to version conflict. -# https://github.com/home-assistant/core/pull/74104 -charset-normalizer==2.0.12 """ IGNORE_PRE_COMMIT_HOOK_ID = ( From f4df584f1326b5f19bbe6d120c5aa64075057b96 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Jun 2022 07:06:35 +0200 Subject: [PATCH 885/947] Fix input_number invalid state restore handling (#74213) Co-authored-by: J. Nick Koston --- .../components/input_number/__init__.py | 7 ++++-- tests/components/input_number/test_init.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index a6ee8dd0f7d..8e922687e59 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -1,6 +1,7 @@ """Support to set a numeric value from a slider or text box.""" from __future__ import annotations +from contextlib import suppress import logging import voluptuous as vol @@ -281,8 +282,10 @@ class InputNumber(RestoreEntity): if self._current_value is not None: return - state = await self.async_get_last_state() - value = state and float(state.state) + value: float | None = None + if state := await self.async_get_last_state(): + with suppress(ValueError): + value = float(state.state) # Check against None because value can be 0 if value is not None and self._minimum <= value <= self._maximum: diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index ca496723d99..4149627720b 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -255,6 +255,29 @@ async def test_restore_state(hass): assert float(state.state) == 10 +async def test_restore_invalid_state(hass): + """Ensure an invalid restore state is handled.""" + mock_restore_cache( + hass, (State("input_number.b1", "="), State("input_number.b2", "200")) + ) + + hass.state = CoreState.starting + + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"b1": {"min": 2, "max": 100}, "b2": {"min": 10, "max": 100}}}, + ) + + state = hass.states.get("input_number.b1") + assert state + assert float(state.state) == 2 + + state = hass.states.get("input_number.b2") + assert state + assert float(state.state) == 10 + + async def test_initial_state_overrules_restore_state(hass): """Ensure states are restored on startup.""" mock_restore_cache( From e3b99fe62ab5e16611df7a3740f26b28ad74deb5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jun 2022 10:00:10 -0700 Subject: [PATCH 886/947] Treat thermostat unknown state like unavailable in alexa (#74220) --- homeassistant/components/alexa/capabilities.py | 2 ++ tests/components/alexa/test_capabilities.py | 17 ++++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 818b4b794cf..25ec43b689c 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1046,6 +1046,8 @@ class AlexaThermostatController(AlexaCapability): if preset in API_THERMOSTAT_PRESETS: mode = API_THERMOSTAT_PRESETS[preset] + elif self.entity.state == STATE_UNKNOWN: + return None else: mode = API_THERMOSTAT_MODES.get(self.entity.state) if mode is None: diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index 3e176b0fb8c..ea6c96bbaef 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -658,13 +658,16 @@ async def test_report_climate_state(hass): "Alexa.TemperatureSensor", "temperature", {"value": 34.0, "scale": "CELSIUS"} ) - hass.states.async_set( - "climate.unavailable", - "unavailable", - {"friendly_name": "Climate Unavailable", "supported_features": 91}, - ) - properties = await reported_properties(hass, "climate.unavailable") - properties.assert_not_has_property("Alexa.ThermostatController", "thermostatMode") + for state in "unavailable", "unknown": + hass.states.async_set( + f"climate.{state}", + state, + {"friendly_name": f"Climate {state}", "supported_features": 91}, + ) + properties = await reported_properties(hass, f"climate.{state}") + properties.assert_not_has_property( + "Alexa.ThermostatController", "thermostatMode" + ) hass.states.async_set( "climate.unsupported", From b71205acd74588df57d4f8108ccd6cf7e1dd5f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pierre=20St=C3=A5hl?= Date: Thu, 30 Jun 2022 18:59:46 +0200 Subject: [PATCH 887/947] Make media_player.toggle turn on a standby device (#74221) --- homeassistant/components/media_player/__init__.py | 3 ++- .../components/media_player/test_async_helpers.py | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index dc2f3624a0e..14546a36ec8 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -52,6 +52,7 @@ from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PLAYING, + STATE_STANDBY, ) from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -888,7 +889,7 @@ class MediaPlayerEntity(Entity): await self.hass.async_add_executor_job(self.toggle) return - if self.state in (STATE_OFF, STATE_IDLE): + if self.state in (STATE_OFF, STATE_IDLE, STATE_STANDBY): await self.async_turn_on() else: await self.async_turn_off() diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py index 53c80bfc8de..8be263e7ee0 100644 --- a/tests/components/media_player/test_async_helpers.py +++ b/tests/components/media_player/test_async_helpers.py @@ -8,6 +8,7 @@ from homeassistant.const import ( STATE_ON, STATE_PAUSED, STATE_PLAYING, + STATE_STANDBY, ) @@ -79,9 +80,13 @@ class ExtendedMediaPlayer(mp.MediaPlayerEntity): """Turn off state.""" self._state = STATE_OFF + def standby(self): + """Put device in standby.""" + self._state = STATE_STANDBY + def toggle(self): """Toggle the power on the media player.""" - if self._state in [STATE_OFF, STATE_IDLE]: + if self._state in [STATE_OFF, STATE_IDLE, STATE_STANDBY]: self._state = STATE_ON else: self._state = STATE_OFF @@ -138,6 +143,10 @@ class SimpleMediaPlayer(mp.MediaPlayerEntity): """Turn off state.""" self._state = STATE_OFF + def standby(self): + """Put device in standby.""" + self._state = STATE_STANDBY + @pytest.fixture(params=[ExtendedMediaPlayer, SimpleMediaPlayer]) def player(hass, request): @@ -188,3 +197,7 @@ async def test_toggle(player): assert player.state == STATE_ON await player.async_toggle() assert player.state == STATE_OFF + player.standby() + assert player.state == STATE_STANDBY + await player.async_toggle() + assert player.state == STATE_ON From 518468a70bd2b423bab6590055affd9590210f73 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Wed, 29 Jun 2022 23:54:51 -0700 Subject: [PATCH 888/947] Allow legacy nest integration with no configuration.yaml (#74222) --- homeassistant/components/nest/__init__.py | 2 +- tests/components/nest/common.py | 12 +++++++++++- tests/components/nest/test_init_legacy.py | 5 ++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 0e0128136ad..b31354b598c 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -177,7 +177,7 @@ class SignalUpdateCallback: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Nest from a config entry with dispatch between old/new flows.""" config_mode = config_flow.get_config_mode(hass) - if config_mode == config_flow.ConfigMode.LEGACY: + if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) if config_mode == config_flow.ConfigMode.SDM: diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index 765a954b6de..f86112ada75 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -147,7 +147,17 @@ TEST_CONFIG_LEGACY = NestTestConfig( }, }, }, - credential=None, +) +TEST_CONFIG_ENTRY_LEGACY = NestTestConfig( + config_entry_data={ + "auth_implementation": "local", + "tokens": { + "expires_at": time.time() + 86400, + "access_token": { + "token": "some-token", + }, + }, + }, ) diff --git a/tests/components/nest/test_init_legacy.py b/tests/components/nest/test_init_legacy.py index cbf1bfe2d48..fc4e6070faf 100644 --- a/tests/components/nest/test_init_legacy.py +++ b/tests/components/nest/test_init_legacy.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, PropertyMock, patch import pytest -from .common import TEST_CONFIG_LEGACY +from .common import TEST_CONFIG_ENTRY_LEGACY, TEST_CONFIG_LEGACY DOMAIN = "nest" @@ -33,6 +33,9 @@ def make_thermostat(): return device +@pytest.mark.parametrize( + "nest_test_config", [TEST_CONFIG_LEGACY, TEST_CONFIG_ENTRY_LEGACY] +) async def test_thermostat(hass, setup_base_platform): """Test simple initialization for thermostat entities.""" From a36a2d53ecf1be8e567400c6b4f42b0bf616b87b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jun 2022 09:42:15 +0200 Subject: [PATCH 889/947] Correct native_pressure_unit for zamg weather (#74225) --- homeassistant/components/zamg/weather.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index 2bf4a5b39f6..6910955fcf7 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -18,7 +18,7 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - LENGTH_MILLIMETERS, + PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) @@ -87,9 +87,7 @@ def setup_platform( class ZamgWeather(WeatherEntity): """Representation of a weather condition.""" - _attr_native_pressure_unit = ( - LENGTH_MILLIMETERS # API reports l/m², equivalent to mm - ) + _attr_native_pressure_unit = PRESSURE_HPA _attr_native_temperature_unit = TEMP_CELSIUS _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR From e1fc2ed0466173daf165cfd869734f99deaef534 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 30 Jun 2022 19:15:25 +0200 Subject: [PATCH 890/947] Fire event_mqtt_reloaded only after reload is completed (#74226) --- homeassistant/components/mqtt/__init__.py | 70 +++++++++++-------- .../components/mqtt/alarm_control_panel.py | 6 +- .../components/mqtt/binary_sensor.py | 6 +- homeassistant/components/mqtt/button.py | 6 +- homeassistant/components/mqtt/camera.py | 6 +- homeassistant/components/mqtt/climate.py | 6 +- homeassistant/components/mqtt/const.py | 3 +- homeassistant/components/mqtt/cover.py | 6 +- homeassistant/components/mqtt/fan.py | 4 +- homeassistant/components/mqtt/humidifier.py | 6 +- .../components/mqtt/light/__init__.py | 6 +- homeassistant/components/mqtt/lock.py | 6 +- homeassistant/components/mqtt/mixins.py | 46 +++++------- homeassistant/components/mqtt/number.py | 6 +- homeassistant/components/mqtt/scene.py | 6 +- homeassistant/components/mqtt/select.py | 6 +- homeassistant/components/mqtt/sensor.py | 6 +- homeassistant/components/mqtt/siren.py | 6 +- homeassistant/components/mqtt/switch.py | 6 +- .../components/mqtt/vacuum/__init__.py | 6 +- 20 files changed, 96 insertions(+), 123 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 6fd288a86cf..a099e7b580c 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -28,14 +28,12 @@ from homeassistant.data_entry_flow import BaseServiceInfo from homeassistant.exceptions import TemplateError, Unauthorized from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.reload import ( async_integration_yaml_config, - async_setup_reload_service, + async_reload_integration_platforms, ) +from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow @@ -78,10 +76,10 @@ from .const import ( # noqa: F401 DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, - MQTT_RELOADED, PLATFORMS, RELOADABLE_PLATFORMS, ) +from .mixins import async_discover_yaml_entities from .models import ( # noqa: F401 MqttCommandTemplate, MqttValueTemplate, @@ -241,7 +239,9 @@ async def _async_config_entry_updated(hass: HomeAssistant, entry: ConfigEntry) - await _async_setup_discovery(hass, mqtt_client.conf, entry) -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: +async def async_setup_entry( # noqa: C901 + hass: HomeAssistant, entry: ConfigEntry +) -> bool: """Load a config entry.""" # Merge basic configuration, and add missing defaults for basic options _merge_basic_config(hass, entry, hass.data.get(DATA_MQTT_CONFIG, {})) @@ -378,16 +378,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock() hass.data[CONFIG_ENTRY_IS_SETUP] = set() - # Setup reload service. Once support for legacy config is removed in 2022.9, we - # should no longer call async_setup_reload_service but instead implement a custom - # service - await async_setup_reload_service(hass, DOMAIN, RELOADABLE_PLATFORMS) + async def async_setup_reload_service() -> None: + """Create the reload service for the MQTT domain.""" + if hass.services.has_service(DOMAIN, SERVICE_RELOAD): + return - async def _async_reload_platforms(_: Event | None) -> None: - """Discover entities for a platform.""" - config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} - hass.data[DATA_MQTT_UPDATED_CONFIG] = config_yaml.get(DOMAIN, {}) - async_dispatcher_send(hass, MQTT_RELOADED) + async def _reload_config(call: ServiceCall) -> None: + """Reload the platforms.""" + # Reload the legacy yaml platform + await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS) + + # Reload the modern yaml platforms + config_yaml = await async_integration_yaml_config(hass, DOMAIN) or {} + hass.data[DATA_MQTT_UPDATED_CONFIG] = config_yaml.get(DOMAIN, {}) + await asyncio.gather( + *( + [ + async_discover_yaml_entities(hass, component) + for component in RELOADABLE_PLATFORMS + ] + ) + ) + + # Fire event + hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context) + + async_register_admin_service(hass, DOMAIN, SERVICE_RELOAD, _reload_config) async def async_forward_entry_setup_and_setup_discovery(config_entry): """Forward the config entry setup to the platforms and set up discovery.""" @@ -411,21 +427,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if conf.get(CONF_DISCOVERY): await _async_setup_discovery(hass, conf, entry) # Setup reload service after all platforms have loaded - entry.async_on_unload( - hass.bus.async_listen("event_mqtt_reloaded", _async_reload_platforms) - ) + await async_setup_reload_service() + + if DATA_MQTT_RELOAD_NEEDED in hass.data: + hass.data.pop(DATA_MQTT_RELOAD_NEEDED) + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + {}, + blocking=False, + ) hass.async_create_task(async_forward_entry_setup_and_setup_discovery(entry)) - if DATA_MQTT_RELOAD_NEEDED in hass.data: - hass.data.pop(DATA_MQTT_RELOAD_NEEDED) - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=False, - ) - return True diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 8e5ee54d688..b6f2f8f236e 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -44,8 +44,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -147,9 +147,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT alarm control panel through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, alarm.DOMAIN) - ) + await async_discover_yaml_entities(hass, alarm.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 39fd87c8b02..9e0a049b15e 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -41,8 +41,8 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -102,9 +102,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT binary sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, binary_sensor.DOMAIN) - ) + await async_discover_yaml_entities(hass, binary_sensor.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index 0374727bf7d..b75fbe4b97f 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -25,8 +25,8 @@ from .const import ( from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -82,9 +82,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT button through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, button.DOMAIN) - ) + await async_discover_yaml_entities(hass, button.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 5c8d3bc48b2..69af7992229 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -22,8 +22,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -80,9 +80,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT camera through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, camera.DOMAIN) - ) + await async_discover_yaml_entities(hass, camera.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index a26e9cba8df..6b09891483c 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -50,8 +50,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -391,9 +391,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT climate device through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, climate.DOMAIN) - ) + await async_discover_yaml_entities(hass, climate.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 67a9208faba..6ac77021337 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -64,7 +64,6 @@ DOMAIN = "mqtt" MQTT_CONNECTED = "mqtt_connected" MQTT_DISCONNECTED = "mqtt_disconnected" -MQTT_RELOADED = "mqtt_reloaded" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" @@ -105,8 +104,8 @@ RELOADABLE_PLATFORMS = [ Platform.LIGHT, Platform.LOCK, Platform.NUMBER, - Platform.SELECT, Platform.SCENE, + Platform.SELECT, Platform.SENSOR, Platform.SIREN, Platform.SWITCH, diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 54ed4f2b0a0..14746329250 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -46,8 +46,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -242,9 +242,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT cover through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, cover.DOMAIN) - ) + await async_discover_yaml_entities(hass, cover.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 721fa93f244..15e4a80f3e7 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -50,8 +50,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -232,7 +232,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT fan through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload(await async_setup_platform_discovery(hass, fan.DOMAIN)) + await async_discover_yaml_entities(hass, fan.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index d2856767cf0..5f09fc0d513 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -45,8 +45,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -187,9 +187,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT humidifier through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, humidifier.DOMAIN) - ) + await async_discover_yaml_entities(hass, humidifier.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index d4914cb9506..c7f3395ba4e 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -13,8 +13,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from ..mixins import ( + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -111,9 +111,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT lights configured under the light platform key (deprecated).""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, light.DOMAIN) - ) + await async_discover_yaml_entities(hass, light.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 1d6a40c2331..b4788f1db0c 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -28,8 +28,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -103,9 +103,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT lock through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, lock.DOMAIN) - ) + await async_discover_yaml_entities(hass, lock.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 10fe6cb6cc5..8e59d09dfce 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -26,7 +26,7 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -69,7 +69,6 @@ from .const import ( DOMAIN, MQTT_CONNECTED, MQTT_DISCONNECTED, - MQTT_RELOADED, ) from .debug_info import log_message, log_messages from .discovery import ( @@ -261,34 +260,27 @@ class SetupEntity(Protocol): """Define setup_entities type.""" -async def async_setup_platform_discovery( +async def async_discover_yaml_entities( hass: HomeAssistant, platform_domain: str -) -> CALLBACK_TYPE: - """Set up platform discovery for manual config.""" - - async def _async_discover_entities() -> None: - """Discover entities for a platform.""" - if DATA_MQTT_UPDATED_CONFIG in hass.data: - # The platform has been reloaded - config_yaml = hass.data[DATA_MQTT_UPDATED_CONFIG] - else: - config_yaml = hass.data.get(DATA_MQTT_CONFIG, {}) - if not config_yaml: - return - if platform_domain not in config_yaml: - return - await asyncio.gather( - *( - discovery.async_load_platform(hass, platform_domain, DOMAIN, config, {}) - for config in await async_get_platform_config_from_yaml( - hass, platform_domain, config_yaml - ) +) -> None: + """Discover entities for a platform.""" + if DATA_MQTT_UPDATED_CONFIG in hass.data: + # The platform has been reloaded + config_yaml = hass.data[DATA_MQTT_UPDATED_CONFIG] + else: + config_yaml = hass.data.get(DATA_MQTT_CONFIG, {}) + if not config_yaml: + return + if platform_domain not in config_yaml: + return + await asyncio.gather( + *( + discovery.async_load_platform(hass, platform_domain, DOMAIN, config, {}) + for config in await async_get_platform_config_from_yaml( + hass, platform_domain, config_yaml ) ) - - unsub = async_dispatcher_connect(hass, MQTT_RELOADED, _async_discover_entities) - await _async_discover_entities() - return unsub + ) async def async_get_platform_config_from_yaml( diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 660ffe987f0..dc27a740720 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -41,8 +41,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -135,9 +135,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT number through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, number.DOMAIN) - ) + await async_discover_yaml_entities(hass, number.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index cc911cc3431..8b654f7cca0 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -22,8 +22,8 @@ from .mixins import ( CONF_OBJECT_ID, MQTT_AVAILABILITY_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -79,9 +79,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT scene through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, scene.DOMAIN) - ) + await async_discover_yaml_entities(hass, scene.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 0d9f1411fd1..4c302446b19 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -30,8 +30,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -94,9 +94,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT select through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, select.DOMAIN) - ) + await async_discover_yaml_entities(hass, select.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 672e22f632f..6948e173039 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -41,8 +41,8 @@ from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttAvailability, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -147,9 +147,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT sensor through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, sensor.DOMAIN) - ) + await async_discover_yaml_entities(hass, sensor.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index a6cff4cf91d..dfb89d2ee79 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -51,8 +51,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -143,9 +143,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT siren through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, siren.DOMAIN) - ) + await async_discover_yaml_entities(hass, siren.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index dadd5f86f20..b04f2433659 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,8 +37,8 @@ from .debug_info import log_messages from .mixins import ( MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, warn_for_legacy_schema, ) @@ -97,9 +97,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT switch through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, switch.DOMAIN) - ) + await async_discover_yaml_entities(hass, switch.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 694e9530939..c49b8cfa012 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -12,8 +12,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from ..mixins import ( + async_discover_yaml_entities, async_setup_entry_helper, - async_setup_platform_discovery, async_setup_platform_helper, ) from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE @@ -91,9 +91,7 @@ async def async_setup_entry( ) -> None: """Set up MQTT vacuum through configuration.yaml and dynamically through MQTT discovery.""" # load and initialize platform config from configuration.yaml - config_entry.async_on_unload( - await async_setup_platform_discovery(hass, vacuum.DOMAIN) - ) + await async_discover_yaml_entities(hass, vacuum.DOMAIN) # setup for discovery setup = functools.partial( _async_setup_entity, hass, async_add_entities, config_entry=config_entry From 15149f4aa1c9667deb796e0575bf7fe41f4a3f23 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 30 Jun 2022 13:03:39 -0400 Subject: [PATCH 891/947] Fix ZHA events for logbook (#74245) --- homeassistant/components/zha/logbook.py | 10 +++++++--- tests/components/zha/test_logbook.py | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index e2d238ddbe8..8140a5244f1 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -62,14 +62,18 @@ def async_describe_events( break if event_type is None: - event_type = event_data[ATTR_COMMAND] + event_type = event_data.get(ATTR_COMMAND, ZHA_EVENT) if event_subtype is not None and event_subtype != event_type: event_type = f"{event_type} - {event_subtype}" - event_type = event_type.replace("_", " ").title() + if event_type is not None: + event_type = event_type.replace("_", " ").title() + if "event" in event_type.lower(): + message = f"{event_type} was fired" + else: + message = f"{event_type} event was fired" - message = f"{event_type} event was fired" if event_data["params"]: message = f"{message} with parameters: {event_data['params']}" diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py index 33b758fd0a7..6c28284b1e6 100644 --- a/tests/components/zha/test_logbook.py +++ b/tests/components/zha/test_logbook.py @@ -172,6 +172,19 @@ async def test_zha_logbook_event_device_no_triggers(hass, mock_devices): }, }, ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": { + "test": "test", + }, + }, + ), ], ) @@ -182,6 +195,12 @@ async def test_zha_logbook_event_device_no_triggers(hass, mock_devices): == "Shake event was fired with parameters: {'test': 'test'}" ) + assert events[1]["name"] == "FakeManufacturer FakeModel" + assert events[1]["domain"] == "zha" + assert ( + events[1]["message"] == "Zha Event was fired with parameters: {'test': 'test'}" + ) + async def test_zha_logbook_event_device_no_device(hass, mock_devices): """Test zha logbook events without device and without triggers.""" From 00468db5afbfdf9b41b1c57a750f9302ed82603b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 30 Jun 2022 19:04:59 +0200 Subject: [PATCH 892/947] Update numpy to 1.23.0 (#74250) --- homeassistant/components/compensation/manifest.json | 2 +- homeassistant/components/iqvia/manifest.json | 2 +- homeassistant/components/opencv/manifest.json | 2 +- homeassistant/components/tensorflow/manifest.json | 2 +- homeassistant/components/trend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/compensation/manifest.json b/homeassistant/components/compensation/manifest.json index eb3135954b0..509d5740b22 100644 --- a/homeassistant/components/compensation/manifest.json +++ b/homeassistant/components/compensation/manifest.json @@ -2,7 +2,7 @@ "domain": "compensation", "name": "Compensation", "documentation": "https://www.home-assistant.io/integrations/compensation", - "requirements": ["numpy==1.22.4"], + "requirements": ["numpy==1.23.0"], "codeowners": ["@Petro31"], "iot_class": "calculated" } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 8b0dacd3575..7485ff9d608 100644 --- a/homeassistant/components/iqvia/manifest.json +++ b/homeassistant/components/iqvia/manifest.json @@ -3,7 +3,7 @@ "name": "IQVIA", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/iqvia", - "requirements": ["numpy==1.22.4", "pyiqvia==2022.04.0"], + "requirements": ["numpy==1.23.0", "pyiqvia==2022.04.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling", "loggers": ["pyiqvia"] diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index 8cd1604f106..0272feb0f9e 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,7 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.22.4", "opencv-python-headless==4.6.0.66"], + "requirements": ["numpy==1.23.0", "opencv-python-headless==4.6.0.66"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json index 4168d820fb6..42d0eae1ecd 100644 --- a/homeassistant/components/tensorflow/manifest.json +++ b/homeassistant/components/tensorflow/manifest.json @@ -6,7 +6,7 @@ "tensorflow==2.5.0", "tf-models-official==2.5.0", "pycocotools==2.0.1", - "numpy==1.22.4", + "numpy==1.23.0", "pillow==9.1.1" ], "codeowners": [], diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 578aea3bbc6..b579cc036bb 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -2,7 +2,7 @@ "domain": "trend", "name": "Trend", "documentation": "https://www.home-assistant.io/integrations/trend", - "requirements": ["numpy==1.22.4"], + "requirements": ["numpy==1.23.0"], "codeowners": [], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9ba945c3e2b..6b7b689b93d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -88,7 +88,7 @@ httpcore==0.15.0 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy>=1.22.0 +numpy==1.23.0 # pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead pytest_asyncio==1000000000.0.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6f677d1d092..2ebabd89a5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1126,7 +1126,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.22.4 +numpy==1.23.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0c9bc54ddd..a19338e5715 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ numato-gpio==0.10.0 # homeassistant.components.opencv # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.22.4 +numpy==1.23.0 # homeassistant.components.google oauth2client==4.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a2a0eab897a..11e88976e83 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -106,7 +106,7 @@ httpcore==0.15.0 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy>=1.22.0 +numpy==1.23.0 # pytest_asyncio breaks our test suite. We rely on pytest-aiohttp instead pytest_asyncio==1000000000.0.0 From d47e1d28dea6f91b5f50470b015263b189b57a55 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jun 2022 12:39:36 -0500 Subject: [PATCH 893/947] Filter out CONF_SCAN_INTERVAL from scrape import (#74254) --- homeassistant/components/scrape/sensor.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 1c447439820..a73dbc17c1c 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -24,6 +24,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, + CONF_SCAN_INTERVAL, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -90,7 +91,7 @@ async def async_setup_platform( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data=config, + data={k: v for k, v in config.items() if k != CONF_SCAN_INTERVAL}, ) ) From 249af3a78dc7a6201333df6b4b3626bf39ff2189 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 30 Jun 2022 19:47:29 +0200 Subject: [PATCH 894/947] Met.no use native_* (#74259) --- homeassistant/components/met/const.py | 16 +++---- homeassistant/components/met/weather.py | 57 ++++++------------------- 2 files changed, 20 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index 93f9e3414dd..5b2a756847e 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -11,13 +11,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, @@ -173,13 +173,13 @@ CONDITIONS_MAP = { FORECAST_MAP = { ATTR_FORECAST_CONDITION: "condition", - ATTR_FORECAST_PRECIPITATION: "precipitation", + ATTR_FORECAST_NATIVE_PRECIPITATION: "precipitation", ATTR_FORECAST_PRECIPITATION_PROBABILITY: "precipitation_probability", - ATTR_FORECAST_TEMP: "temperature", - ATTR_FORECAST_TEMP_LOW: "templow", + ATTR_FORECAST_NATIVE_TEMP: "temperature", + ATTR_FORECAST_NATIVE_TEMP_LOW: "templow", ATTR_FORECAST_TIME: "datetime", ATTR_FORECAST_WIND_BEARING: "wind_bearing", - ATTR_FORECAST_WIND_SPEED: "wind_speed", + ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", } ATTR_MAP = { diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index 251d99ad295..0ff0a60bfa1 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -6,7 +6,6 @@ from typing import Any from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, @@ -21,12 +20,9 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - LENGTH_INCHES, LENGTH_MILLIMETERS, PRESSURE_HPA, - PRESSURE_INHG, SPEED_KILOMETERS_PER_HOUR, - SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -34,19 +30,9 @@ from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util.distance import convert as convert_distance -from homeassistant.util.pressure import convert as convert_pressure -from homeassistant.util.speed import convert as convert_speed from . import MetDataUpdateCoordinator -from .const import ( - ATTR_FORECAST_PRECIPITATION, - ATTR_MAP, - CONDITIONS_MAP, - CONF_TRACK_HOME, - DOMAIN, - FORECAST_MAP, -) +from .const import ATTR_MAP, CONDITIONS_MAP, CONF_TRACK_HOME, DOMAIN, FORECAST_MAP ATTRIBUTION = ( "Weather forecast from met.no, delivered by the Norwegian " @@ -85,6 +71,11 @@ def format_condition(condition: str) -> str: class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): """Implementation of a Met.no weather condition.""" + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__( self, coordinator: MetDataUpdateCoordinator, @@ -144,27 +135,18 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): return format_condition(condition) @property - def temperature(self) -> float | None: + def native_temperature(self) -> float | None: """Return the temperature.""" return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_TEMPERATURE] ) @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self) -> float | None: + def native_pressure(self) -> float | None: """Return the pressure.""" - pressure_hpa = self.coordinator.data.current_weather_data.get( + return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_PRESSURE] ) - if self._is_metric or pressure_hpa is None: - return pressure_hpa - - return round(convert_pressure(pressure_hpa, PRESSURE_HPA, PRESSURE_INHG), 2) @property def humidity(self) -> float | None: @@ -174,18 +156,11 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): ) @property - def wind_speed(self) -> float | None: + def native_wind_speed(self) -> float | None: """Return the wind speed.""" - speed_km_h = self.coordinator.data.current_weather_data.get( + return self.coordinator.data.current_weather_data.get( ATTR_MAP[ATTR_WEATHER_WIND_SPEED] ) - if self._is_metric or speed_km_h is None: - return speed_km_h - - speed_mi_h = convert_speed( - speed_km_h, SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR - ) - return int(round(speed_mi_h)) @property def wind_bearing(self) -> float | str | None: @@ -206,7 +181,7 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): met_forecast = self.coordinator.data.hourly_forecast else: met_forecast = self.coordinator.data.daily_forecast - required_keys = {ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME} + required_keys = {"temperature", ATTR_FORECAST_TIME} ha_forecast: list[Forecast] = [] for met_item in met_forecast: if not set(met_item).issuperset(required_keys): @@ -216,14 +191,6 @@ class MetWeather(CoordinatorEntity[MetDataUpdateCoordinator], WeatherEntity): for k, v in FORECAST_MAP.items() if met_item.get(v) is not None } - if not self._is_metric and ATTR_FORECAST_PRECIPITATION in ha_item: - if ha_item[ATTR_FORECAST_PRECIPITATION] is not None: - precip_inches = convert_distance( - ha_item[ATTR_FORECAST_PRECIPITATION], - LENGTH_MILLIMETERS, - LENGTH_INCHES, - ) - ha_item[ATTR_FORECAST_PRECIPITATION] = round(precip_inches, 2) if ha_item.get(ATTR_FORECAST_CONDITION): ha_item[ATTR_FORECAST_CONDITION] = format_condition( ha_item[ATTR_FORECAST_CONDITION] From 6f63cd731bc0a761f4257e60a0dd1e0400c9eda0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jun 2022 12:05:29 -0500 Subject: [PATCH 895/947] Add debug logging to esphome state updates (#74260) --- homeassistant/components/esphome/entry_data.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 7980d1a6a17..d4bcc67db4a 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -4,6 +4,7 @@ from __future__ import annotations import asyncio from collections.abc import Callable from dataclasses import dataclass, field +import logging from typing import Any, cast from aioesphomeapi import ( @@ -36,6 +37,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store SAVE_DELAY = 120 +_LOGGER = logging.getLogger(__name__) # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { @@ -128,6 +130,12 @@ class RuntimeEntryData: component_key = self.key_to_component[state.key] self.state[component_key][state.key] = state signal = f"esphome_{self.entry_id}_update_{component_key}_{state.key}" + _LOGGER.debug( + "Dispatching update for component %s with state key %s: %s", + component_key, + state.key, + state, + ) async_dispatcher_send(hass, signal) @callback From 3db1552f26c1e7d7ebfd66be02e14c69546d412f Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Thu, 30 Jun 2022 12:10:05 -0500 Subject: [PATCH 896/947] Fix Life360 unload (#74263) * Fix life360 async_unload_entry * Update tracked_members when unloading config entry --- homeassistant/components/life360/__init__.py | 14 ++++++++++---- homeassistant/components/life360/device_tracker.py | 10 +++++----- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 66c9416a1c1..4527f6ac298 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -124,11 +124,11 @@ class IntegData: """Integration data.""" cfg_options: dict[str, Any] | None = None - # ConfigEntry.unique_id: Life360DataUpdateCoordinator + # ConfigEntry.entry_id: Life360DataUpdateCoordinator coordinators: dict[str, Life360DataUpdateCoordinator] = field( init=False, default_factory=dict ) - # member_id: ConfigEntry.unique_id + # member_id: ConfigEntry.entry_id tracked_members: dict[str, str] = field(init=False, default_factory=dict) logged_circles: list[str] = field(init=False, default_factory=list) logged_places: list[str] = field(init=False, default_factory=list) @@ -171,7 +171,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload config entry.""" - del hass.data[DOMAIN].coordinators[entry.entry_id] # Unload components for our platforms. - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN].coordinators[entry.entry_id] + # Remove any members that were tracked by this entry. + for member_id, entry_id in hass.data[DOMAIN].tracked_members.copy().items(): + if entry_id == entry.entry_id: + del hass.data[DOMAIN].tracked_members[member_id] + + return unload_ok diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index a38181a6830..5a18422487e 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -78,13 +78,13 @@ async def async_setup_entry( new_entities = [] for member_id, member in coordinator.data.members.items(): - tracked_by_account = tracked_members.get(member_id) - if new_member := not tracked_by_account: - tracked_members[member_id] = entry.unique_id - LOGGER.debug("Member: %s", member.name) + tracked_by_entry = tracked_members.get(member_id) + if new_member := not tracked_by_entry: + tracked_members[member_id] = entry.entry_id + LOGGER.debug("Member: %s (%s)", member.name, entry.unique_id) if ( new_member - or tracked_by_account == entry.unique_id + or tracked_by_entry == entry.entry_id and not new_members_only ): new_entities.append(Life360DeviceTracker(coordinator, member_id)) From 4755f0054936cbb948e2e2065c56c6f4308c69fd Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Thu, 30 Jun 2022 13:01:23 -0500 Subject: [PATCH 897/947] Bump frontend to 20220630.0 (#74266) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 23f056ba0da..27ff0a73f20 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220629.0"], + "requirements": ["home-assistant-frontend==20220630.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b7b689b93d..7ee5a9fe8d3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220629.0 +home-assistant-frontend==20220630.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index 2ebabd89a5a..84fb3e6894d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -828,7 +828,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220629.0 +home-assistant-frontend==20220630.0 # homeassistant.components.home_connect homeconnect==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a19338e5715..b15b9446de3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -595,7 +595,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220629.0 +home-assistant-frontend==20220630.0 # homeassistant.components.home_connect homeconnect==0.7.1 From 762fe17f48cd01a324192696f4ab2127aa15d30f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 30 Jun 2022 11:02:38 -0700 Subject: [PATCH 898/947] Bumped version to 2022.7.0b1 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index df160e1cc6b..60a3d6817b5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index 69c5a539f3f..2b1e14a40f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.0b0" +version = "2022.7.0b1" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 135d104430f7dc4c30ef420120b80c2a7b366a57 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 30 Jun 2022 23:48:50 +0200 Subject: [PATCH 899/947] Bump pyRFXtrx to 0.30.0 (#74146) --- homeassistant/components/rfxtrx/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/rfxtrx/test_config_flow.py | 12 ++++-- tests/components/rfxtrx/test_cover.py | 37 ++++++++++--------- tests/components/rfxtrx/test_switch.py | 4 +- 6 files changed, 34 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json index cfe1049c888..3439fbba70c 100644 --- a/homeassistant/components/rfxtrx/manifest.json +++ b/homeassistant/components/rfxtrx/manifest.json @@ -2,7 +2,7 @@ "domain": "rfxtrx", "name": "RFXCOM RFXtrx", "documentation": "https://www.home-assistant.io/integrations/rfxtrx", - "requirements": ["pyRFXtrx==0.29.0"], + "requirements": ["pyRFXtrx==0.30.0"], "codeowners": ["@danielhiversen", "@elupus", "@RobBie1221"], "config_flow": true, "iot_class": "local_push", diff --git a/requirements_all.txt b/requirements_all.txt index 84fb3e6894d..ca304ff6764 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1336,7 +1336,7 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.29.0 +pyRFXtrx==0.30.0 # homeassistant.components.switchmate # pySwitchmate==0.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b15b9446de3..cd67a1a72d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -914,7 +914,7 @@ pyMetEireann==2021.8.0 pyMetno==0.9.0 # homeassistant.components.rfxtrx -pyRFXtrx==0.29.0 +pyRFXtrx==0.30.0 # homeassistant.components.tibber pyTibber==0.22.3 diff --git a/tests/components/rfxtrx/test_config_flow.py b/tests/components/rfxtrx/test_config_flow.py index a756bf26b9f..2c695d71d2e 100644 --- a/tests/components/rfxtrx/test_config_flow.py +++ b/tests/components/rfxtrx/test_config_flow.py @@ -847,7 +847,7 @@ async def test_options_configure_rfy_cover_device(hass): result["flow_id"], user_input={ "automatic_add": True, - "event_code": "071a000001020301", + "event_code": "0C1a0000010203010000000000", }, ) @@ -863,7 +863,10 @@ async def test_options_configure_rfy_cover_device(hass): await hass.async_block_till_done() - assert entry.data["devices"]["071a000001020301"]["venetian_blind_mode"] == "EU" + assert ( + entry.data["devices"]["0C1a0000010203010000000000"]["venetian_blind_mode"] + == "EU" + ) device_registry = dr.async_get(hass) device_entries = dr.async_entries_for_config_entry(device_registry, entry.entry_id) @@ -897,7 +900,10 @@ async def test_options_configure_rfy_cover_device(hass): await hass.async_block_till_done() - assert entry.data["devices"]["071a000001020301"]["venetian_blind_mode"] == "EU" + assert ( + entry.data["devices"]["0C1a0000010203010000000000"]["venetian_blind_mode"] + == "EU" + ) def test_get_serial_by_id_no_dir(): diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py index e3d44edda82..3be41d9233e 100644 --- a/tests/components/rfxtrx/test_cover.py +++ b/tests/components/rfxtrx/test_cover.py @@ -146,8 +146,11 @@ async def test_rfy_cover(hass, rfxtrx): "071a000001020301": { "venetian_blind_mode": "Unknown", }, - "071a000001020302": {"venetian_blind_mode": "US"}, - "071a000001020303": {"venetian_blind_mode": "EU"}, + "0c1a0000010203010000000000": { + "venetian_blind_mode": "Unknown", + }, + "0c1a0000010203020000000000": {"venetian_blind_mode": "US"}, + "0c1a0000010203030000000000": {"venetian_blind_mode": "EU"}, } ) mock_entry = MockConfigEntry(domain="rfxtrx", unique_id=DOMAIN, data=entry_data) @@ -199,9 +202,9 @@ async def test_rfy_cover(hass, rfxtrx): ) assert rfxtrx.transport.send.mock_calls == [ - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x01\x00")), - call(bytearray(b"\x08\x1a\x00\x01\x01\x02\x03\x01\x01")), - call(bytearray(b"\x08\x1a\x00\x02\x01\x02\x03\x01\x03")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x01\x00\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x01\x01\x02\x03\x01\x01\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x02\x01\x02\x03\x01\x03\x00\x00\x00\x00")), ] # Test a blind with venetian mode set to US @@ -252,12 +255,12 @@ async def test_rfy_cover(hass, rfxtrx): ) assert rfxtrx.transport.send.mock_calls == [ - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x02\x00")), - call(bytearray(b"\x08\x1a\x00\x01\x01\x02\x03\x02\x0F")), - call(bytearray(b"\x08\x1a\x00\x02\x01\x02\x03\x02\x10")), - call(bytearray(b"\x08\x1a\x00\x03\x01\x02\x03\x02\x11")), - call(bytearray(b"\x08\x1a\x00\x04\x01\x02\x03\x02\x12")), - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x02\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x02\x00\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x01\x01\x02\x03\x02\x0F\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x02\x01\x02\x03\x02\x10\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x03\x01\x02\x03\x02\x11\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x04\x01\x02\x03\x02\x12\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x02\x00\x00\x00\x00\x00")), ] # Test a blind with venetian mode set to EU @@ -308,10 +311,10 @@ async def test_rfy_cover(hass, rfxtrx): ) assert rfxtrx.transport.send.mock_calls == [ - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x03\x00")), - call(bytearray(b"\x08\x1a\x00\x01\x01\x02\x03\x03\x11")), - call(bytearray(b"\x08\x1a\x00\x02\x01\x02\x03\x03\x12")), - call(bytearray(b"\x08\x1a\x00\x03\x01\x02\x03\x03\x0F")), - call(bytearray(b"\x08\x1a\x00\x04\x01\x02\x03\x03\x10")), - call(bytearray(b"\x08\x1a\x00\x00\x01\x02\x03\x03\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x03\x00\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x01\x01\x02\x03\x03\x11\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x02\x01\x02\x03\x03\x12\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x03\x01\x02\x03\x03\x0F\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x04\x01\x02\x03\x03\x10\x00\x00\x00\x00")), + call(bytearray(b"\x0C\x1a\x00\x00\x01\x02\x03\x03\x00\x00\x00\x00\x00")), ] diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py index 4da7f1d9881..4d92c6fa332 100644 --- a/tests/components/rfxtrx/test_switch.py +++ b/tests/components/rfxtrx/test_switch.py @@ -11,8 +11,8 @@ from homeassistant.core import State from tests.common import MockConfigEntry, mock_restore_cache from tests.components.rfxtrx.conftest import create_rfx_test_cfg -EVENT_RFY_ENABLE_SUN_AUTO = "081a00000301010113" -EVENT_RFY_DISABLE_SUN_AUTO = "081a00000301010114" +EVENT_RFY_ENABLE_SUN_AUTO = "0C1a0000030101011300000003" +EVENT_RFY_DISABLE_SUN_AUTO = "0C1a0000030101011400000003" async def test_one_switch(hass, rfxtrx): From 1351b83731aa8b19231b0f053f60b2d408147d6e Mon Sep 17 00:00:00 2001 From: Christopher Hoage Date: Thu, 30 Jun 2022 13:06:22 -0700 Subject: [PATCH 900/947] Bump venstarcolortouch to 0.17 (#74271) --- homeassistant/components/venstar/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index e63c75792bf..2f3331af6e2 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -3,7 +3,7 @@ "name": "Venstar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", - "requirements": ["venstarcolortouch==0.16"], + "requirements": ["venstarcolortouch==0.17"], "codeowners": ["@garbled1"], "iot_class": "local_polling", "loggers": ["venstarcolortouch"] diff --git a/requirements_all.txt b/requirements_all.txt index ca304ff6764..84501dbce79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2387,7 +2387,7 @@ vehicle==0.4.0 velbus-aio==2022.6.2 # homeassistant.components.venstar -venstarcolortouch==0.16 +venstarcolortouch==0.17 # homeassistant.components.vilfo vilfo-api-client==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cd67a1a72d7..de9ca72060d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1590,7 +1590,7 @@ vehicle==0.4.0 velbus-aio==2022.6.2 # homeassistant.components.venstar -venstarcolortouch==0.16 +venstarcolortouch==0.17 # homeassistant.components.vilfo vilfo-api-client==0.3.2 From b61530742ef2a887a979b14d6aaf0e1094d0b72a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Jul 2022 00:19:40 -0500 Subject: [PATCH 901/947] Fix key collision between platforms in esphome state updates (#74273) --- homeassistant/components/esphome/__init__.py | 19 +--- .../components/esphome/entry_data.py | 92 ++++++++++++++----- 2 files changed, 73 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 2e88a883dc1..0c1eac3aa45 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -150,11 +150,6 @@ async def async_setup_entry( # noqa: C901 hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, on_stop) ) - @callback - def async_on_state(state: EntityState) -> None: - """Send dispatcher updates when a new state is received.""" - entry_data.async_update_state(hass, state) - @callback def async_on_service_call(service: HomeassistantServiceCall) -> None: """Call service when user automation in ESPHome config is triggered.""" @@ -288,7 +283,7 @@ async def async_setup_entry( # noqa: C901 entity_infos, services = await cli.list_entities_services() await entry_data.async_update_static_infos(hass, entry, entity_infos) await _setup_services(hass, entry_data, services) - await cli.subscribe_states(async_on_state) + await cli.subscribe_states(entry_data.async_update_state) await cli.subscribe_service_calls(async_on_service_call) await cli.subscribe_home_assistant_states(async_on_state_subscription) @@ -568,7 +563,6 @@ async def platform_async_setup_entry( @callback def async_list_entities(infos: list[EntityInfo]) -> None: """Update entities of this platform when entities are listed.""" - key_to_component = entry_data.key_to_component old_infos = entry_data.info[component_key] new_infos: dict[int, EntityInfo] = {} add_entities = [] @@ -587,12 +581,10 @@ async def platform_async_setup_entry( entity = entity_type(entry_data, component_key, info.key) add_entities.append(entity) new_infos[info.key] = info - key_to_component[info.key] = component_key # Remove old entities for info in old_infos.values(): entry_data.async_remove_entity(hass, component_key, info.key) - key_to_component.pop(info.key, None) # First copy the now-old info into the backup object entry_data.old_info[component_key] = entry_data.info[component_key] @@ -714,13 +706,8 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): ) self.async_on_remove( - async_dispatcher_connect( - self.hass, - ( - f"esphome_{self._entry_id}" - f"_update_{self._component_key}_{self._key}" - ), - self._on_state_update, + self._entry_data.async_subscribe_state_update( + self._component_key, self._key, self._on_state_update ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index d4bcc67db4a..8eb56e6fdb6 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -12,26 +12,40 @@ from aioesphomeapi import ( APIClient, APIVersion, BinarySensorInfo, + BinarySensorState, CameraInfo, + CameraState, ClimateInfo, + ClimateState, CoverInfo, + CoverState, DeviceInfo, EntityInfo, EntityState, FanInfo, + FanState, LightInfo, + LightState, LockInfo, + LockState, MediaPlayerInfo, + MediaPlayerState, NumberInfo, + NumberState, SelectInfo, + SelectState, SensorInfo, + SensorState, SwitchInfo, + SwitchState, TextSensorInfo, + TextSensorState, UserService, ) from aioesphomeapi.model import ButtonInfo from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.storage import Store @@ -41,20 +55,37 @@ _LOGGER = logging.getLogger(__name__) # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { - BinarySensorInfo: "binary_sensor", - ButtonInfo: "button", - CameraInfo: "camera", - ClimateInfo: "climate", - CoverInfo: "cover", - FanInfo: "fan", - LightInfo: "light", - LockInfo: "lock", - MediaPlayerInfo: "media_player", - NumberInfo: "number", - SelectInfo: "select", - SensorInfo: "sensor", - SwitchInfo: "switch", - TextSensorInfo: "sensor", + BinarySensorInfo: Platform.BINARY_SENSOR, + ButtonInfo: Platform.BINARY_SENSOR, + CameraInfo: Platform.BINARY_SENSOR, + ClimateInfo: Platform.CLIMATE, + CoverInfo: Platform.COVER, + FanInfo: Platform.FAN, + LightInfo: Platform.LIGHT, + LockInfo: Platform.LOCK, + MediaPlayerInfo: Platform.MEDIA_PLAYER, + NumberInfo: Platform.NUMBER, + SelectInfo: Platform.SELECT, + SensorInfo: Platform.SENSOR, + SwitchInfo: Platform.SWITCH, + TextSensorInfo: Platform.SENSOR, +} + +STATE_TYPE_TO_COMPONENT_KEY = { + BinarySensorState: Platform.BINARY_SENSOR, + EntityState: Platform.BINARY_SENSOR, + CameraState: Platform.BINARY_SENSOR, + ClimateState: Platform.CLIMATE, + CoverState: Platform.COVER, + FanState: Platform.FAN, + LightState: Platform.LIGHT, + LockState: Platform.LOCK, + MediaPlayerState: Platform.MEDIA_PLAYER, + NumberState: Platform.NUMBER, + SelectState: Platform.SELECT, + SensorState: Platform.SENSOR, + SwitchState: Platform.SWITCH, + TextSensorState: Platform.SENSOR, } @@ -67,7 +98,6 @@ class RuntimeEntryData: store: Store state: dict[str, dict[int, EntityState]] = field(default_factory=dict) info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) - key_to_component: dict[int, str] = field(default_factory=dict) # A second list of EntityInfo objects # This is necessary for when an entity is being removed. HA requires @@ -81,6 +111,9 @@ class RuntimeEntryData: api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) + state_subscriptions: dict[tuple[str, int], Callable[[], None]] = field( + default_factory=dict + ) loaded_platforms: set[str] = field(default_factory=set) platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _storage_contents: dict[str, Any] | None = None @@ -125,18 +158,33 @@ class RuntimeEntryData: async_dispatcher_send(hass, signal, infos) @callback - def async_update_state(self, hass: HomeAssistant, state: EntityState) -> None: + def async_subscribe_state_update( + self, + component_key: str, + state_key: int, + entity_callback: Callable[[], None], + ) -> Callable[[], None]: + """Subscribe to state updates.""" + + def _unsubscribe() -> None: + self.state_subscriptions.pop((component_key, state_key)) + + self.state_subscriptions[(component_key, state_key)] = entity_callback + return _unsubscribe + + @callback + def async_update_state(self, state: EntityState) -> None: """Distribute an update of state information to the target.""" - component_key = self.key_to_component[state.key] + component_key = STATE_TYPE_TO_COMPONENT_KEY[type(state)] + subscription_key = (component_key, state.key) self.state[component_key][state.key] = state - signal = f"esphome_{self.entry_id}_update_{component_key}_{state.key}" _LOGGER.debug( - "Dispatching update for component %s with state key %s: %s", - component_key, - state.key, + "Dispatching update with key %s: %s", + subscription_key, state, ) - async_dispatcher_send(hass, signal) + if subscription_key in self.state_subscriptions: + self.state_subscriptions[subscription_key]() @callback def async_update_device_state(self, hass: HomeAssistant) -> None: From 78e5296d07aa7f9428e95a3b372e86d07c784467 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 30 Jun 2022 16:59:35 -0400 Subject: [PATCH 902/947] Fix bad conditional in ZHA logbook (#74277) * Fix bad conditional in ZHA logbook * change syntax --- homeassistant/components/zha/logbook.py | 4 ++-- tests/components/zha/test_logbook.py | 29 +++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/logbook.py b/homeassistant/components/zha/logbook.py index 8140a5244f1..90d433be210 100644 --- a/homeassistant/components/zha/logbook.py +++ b/homeassistant/components/zha/logbook.py @@ -74,8 +74,8 @@ def async_describe_events( else: message = f"{event_type} event was fired" - if event_data["params"]: - message = f"{message} with parameters: {event_data['params']}" + if params := event_data.get("params"): + message = f"{message} with parameters: {params}" return { LOGBOOK_ENTRY_NAME: device_name, diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py index 6c28284b1e6..373a48c2d47 100644 --- a/tests/components/zha/test_logbook.py +++ b/tests/components/zha/test_logbook.py @@ -185,6 +185,27 @@ async def test_zha_logbook_event_device_no_triggers(hass, mock_devices): }, }, ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + "params": {}, + }, + ), + MockRow( + ZHA_EVENT, + { + CONF_DEVICE_ID: reg_device.id, + "device_ieee": str(ieee_address), + CONF_UNIQUE_ID: f"{str(ieee_address)}:1:0x0006", + "endpoint_id": 1, + "cluster_id": 6, + }, + ), ], ) @@ -201,6 +222,14 @@ async def test_zha_logbook_event_device_no_triggers(hass, mock_devices): events[1]["message"] == "Zha Event was fired with parameters: {'test': 'test'}" ) + assert events[2]["name"] == "FakeManufacturer FakeModel" + assert events[2]["domain"] == "zha" + assert events[2]["message"] == "Zha Event was fired" + + assert events[3]["name"] == "FakeManufacturer FakeModel" + assert events[3]["domain"] == "zha" + assert events[3]["message"] == "Zha Event was fired" + async def test_zha_logbook_event_device_no_device(hass, mock_devices): """Test zha logbook events without device and without triggers.""" From 4817f9f905d9474b1b9810032ef7bd7c35482afe Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 Jul 2022 00:40:05 -0700 Subject: [PATCH 903/947] Add scan interval to scrape sensor (#74285) --- homeassistant/components/scrape/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index a73dbc17c1c..b6b8828ca73 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,6 +1,7 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations +from datetime import timedelta import logging from typing import Any @@ -44,6 +45,7 @@ from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DO _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=10) ICON = "mdi:web" PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( From 877803169bc2d0c50b0eb2d5d75a1b7201f7df63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Fri, 1 Jul 2022 11:52:46 +0200 Subject: [PATCH 904/947] Fix QNAP QSW DHCP discover bugs (#74291) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qnqp_qsw: fix DHCP discover bugs Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/qnap_qsw/config_flow.py | 3 ++- homeassistant/components/qnap_qsw/strings.json | 6 ++++++ homeassistant/components/qnap_qsw/translations/en.json | 6 ++++++ tests/components/qnap_qsw/test_config_flow.py | 5 +++-- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/qnap_qsw/config_flow.py b/homeassistant/components/qnap_qsw/config_flow.py index e9d11433021..bb42c9ea294 100644 --- a/homeassistant/components/qnap_qsw/config_flow.py +++ b/homeassistant/components/qnap_qsw/config_flow.py @@ -113,9 +113,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except LoginError: errors[CONF_PASSWORD] = "invalid_auth" except QswError: - errors[CONF_URL] = "cannot_connect" + errors["base"] = "cannot_connect" else: title = f"QNAP {system_board.get_product()} {self._discovered_mac}" + user_input[CONF_URL] = self._discovered_url return self.async_create_entry(title=title, data=user_input) return self.async_show_form( diff --git a/homeassistant/components/qnap_qsw/strings.json b/homeassistant/components/qnap_qsw/strings.json index 351245a9591..ba0cb28ba77 100644 --- a/homeassistant/components/qnap_qsw/strings.json +++ b/homeassistant/components/qnap_qsw/strings.json @@ -9,6 +9,12 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" }, "step": { + "discovered_connection": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + }, "user": { "data": { "url": "[%key:common::config_flow::data::url%]", diff --git a/homeassistant/components/qnap_qsw/translations/en.json b/homeassistant/components/qnap_qsw/translations/en.json index b6f68f2f062..c75c2d76ac8 100644 --- a/homeassistant/components/qnap_qsw/translations/en.json +++ b/homeassistant/components/qnap_qsw/translations/en.json @@ -9,6 +9,12 @@ "invalid_auth": "Invalid authentication" }, "step": { + "discovered_connection": { + "data": { + "password": "Password", + "username": "Username" + } + }, "user": { "data": { "password": "Password", diff --git a/tests/components/qnap_qsw/test_config_flow.py b/tests/components/qnap_qsw/test_config_flow.py index 0b7072dd602..02f873c6a4a 100644 --- a/tests/components/qnap_qsw/test_config_flow.py +++ b/tests/components/qnap_qsw/test_config_flow.py @@ -24,7 +24,7 @@ DHCP_SERVICE_INFO = dhcp.DhcpServiceInfo( ) TEST_PASSWORD = "test-password" -TEST_URL = "test-url" +TEST_URL = f"http://{DHCP_SERVICE_INFO.ip}" TEST_USERNAME = "test-username" @@ -187,6 +187,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: assert result2["data"] == { CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD, + CONF_URL: TEST_URL, } assert len(mock_setup_entry.mock_calls) == 1 @@ -237,7 +238,7 @@ async def test_dhcp_connection_error(hass: HomeAssistant): }, ) - assert result["errors"] == {CONF_URL: "cannot_connect"} + assert result["errors"] == {"base": "cannot_connect"} async def test_dhcp_login_error(hass: HomeAssistant): From 2305c625fb4c87b75e2389f6316649498e392cd3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Jul 2022 12:10:40 -0500 Subject: [PATCH 905/947] Revert scrape changes to 2022.6.6 (#74305) --- CODEOWNERS | 4 +- homeassistant/components/scrape/__init__.py | 63 ------ homeassistant/components/scrape/manifest.json | 3 +- homeassistant/components/scrape/sensor.py | 104 ++++----- homeassistant/generated/config_flows.py | 1 - tests/components/scrape/__init__.py | 40 +--- tests/components/scrape/test_config_flow.py | 194 ---------------- tests/components/scrape/test_init.py | 89 -------- tests/components/scrape/test_sensor.py | 209 ++++++++---------- 9 files changed, 137 insertions(+), 570 deletions(-) delete mode 100644 tests/components/scrape/test_config_flow.py delete mode 100644 tests/components/scrape/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 1b6d88b5464..5f5588c0b91 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -891,8 +891,8 @@ build.json @home-assistant/supervisor /homeassistant/components/scene/ @home-assistant/core /tests/components/scene/ @home-assistant/core /homeassistant/components/schluter/ @prairieapps -/homeassistant/components/scrape/ @fabaff @gjohansson-ST -/tests/components/scrape/ @fabaff @gjohansson-ST +/homeassistant/components/scrape/ @fabaff +/tests/components/scrape/ @fabaff /homeassistant/components/screenlogic/ @dieselrabbit @bdraco /tests/components/screenlogic/ @dieselrabbit @bdraco /homeassistant/components/script/ @home-assistant/core diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py index 684be76b80d..f9222c126b5 100644 --- a/homeassistant/components/scrape/__init__.py +++ b/homeassistant/components/scrape/__init__.py @@ -1,64 +1 @@ """The scrape component.""" -from __future__ import annotations - -import httpx - -from homeassistant.components.rest.data import RestData -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_AUTHENTICATION, - CONF_HEADERS, - CONF_PASSWORD, - CONF_RESOURCE, - CONF_USERNAME, - CONF_VERIFY_SSL, - HTTP_DIGEST_AUTHENTICATION, -) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady - -from .const import DOMAIN, PLATFORMS - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Scrape from a config entry.""" - - resource: str = entry.options[CONF_RESOURCE] - method: str = "GET" - payload: str | None = None - headers: str | None = entry.options.get(CONF_HEADERS) - verify_ssl: bool = entry.options[CONF_VERIFY_SSL] - username: str | None = entry.options.get(CONF_USERNAME) - password: str | None = entry.options.get(CONF_PASSWORD) - - auth: httpx.DigestAuth | tuple[str, str] | None = None - if username and password: - if entry.options.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = httpx.DigestAuth(username, password) - else: - auth = (username, password) - - rest = RestData(hass, method, resource, auth, headers, None, payload, verify_ssl) - await rest.async_update() - - if rest.data is None: - raise ConfigEntryNotReady - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = rest - - entry.async_on_unload(entry.add_update_listener(async_update_listener)) - - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - - return True - - -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload Scrape config entry.""" - - return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index 631af2e6051..b1ccbb354a9 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -4,7 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/scrape", "requirements": ["beautifulsoup4==4.11.1", "lxml==4.8.0"], "after_dependencies": ["rest"], - "codeowners": ["@fabaff", "@gjohansson-ST"], - "config_flow": true, + "codeowners": ["@fabaff"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index b6b8828ca73..e15f7c5ba97 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,11 +1,11 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations -from datetime import timedelta import logging from typing import Any from bs4 import BeautifulSoup +import httpx import voluptuous as vol from homeassistant.components.rest.data import RestData @@ -16,16 +16,13 @@ from homeassistant.components.sensor import ( STATE_CLASSES_SCHEMA, SensorEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( - CONF_ATTRIBUTE, CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_HEADERS, CONF_NAME, CONF_PASSWORD, CONF_RESOURCE, - CONF_SCAN_INTERVAL, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE, @@ -34,25 +31,26 @@ from homeassistant.const import ( HTTP_DIGEST_AUTHENTICATION, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_INDEX, CONF_SELECT, DEFAULT_NAME, DEFAULT_VERIFY_SSL, DOMAIN - _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(minutes=10) -ICON = "mdi:web" +CONF_ATTR = "attribute" +CONF_SELECT = "select" +CONF_INDEX = "index" + +DEFAULT_NAME = "Web scrape" +DEFAULT_VERIFY_SSL = True PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( { vol.Required(CONF_RESOURCE): cv.string, vol.Required(CONF_SELECT): cv.string, - vol.Optional(CONF_ATTRIBUTE): cv.string, + vol.Optional(CONF_ATTR): cv.string, vol.Optional(CONF_INDEX, default=0): cv.positive_int, vol.Optional(CONF_AUTHENTICATION): vol.In( [HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION] @@ -64,7 +62,7 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): STATE_CLASSES_SCHEMA, vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_VALUE_TEMPLATE): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, } ) @@ -77,47 +75,37 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Web scrape sensor.""" - _LOGGER.warning( - # Config flow added in Home Assistant Core 2022.7, remove import flow in 2022.9 - "Loading Scrape via platform setup has been deprecated in Home Assistant 2022.7 " - "Your configuration has been automatically imported and you can " - "remove it from your configuration.yaml" - ) + name: str = config[CONF_NAME] + resource: str = config[CONF_RESOURCE] + method: str = "GET" + payload: str | None = None + headers: str | None = config.get(CONF_HEADERS) + verify_ssl: bool = config[CONF_VERIFY_SSL] + select: str | None = config.get(CONF_SELECT) + attr: str | None = config.get(CONF_ATTR) + index: int = config[CONF_INDEX] + unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) + device_class: str | None = config.get(CONF_DEVICE_CLASS) + state_class: str | None = config.get(CONF_STATE_CLASS) + username: str | None = config.get(CONF_USERNAME) + password: str | None = config.get(CONF_PASSWORD) + value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) - if config.get(CONF_VALUE_TEMPLATE): - template: Template = Template(config[CONF_VALUE_TEMPLATE]) - template.ensure_valid() - config[CONF_VALUE_TEMPLATE] = template.template - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={k: v for k, v in config.items() if k != CONF_SCAN_INTERVAL}, - ) - ) - - -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback -) -> None: - """Set up the Scrape sensor entry.""" - name: str = entry.options[CONF_NAME] - resource: str = entry.options[CONF_RESOURCE] - select: str | None = entry.options.get(CONF_SELECT) - attr: str | None = entry.options.get(CONF_ATTRIBUTE) - index: int = int(entry.options[CONF_INDEX]) - unit: str | None = entry.options.get(CONF_UNIT_OF_MEASUREMENT) - device_class: str | None = entry.options.get(CONF_DEVICE_CLASS) - state_class: str | None = entry.options.get(CONF_STATE_CLASS) - value_template: str | None = entry.options.get(CONF_VALUE_TEMPLATE) - entry_id: str = entry.entry_id - - val_template: Template | None = None if value_template is not None: - val_template = Template(value_template, hass) + value_template.hass = hass - rest = hass.data[DOMAIN][entry.entry_id] + auth: httpx.DigestAuth | tuple[str, str] | None = None + if username and password: + if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: + auth = httpx.DigestAuth(username, password) + else: + auth = (username, password) + + rest = RestData(hass, method, resource, auth, headers, None, payload, verify_ssl) + await rest.async_update() + + if rest.data is None: + raise PlatformNotReady async_add_entities( [ @@ -127,12 +115,10 @@ async def async_setup_entry( select, attr, index, - val_template, + value_template, unit, device_class, state_class, - entry_id, - resource, ) ], True, @@ -142,8 +128,6 @@ async def async_setup_entry( class ScrapeSensor(SensorEntity): """Representation of a web scrape sensor.""" - _attr_icon = ICON - def __init__( self, rest: RestData, @@ -155,8 +139,6 @@ class ScrapeSensor(SensorEntity): unit: str | None, device_class: str | None, state_class: str | None, - entry_id: str, - resource: str, ) -> None: """Initialize a web scrape sensor.""" self.rest = rest @@ -169,14 +151,6 @@ class ScrapeSensor(SensorEntity): self._attr_native_unit_of_measurement = unit self._attr_device_class = device_class self._attr_state_class = state_class - self._attr_unique_id = entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Scrape", - name=name, - configuration_url=resource, - ) def _extract_value(self) -> Any: """Parse the html extraction in the executor.""" diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index af4b8481873..d7ed9159d7f 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -300,7 +300,6 @@ FLOWS = { "ruckus_unleashed", "sabnzbd", "samsungtv", - "scrape", "screenlogic", "season", "sense", diff --git a/tests/components/scrape/__init__.py b/tests/components/scrape/__init__.py index 37abb061e75..0ba9266a79d 100644 --- a/tests/components/scrape/__init__.py +++ b/tests/components/scrape/__init__.py @@ -2,42 +2,6 @@ from __future__ import annotations from typing import Any -from unittest.mock import patch - -from homeassistant.components.scrape.const import DOMAIN -from homeassistant.config_entries import SOURCE_USER -from homeassistant.core import HomeAssistant - -from tests.common import MockConfigEntry - - -async def init_integration( - hass: HomeAssistant, - config: dict[str, Any], - data: str, - entry_id: str = "1", - source: str = SOURCE_USER, -) -> MockConfigEntry: - """Set up the Scrape integration in Home Assistant.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - source=source, - data={}, - options=config, - entry_id=entry_id, - ) - - config_entry.add_to_hass(hass) - mocker = MockRestData(data) - with patch( - "homeassistant.components.scrape.RestData", - return_value=mocker, - ): - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - return config_entry def return_config( @@ -61,8 +25,6 @@ def return_config( "resource": "https://www.home-assistant.io", "select": select, "name": name, - "index": 0, - "verify_ssl": True, } if attribute: config["attribute"] = attribute @@ -76,7 +38,7 @@ def return_config( config["device_class"] = device_class if state_class: config["state_class"] = state_class - if username: + if authentication: config["authentication"] = authentication config["username"] = username config["password"] = password diff --git a/tests/components/scrape/test_config_flow.py b/tests/components/scrape/test_config_flow.py deleted file mode 100644 index 287004b1dd3..00000000000 --- a/tests/components/scrape/test_config_flow.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Test the Scrape config flow.""" -from __future__ import annotations - -from unittest.mock import patch - -from homeassistant import config_entries -from homeassistant.components.scrape.const import CONF_INDEX, CONF_SELECT, DOMAIN -from homeassistant.const import ( - CONF_NAME, - CONF_RESOURCE, - CONF_VALUE_TEMPLATE, - CONF_VERIFY_SSL, -) -from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import ( - RESULT_TYPE_ABORT, - RESULT_TYPE_CREATE_ENTRY, - RESULT_TYPE_FORM, -) - -from . import MockRestData - -from tests.common import MockConfigEntry - - -async def test_form(hass: HomeAssistant) -> None: - """Test we get the form.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == RESULT_TYPE_FORM - - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=MockRestData("test_scrape_sensor"), - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_RESOURCE: "https://www.home-assistant.io", - CONF_NAME: "Release", - CONF_SELECT: ".current-version h1", - CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "Release" - assert result2["options"] == { - "resource": "https://www.home-assistant.io", - "name": "Release", - "select": ".current-version h1", - "value_template": "{{ value.split(':')[1] }}", - "index": 0.0, - "verify_ssl": True, - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=MockRestData("test_scrape_sensor"), - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_RESOURCE: "https://www.home-assistant.io", - CONF_NAME: "Release", - CONF_SELECT: ".current-version h1", - CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", - CONF_INDEX: 0, - CONF_VERIFY_SSL: True, - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["title"] == "Release" - assert result2["options"] == { - "resource": "https://www.home-assistant.io", - "name": "Release", - "select": ".current-version h1", - "value_template": "{{ value.split(':')[1] }}", - "index": 0, - "verify_ssl": True, - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: - """Test import of yaml already exist.""" - - MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - "resource": "https://www.home-assistant.io", - "name": "Release", - "select": ".current-version h1", - "value_template": "{{ value.split(':')[1] }}", - "index": 0, - "verify_ssl": True, - }, - ).add_to_hass(hass) - - with patch( - "homeassistant.components.scrape.sensor.RestData", - return_value=MockRestData("test_scrape_sensor"), - ), patch( - "homeassistant.components.scrape.async_setup_entry", - return_value=True, - ): - result3 = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_RESOURCE: "https://www.home-assistant.io", - CONF_NAME: "Release", - CONF_SELECT: ".current-version h1", - CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}", - CONF_INDEX: 0, - CONF_VERIFY_SSL: True, - }, - ) - await hass.async_block_till_done() - - assert result3["type"] == RESULT_TYPE_ABORT - assert result3["reason"] == "already_configured" - - -async def test_options_form(hass: HomeAssistant) -> None: - """Test we get the form in options.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - "resource": "https://www.home-assistant.io", - "name": "Release", - "select": ".current-version h1", - "value_template": "{{ value.split(':')[1] }}", - "index": 0, - "verify_ssl": True, - }, - entry_id="1", - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.scrape.RestData", - return_value=MockRestData("test_scrape_sensor"), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - result = await hass.config_entries.options.async_init(entry.entry_id) - - with patch( - "homeassistant.components.scrape.RestData", - return_value=MockRestData("test_scrape_sensor"), - ): - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - "value_template": "{{ value.split(':')[1] }}", - "index": 1.0, - "verify_ssl": True, - }, - ) - - assert result2["type"] == RESULT_TYPE_CREATE_ENTRY - assert result2["data"] == { - "resource": "https://www.home-assistant.io", - "name": "Release", - "select": ".current-version h1", - "value_template": "{{ value.split(':')[1] }}", - "index": 1.0, - "verify_ssl": True, - } - entry_check = hass.config_entries.async_get_entry("1") - assert entry_check.state == config_entries.ConfigEntryState.LOADED - assert entry_check.update_listeners is not None diff --git a/tests/components/scrape/test_init.py b/tests/components/scrape/test_init.py deleted file mode 100644 index 021790e65c3..00000000000 --- a/tests/components/scrape/test_init.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Test Scrape component setup process.""" -from __future__ import annotations - -from unittest.mock import patch - -from homeassistant.components.scrape.const import DOMAIN -from homeassistant.config_entries import ConfigEntryState -from homeassistant.core import HomeAssistant - -from . import MockRestData - -from tests.common import MockConfigEntry - -TEST_CONFIG = { - "resource": "https://www.home-assistant.io", - "name": "Release", - "select": ".current-version h1", - "value_template": "{{ value.split(':')[1] }}", - "index": 0, - "verify_ssl": True, -} - - -async def test_setup_entry(hass: HomeAssistant) -> None: - """Test setup entry.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options=TEST_CONFIG, - title="Release", - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.scrape.RestData", - return_value=MockRestData("test_scrape_sensor"), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.release") - assert state - - -async def test_setup_entry_no_data_fails(hass: HomeAssistant) -> None: - """Test setup entry no data fails.""" - entry = MockConfigEntry( - domain=DOMAIN, data={}, options=TEST_CONFIG, title="Release", entry_id="1" - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.scrape.RestData", - return_value=MockRestData("test_scrape_sensor_no_data"), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.ha_version") - assert state is None - entry = hass.config_entries.async_get_entry("1") - assert entry.state == ConfigEntryState.SETUP_RETRY - - -async def test_remove_entry(hass: HomeAssistant) -> None: - """Test remove entry.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options=TEST_CONFIG, - title="Release", - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.scrape.RestData", - return_value=MockRestData("test_scrape_sensor"), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.release") - assert state - - await hass.config_entries.async_remove(entry.entry_id) - await hass.async_block_till_done() - - state = hass.states.get("sensor.release") - assert not state diff --git a/tests/components/scrape/test_sensor.py b/tests/components/scrape/test_sensor.py index cd4e27e88a2..aaf156208ef 100644 --- a/tests/components/scrape/test_sensor.py +++ b/tests/components/scrape/test_sensor.py @@ -3,15 +3,10 @@ from __future__ import annotations from unittest.mock import patch -import pytest - from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.components.sensor.const import CONF_STATE_CLASS -from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_DEVICE_CLASS, - CONF_NAME, - CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS, @@ -20,20 +15,22 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component -from . import MockRestData, init_integration, return_config - -from tests.common import MockConfigEntry +from . import MockRestData, return_config DOMAIN = "scrape" async def test_scrape_sensor(hass: HomeAssistant) -> None: """Test Scrape sensor minimal.""" - await init_integration( - hass, - return_config(select=".current-version h1", name="HA version"), - "test_scrape_sensor", - ) + config = {"sensor": return_config(select=".current-version h1", name="HA version")} + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") assert state.state == "Current Version: 2021.12.10" @@ -41,15 +38,21 @@ async def test_scrape_sensor(hass: HomeAssistant) -> None: async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: """Test Scrape sensor with value template.""" - await init_integration( - hass, - return_config( + config = { + "sensor": return_config( select=".current-version h1", name="HA version", template="{{ value.split(':')[1] }}", - ), - "test_scrape_sensor", - ) + ) + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") assert state.state == "2021.12.10" @@ -57,18 +60,24 @@ async def test_scrape_sensor_value_template(hass: HomeAssistant) -> None: async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: """Test Scrape sensor for unit of measurement, device class and state class.""" - await init_integration( - hass, - return_config( + config = { + "sensor": return_config( select=".current-temp h3", name="Current Temp", template="{{ value.split(':')[1] }}", uom="°C", device_class="temperature", state_class="measurement", - ), - "test_scrape_uom_and_classes", - ) + ) + } + + mocker = MockRestData("test_scrape_uom_and_classes") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() state = hass.states.get("sensor.current_temp") assert state.state == "22.1" @@ -79,28 +88,31 @@ async def test_scrape_uom_and_classes(hass: HomeAssistant) -> None: async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: """Test Scrape sensor with authentication.""" - await init_integration( - hass, - return_config( - select=".return", - name="Auth page", - username="user@secret.com", - password="12345678", - authentication="digest", - ), - "test_scrape_sensor_authentication", - ) - await init_integration( - hass, - return_config( - select=".return", - name="Auth page2", - username="user@secret.com", - password="12345678", - ), - "test_scrape_sensor_authentication", - entry_id="2", - ) + config = { + "sensor": [ + return_config( + select=".return", + name="Auth page", + username="user@secret.com", + password="12345678", + authentication="digest", + ), + return_config( + select=".return", + name="Auth page2", + username="user@secret.com", + password="12345678", + ), + ] + } + + mocker = MockRestData("test_scrape_sensor_authentication") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() state = hass.states.get("sensor.auth_page") assert state.state == "secret text" @@ -110,11 +122,15 @@ async def test_scrape_sensor_authentication(hass: HomeAssistant) -> None: async def test_scrape_sensor_no_data(hass: HomeAssistant) -> None: """Test Scrape sensor fails on no data.""" - await init_integration( - hass, - return_config(select=".current-version h1", name="HA version"), - "test_scrape_sensor_no_data", - ) + config = {"sensor": return_config(select=".current-version h1", name="HA version")} + + mocker = MockRestData("test_scrape_sensor_no_data") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") assert state is None @@ -122,21 +138,14 @@ async def test_scrape_sensor_no_data(hass: HomeAssistant) -> None: async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: """Test Scrape sensor no data on refresh.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - source=SOURCE_USER, - data={}, - options=return_config(select=".current-version h1", name="HA version"), - entry_id="1", - ) + config = {"sensor": return_config(select=".current-version h1", name="HA version")} - config_entry.add_to_hass(hass) mocker = MockRestData("test_scrape_sensor") with patch( - "homeassistant.components.scrape.RestData", + "homeassistant.components.scrape.sensor.RestData", return_value=mocker, ): - await hass.config_entries.async_setup(config_entry.entry_id) + assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() state = hass.states.get("sensor.ha_version") @@ -153,17 +162,20 @@ async def test_scrape_sensor_no_data_refresh(hass: HomeAssistant) -> None: async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: """Test Scrape sensor with attribute and tag.""" - await init_integration( - hass, - return_config(select="div", name="HA class", index=1, attribute="class"), - "test_scrape_sensor", - ) - await init_integration( - hass, - return_config(select="template", name="HA template"), - "test_scrape_sensor", - entry_id="2", - ) + config = { + "sensor": [ + return_config(select="div", name="HA class", index=1, attribute="class"), + return_config(select="template", name="HA template"), + ] + } + + mocker = MockRestData("test_scrape_sensor") + with patch( + "homeassistant.components.scrape.sensor.RestData", + return_value=mocker, + ): + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() state = hass.states.get("sensor.ha_class") assert state.state == "['links']" @@ -173,55 +185,22 @@ async def test_scrape_sensor_attribute_and_tag(hass: HomeAssistant) -> None: async def test_scrape_sensor_errors(hass: HomeAssistant) -> None: """Test Scrape sensor handle errors.""" - await init_integration( - hass, - return_config(select="div", name="HA class", index=5, attribute="class"), - "test_scrape_sensor", - ) - await init_integration( - hass, - return_config(select="div", name="HA class2", attribute="classes"), - "test_scrape_sensor", - entry_id="2", - ) - - state = hass.states.get("sensor.ha_class") - assert state.state == STATE_UNKNOWN - state2 = hass.states.get("sensor.ha_class2") - assert state2.state == STATE_UNKNOWN - - -async def test_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: - """Test the Scrape sensor import.""" config = { - "sensor": { - "platform": "scrape", - "resource": "https://www.home-assistant.io", - "select": ".current-version h1", - "name": "HA Version", - "index": 0, - "verify_ssl": True, - "value_template": "{{ value.split(':')[1] }}", - } + "sensor": [ + return_config(select="div", name="HA class", index=5, attribute="class"), + return_config(select="div", name="HA class2", attribute="classes"), + ] } mocker = MockRestData("test_scrape_sensor") with patch( - "homeassistant.components.scrape.RestData", + "homeassistant.components.scrape.sensor.RestData", return_value=mocker, ): assert await async_setup_component(hass, "sensor", config) await hass.async_block_till_done() - assert ( - "Loading Scrape via platform setup has been deprecated in Home Assistant" - in caplog.text - ) - - assert hass.config_entries.async_entries(DOMAIN) - options = hass.config_entries.async_entries(DOMAIN)[0].options - assert options[CONF_NAME] == "HA Version" - assert options[CONF_RESOURCE] == "https://www.home-assistant.io" - - state = hass.states.get("sensor.ha_version") - assert state.state == "2021.12.10" + state = hass.states.get("sensor.ha_class") + assert state.state == STATE_UNKNOWN + state2 = hass.states.get("sensor.ha_class2") + assert state2.state == STATE_UNKNOWN From bc41832c71ee41df9cc2070e044697fbded1f5fa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 Jul 2022 10:12:02 -0700 Subject: [PATCH 906/947] Bumped version to 2022.7.0b2 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 60a3d6817b5..de1148a3932 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index 2b1e14a40f6..92a7a962cbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.0b1" +version = "2022.7.0b2" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 4e793a51ba1da105846544bb3b73400cc6796be8 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Sun, 3 Jul 2022 21:26:00 +0100 Subject: [PATCH 907/947] Dont substitute user/pass for relative stream urls on generic camera (#74201) Co-authored-by: Dave T --- homeassistant/components/generic/camera.py | 8 +++- .../components/generic/config_flow.py | 13 +++++- homeassistant/components/generic/strings.json | 4 ++ .../components/generic/translations/en.json | 11 ++--- tests/components/generic/test_config_flow.py | 46 +++++++++++++++++-- 5 files changed, 68 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py index 8b03f0a8ed3..961d3cecfb7 100644 --- a/homeassistant/components/generic/camera.py +++ b/homeassistant/components/generic/camera.py @@ -228,7 +228,13 @@ class GenericCamera(Camera): try: stream_url = self._stream_source.async_render(parse_result=False) url = yarl.URL(stream_url) - if not url.user and not url.password and self._username and self._password: + if ( + not url.user + and not url.password + and self._username + and self._password + and url.is_absolute() + ): url = url.with_user(self._username).with_password(self._password) return str(url) except TemplateError as err: diff --git a/homeassistant/components/generic/config_flow.py b/homeassistant/components/generic/config_flow.py index 9096f2ce87e..514264f919e 100644 --- a/homeassistant/components/generic/config_flow.py +++ b/homeassistant/components/generic/config_flow.py @@ -150,6 +150,12 @@ async def async_test_still( except TemplateError as err: _LOGGER.warning("Problem rendering template %s: %s", url, err) return {CONF_STILL_IMAGE_URL: "template_error"}, None + try: + yarl_url = yarl.URL(url) + except ValueError: + return {CONF_STILL_IMAGE_URL: "malformed_url"}, None + if not yarl_url.is_absolute(): + return {CONF_STILL_IMAGE_URL: "relative_url"}, None verify_ssl = info[CONF_VERIFY_SSL] auth = generate_auth(info) try: @@ -222,7 +228,12 @@ async def async_test_stream( if info.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS): stream_options[CONF_USE_WALLCLOCK_AS_TIMESTAMPS] = True - url = yarl.URL(stream_source) + try: + url = yarl.URL(stream_source) + except ValueError: + return {CONF_STREAM_SOURCE: "malformed_url"} + if not url.is_absolute(): + return {CONF_STREAM_SOURCE: "relative_url"} if not url.user and not url.password: username = info.get(CONF_USERNAME) password = info.get(CONF_PASSWORD) diff --git a/homeassistant/components/generic/strings.json b/homeassistant/components/generic/strings.json index 7d3cab19aa5..608c85c1379 100644 --- a/homeassistant/components/generic/strings.json +++ b/homeassistant/components/generic/strings.json @@ -6,6 +6,8 @@ "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", + "relative_url": "Relative URLs are not allowed", "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", @@ -75,6 +77,8 @@ "unable_still_load": "[%key:component::generic::config::error::unable_still_load%]", "no_still_image_or_stream_url": "[%key:component::generic::config::error::no_still_image_or_stream_url%]", "invalid_still_image": "[%key:component::generic::config::error::invalid_still_image%]", + "malformed_url": "[%key:component::generic::config::error::malformed_url%]", + "relative_url": "[%key:component::generic::config::error::relative_url%]", "template_error": "[%key:component::generic::config::error::template_error%]", "timeout": "[%key:component::generic::config::error::timeout%]", "stream_no_route_to_host": "[%key:component::generic::config::error::stream_no_route_to_host%]", diff --git a/homeassistant/components/generic/translations/en.json b/homeassistant/components/generic/translations/en.json index d01e6e59a4b..cb2200f9755 100644 --- a/homeassistant/components/generic/translations/en.json +++ b/homeassistant/components/generic/translations/en.json @@ -1,20 +1,19 @@ { "config": { "abort": { - "no_devices_found": "No devices found on the network", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "error": { "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", + "relative_url": "Relative URLs are not allowed", "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", @@ -50,14 +49,12 @@ "error": { "already_exists": "A camera with these URL settings already exists.", "invalid_still_image": "URL did not return a valid still image", + "malformed_url": "Malformed URL", "no_still_image_or_stream_url": "You must specify at least a still image or stream URL", - "stream_file_not_found": "File not found while trying to connect to stream (is ffmpeg installed?)", - "stream_http_not_found": "HTTP 404 Not found while trying to connect to stream", + "relative_url": "Relative URLs are not allowed", "stream_io_error": "Input/Output error while trying to connect to stream. Wrong RTSP transport protocol?", "stream_no_route_to_host": "Could not find host while trying to connect to stream", - "stream_no_video": "Stream has no video", "stream_not_permitted": "Operation not permitted while trying to connect to stream. Wrong RTSP transport protocol?", - "stream_unauthorised": "Authorisation failed while trying to connect to stream", "template_error": "Error rendering template. Review log for more info.", "timeout": "Timeout while loading URL", "unable_still_load": "Unable to load valid image from still image URL (e.g. invalid host, URL or authentication failure). Review log for more info.", diff --git a/tests/components/generic/test_config_flow.py b/tests/components/generic/test_config_flow.py index f0589301014..592d139f92e 100644 --- a/tests/components/generic/test_config_flow.py +++ b/tests/components/generic/test_config_flow.py @@ -180,33 +180,43 @@ async def test_form_only_still_sample(hass, user_flow, image_file): @respx.mock @pytest.mark.parametrize( - ("template", "url", "expected_result"), + ("template", "url", "expected_result", "expected_errors"), [ # Test we can handle templates in strange parts of the url, #70961. ( "http://localhost:812{{3}}/static/icons/favicon-apple-180x180.png", "http://localhost:8123/static/icons/favicon-apple-180x180.png", data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + None, ), ( "{% if 1 %}https://bla{% else %}https://yo{% endif %}", "https://bla/", data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + None, ), ( "http://{{example.org", "http://example.org", data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "template_error"}, ), ( "invalid1://invalid:4\\1", "invalid1://invalid:4%5c1", - data_entry_flow.RESULT_TYPE_CREATE_ENTRY, + data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "malformed_url"}, + ), + ( + "relative/urls/are/not/allowed.jpg", + "relative/urls/are/not/allowed.jpg", + data_entry_flow.RESULT_TYPE_FORM, + {"still_image_url": "relative_url"}, ), ], ) async def test_still_template( - hass, user_flow, fakeimgbytes_png, template, url, expected_result + hass, user_flow, fakeimgbytes_png, template, url, expected_result, expected_errors ) -> None: """Test we can handle various templates.""" respx.get(url).respond(stream=fakeimgbytes_png) @@ -220,6 +230,7 @@ async def test_still_template( ) await hass.async_block_till_done() assert result2["type"] == expected_result + assert result2.get("errors") == expected_errors @respx.mock @@ -514,8 +525,29 @@ async def test_options_template_error(hass, fakeimgbytes_png, mock_create_stream result4["flow_id"], user_input=data, ) - assert result5.get("type") == data_entry_flow.RESULT_TYPE_FORM - assert result5["errors"] == {"stream_source": "template_error"} + + assert result5.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result5["errors"] == {"stream_source": "template_error"} + + # verify that an relative stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "relative/stream.mjpeg" + result6 = await hass.config_entries.options.async_configure( + result5["flow_id"], + user_input=data, + ) + assert result6.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result6["errors"] == {"stream_source": "relative_url"} + + # verify that an malformed stream url is rejected. + data[CONF_STILL_IMAGE_URL] = "http://127.0.0.1/testurl/1" + data[CONF_STREAM_SOURCE] = "http://example.com:45:56" + result7 = await hass.config_entries.options.async_configure( + result6["flow_id"], + user_input=data, + ) + assert result7.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result7["errors"] == {"stream_source": "malformed_url"} async def test_slug(hass, caplog): @@ -528,6 +560,10 @@ async def test_slug(hass, caplog): assert result is None assert "Syntax error in" in caplog.text + result = slug(hass, "http://example.com:999999999999/stream") + assert result is None + assert "Syntax error in" in caplog.text + @respx.mock async def test_options_only_stream(hass, fakeimgbytes_png, mock_create_stream): From 373347950cc963a7fe14b2f7a91a0b1210fb9f9f Mon Sep 17 00:00:00 2001 From: mbo18 Date: Sun, 3 Jul 2022 22:49:03 +0200 Subject: [PATCH 908/947] Migrate Meteo_france to native_* (#74297) --- .../components/meteo_france/weather.py | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index cca1f6fe684..a30a65304b0 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -4,16 +4,22 @@ import time from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_MODE, TEMP_CELSIUS +from homeassistant.const import ( + CONF_MODE, + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -71,6 +77,11 @@ async def async_setup_entry( class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + def __init__(self, coordinator: DataUpdateCoordinator, mode: str) -> None: """Initialise the platform with a data instance and station name.""" super().__init__(coordinator) @@ -107,17 +118,12 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): ) @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data.current_forecast["T"]["value"] @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.coordinator.data.current_forecast["sea_level"] @@ -127,10 +133,9 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): return self.coordinator.data.current_forecast["humidity"] @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - # convert from API m/s to km/h - return round(self.coordinator.data.current_forecast["wind"]["speed"] * 3.6) + return self.coordinator.data.current_forecast["wind"]["speed"] @property def wind_bearing(self): @@ -158,9 +163,9 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): ATTR_FORECAST_CONDITION: format_condition( forecast["weather"]["desc"] ), - ATTR_FORECAST_TEMP: forecast["T"]["value"], - ATTR_FORECAST_PRECIPITATION: forecast["rain"].get("1h"), - ATTR_FORECAST_WIND_SPEED: forecast["wind"]["speed"], + ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["value"], + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast["rain"].get("1h"), + ATTR_FORECAST_NATIVE_WIND_SPEED: forecast["wind"]["speed"], ATTR_FORECAST_WIND_BEARING: forecast["wind"]["direction"] if forecast["wind"]["direction"] != -1 else None, @@ -179,9 +184,11 @@ class MeteoFranceWeather(CoordinatorEntity, WeatherEntity): ATTR_FORECAST_CONDITION: format_condition( forecast["weather12H"]["desc"] ), - ATTR_FORECAST_TEMP: forecast["T"]["max"], - ATTR_FORECAST_TEMP_LOW: forecast["T"]["min"], - ATTR_FORECAST_PRECIPITATION: forecast["precipitation"]["24h"], + ATTR_FORECAST_NATIVE_TEMP: forecast["T"]["max"], + ATTR_FORECAST_NATIVE_TEMP_LOW: forecast["T"]["min"], + ATTR_FORECAST_NATIVE_PRECIPITATION: forecast["precipitation"][ + "24h" + ], } ) return forecast_data From 08ee73d671697cdfef86aaa4fea86395ef7fe6c0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 Jul 2022 11:01:07 -0700 Subject: [PATCH 909/947] Guard creating areas in onboarding (#74306) --- homeassistant/components/onboarding/views.py | 8 +++++--- tests/components/onboarding/test_views.py | 7 ++++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 7f40ad87e84..c29fb7edf3a 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -156,9 +156,11 @@ class UserOnboardingView(_BaseOnboardingView): area_registry = ar.async_get(hass) for area in DEFAULT_AREAS: - area_registry.async_create( - translations[f"component.onboarding.area.{area}"] - ) + name = translations[f"component.onboarding.area.{area}"] + # Guard because area might have been created by an automatically + # set up integration. + if not area_registry.async_get_area_by_name(name): + area_registry.async_create(name) await self._async_mark_done(hass) diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 982f5b86e65..204eb6bf772 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -144,6 +144,12 @@ async def test_onboarding_user_already_done(hass, hass_storage, hass_client_no_a async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): """Test creating a new user.""" + area_registry = ar.async_get(hass) + + # Create an existing area to mimic an integration creating an area + # before onboarding is done. + area_registry.async_create("Living Room") + assert await async_setup_component(hass, "person", {}) assert await async_setup_component(hass, "onboarding", {}) await hass.async_block_till_done() @@ -194,7 +200,6 @@ async def test_onboarding_user(hass, hass_storage, hass_client_no_auth): ) # Validate created areas - area_registry = ar.async_get(hass) assert len(area_registry.areas) == 3 assert sorted(area.name for area in area_registry.async_list_areas()) == [ "Bedroom", From 0ca4e81e13295d4ff72af033597a1cfe01722bd6 Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Sat, 2 Jul 2022 21:27:47 +0100 Subject: [PATCH 910/947] Migrate metoffice to native_* (#74312) --- homeassistant/components/metoffice/weather.py | 25 ++++++++-------- tests/components/metoffice/test_weather.py | 29 +++++++++++-------- 2 files changed, 29 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index daf37bcf83f..2e9a55b4415 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -1,15 +1,15 @@ """Support for UK Met Office weather service.""" from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import PRESSURE_HPA, SPEED_MILES_PER_HOUR, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -55,11 +55,11 @@ def _build_forecast_data(timestep): if timestep.precipitation: data[ATTR_FORECAST_PRECIPITATION_PROBABILITY] = timestep.precipitation.value if timestep.temperature: - data[ATTR_FORECAST_TEMP] = timestep.temperature.value + data[ATTR_FORECAST_NATIVE_TEMP] = timestep.temperature.value if timestep.wind_direction: data[ATTR_FORECAST_WIND_BEARING] = timestep.wind_direction.value if timestep.wind_speed: - data[ATTR_FORECAST_WIND_SPEED] = timestep.wind_speed.value + data[ATTR_FORECAST_NATIVE_WIND_SPEED] = timestep.wind_speed.value return data @@ -73,6 +73,10 @@ def _get_weather_condition(metoffice_code): class MetOfficeWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Office weather condition.""" + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_wind_speed_unit = SPEED_MILES_PER_HOUR + def __init__(self, coordinator, hass_data, use_3hourly): """Initialise the platform with a data instance.""" super().__init__(coordinator) @@ -94,17 +98,12 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): return None @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" if self.coordinator.data.now.temperature: return self.coordinator.data.now.temperature.value return None - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def visibility(self): """Return the platform visibility.""" @@ -119,7 +118,7 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): return _visibility @property - def pressure(self): + def native_pressure(self): """Return the mean sea-level pressure.""" weather_now = self.coordinator.data.now if weather_now and weather_now.pressure: @@ -135,7 +134,7 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): return None @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" weather_now = self.coordinator.data.now if weather_now and weather_now.wind_speed: diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index bf279ff3cf7..3b7ebd08678 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -133,7 +133,8 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 17 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 @@ -148,7 +149,7 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.attributes.get("forecast")[26]["condition"] == "cloudy" assert weather.attributes.get("forecast")[26]["precipitation_probability"] == 9 assert weather.attributes.get("forecast")[26]["temperature"] == 10 - assert weather.attributes.get("forecast")[26]["wind_speed"] == 4 + assert weather.attributes.get("forecast")[26]["wind_speed"] == 6.44 assert weather.attributes.get("forecast")[26]["wind_bearing"] == "NNE" # Wavertree daily weather platform expected results @@ -157,7 +158,7 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 19 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 assert weather.attributes.get("wind_bearing") == "SSE" assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 @@ -172,7 +173,7 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.attributes.get("forecast")[3]["condition"] == "rainy" assert weather.attributes.get("forecast")[3]["precipitation_probability"] == 59 assert weather.attributes.get("forecast")[3]["temperature"] == 13 - assert weather.attributes.get("forecast")[3]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[3]["wind_speed"] == 20.92 assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" @@ -229,7 +230,8 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 17 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 @@ -244,7 +246,7 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[18]["condition"] == "clear-night" assert weather.attributes.get("forecast")[18]["precipitation_probability"] == 1 assert weather.attributes.get("forecast")[18]["temperature"] == 9 - assert weather.attributes.get("forecast")[18]["wind_speed"] == 4 + assert weather.attributes.get("forecast")[18]["wind_speed"] == 6.44 assert weather.attributes.get("forecast")[18]["wind_bearing"] == "NW" # Wavertree daily weather platform expected results @@ -253,7 +255,8 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 19 - assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_speed") == 14.48 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 @@ -268,7 +271,7 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[3]["condition"] == "rainy" assert weather.attributes.get("forecast")[3]["precipitation_probability"] == 59 assert weather.attributes.get("forecast")[3]["temperature"] == 13 - assert weather.attributes.get("forecast")[3]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[3]["wind_speed"] == 20.92 assert weather.attributes.get("forecast")[3]["wind_bearing"] == "SE" # King's Lynn 3-hourly weather platform expected results @@ -277,7 +280,8 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "sunny" assert weather.attributes.get("temperature") == 14 - assert weather.attributes.get("wind_speed") == 2 + assert weather.attributes.get("wind_speed") == 3.22 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "E" assert weather.attributes.get("visibility") == "Very Good - 20-40" assert weather.attributes.get("humidity") == 60 @@ -292,7 +296,7 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[18]["condition"] == "cloudy" assert weather.attributes.get("forecast")[18]["precipitation_probability"] == 9 assert weather.attributes.get("forecast")[18]["temperature"] == 10 - assert weather.attributes.get("forecast")[18]["wind_speed"] == 7 + assert weather.attributes.get("forecast")[18]["wind_speed"] == 11.27 assert weather.attributes.get("forecast")[18]["wind_bearing"] == "SE" # King's Lynn daily weather platform expected results @@ -301,7 +305,8 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.state == "cloudy" assert weather.attributes.get("temperature") == 9 - assert weather.attributes.get("wind_speed") == 4 + assert weather.attributes.get("wind_speed") == 6.44 + assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "ESE" assert weather.attributes.get("visibility") == "Very Good - 20-40" assert weather.attributes.get("humidity") == 75 @@ -316,5 +321,5 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("forecast")[2]["condition"] == "cloudy" assert weather.attributes.get("forecast")[2]["precipitation_probability"] == 14 assert weather.attributes.get("forecast")[2]["temperature"] == 11 - assert weather.attributes.get("forecast")[2]["wind_speed"] == 7 + assert weather.attributes.get("forecast")[2]["wind_speed"] == 11.27 assert weather.attributes.get("forecast")[2]["wind_bearing"] == "ESE" From ec036313395090f3cff86d4aedbc8b425ab73eac Mon Sep 17 00:00:00 2001 From: avee87 <6134677+avee87@users.noreply.github.com> Date: Sat, 2 Jul 2022 21:42:58 +0100 Subject: [PATCH 911/947] Remove visibility from metoffice weather (#74314) Co-authored-by: Paulus Schoutsen --- homeassistant/components/metoffice/weather.py | 15 --------------- tests/components/metoffice/test_weather.py | 6 ------ 2 files changed, 21 deletions(-) diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 2e9a55b4415..f4e0bf61d30 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -27,8 +27,6 @@ from .const import ( MODE_3HOURLY_LABEL, MODE_DAILY, MODE_DAILY_LABEL, - VISIBILITY_CLASSES, - VISIBILITY_DISTANCE_CLASSES, ) @@ -104,19 +102,6 @@ class MetOfficeWeather(CoordinatorEntity, WeatherEntity): return self.coordinator.data.now.temperature.value return None - @property - def visibility(self): - """Return the platform visibility.""" - _visibility = None - weather_now = self.coordinator.data.now - if hasattr(weather_now, "visibility"): - visibility_class = VISIBILITY_CLASSES.get(weather_now.visibility.value) - visibility_distance = VISIBILITY_DISTANCE_CLASSES.get( - weather_now.visibility.value - ) - _visibility = f"{visibility_class} - {visibility_distance}" - return _visibility - @property def native_pressure(self): """Return the mean sea-level pressure.""" diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 3b7ebd08678..a93b1ea6b62 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -136,7 +136,6 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.attributes.get("wind_speed") == 14.48 assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Forecasts added - just pick out 1 entry to check @@ -160,7 +159,6 @@ async def test_one_weather_site_running(hass, requests_mock): assert weather.attributes.get("temperature") == 19 assert weather.attributes.get("wind_speed") == 14.48 assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check @@ -233,7 +231,6 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("wind_speed") == 14.48 assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Forecasts added - just pick out 1 entry to check @@ -258,7 +255,6 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("wind_speed") == 14.48 assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "SSE" - assert weather.attributes.get("visibility") == "Good - 10-20" assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check @@ -283,7 +279,6 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("wind_speed") == 3.22 assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "E" - assert weather.attributes.get("visibility") == "Very Good - 20-40" assert weather.attributes.get("humidity") == 60 # Also has Forecast added - just pick out 1 entry to check @@ -308,7 +303,6 @@ async def test_two_weather_sites_running(hass, requests_mock): assert weather.attributes.get("wind_speed") == 6.44 assert weather.attributes.get("wind_speed_unit") == "km/h" assert weather.attributes.get("wind_bearing") == "ESE" - assert weather.attributes.get("visibility") == "Very Good - 20-40" assert weather.attributes.get("humidity") == 75 # All should have Forecast added - again, just picking out 1 entry to check From fe437cc9b7fe03db5b96f8451fb4272e996a4ea4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 2 Jul 2022 22:10:38 +0200 Subject: [PATCH 912/947] Add configuration directory to system health (#74318) --- homeassistant/components/homeassistant/strings.json | 3 ++- homeassistant/components/homeassistant/system_health.py | 1 + homeassistant/components/homeassistant/translations/en.json | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 43180b237b9..317e9d1dfcd 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -2,15 +2,16 @@ "system_health": { "info": { "arch": "CPU Architecture", + "config_dir": "Configuration Directory", "dev": "Development", "docker": "Docker", - "user": "User", "hassio": "Supervisor", "installation_type": "Installation Type", "os_name": "Operating System Family", "os_version": "Operating System Version", "python_version": "Python Version", "timezone": "Timezone", + "user": "User", "version": "Version", "virtualenv": "Virtual Environment" } diff --git a/homeassistant/components/homeassistant/system_health.py b/homeassistant/components/homeassistant/system_health.py index f13278ddfeb..4006228de25 100644 --- a/homeassistant/components/homeassistant/system_health.py +++ b/homeassistant/components/homeassistant/system_health.py @@ -29,4 +29,5 @@ async def system_health_info(hass): "os_version": info.get("os_version"), "arch": info.get("arch"), "timezone": info.get("timezone"), + "config_dir": hass.config.config_dir, } diff --git a/homeassistant/components/homeassistant/translations/en.json b/homeassistant/components/homeassistant/translations/en.json index 977bc203fea..37c4498b32b 100644 --- a/homeassistant/components/homeassistant/translations/en.json +++ b/homeassistant/components/homeassistant/translations/en.json @@ -2,6 +2,7 @@ "system_health": { "info": { "arch": "CPU Architecture", + "config_dir": "Configuration Directory", "dev": "Development", "docker": "Docker", "hassio": "Supervisor", From 2a1a6301a9d5f060b6cfae2f64c3141d37e7ee90 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sun, 3 Jul 2022 22:53:44 +0200 Subject: [PATCH 913/947] Fix unique id issue for OpenWeatherMap (#74335) --- .../components/openweathermap/const.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index f180f2a9bbf..06f13daa9c2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -22,10 +22,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TIME, ) @@ -72,6 +68,11 @@ ATTR_API_FORECAST = "forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] +ATTR_FORECAST_PRECIPITATION = "precipitation" +ATTR_FORECAST_PRESSURE = "pressure" +ATTR_FORECAST_TEMP = "temperature" +ATTR_FORECAST_TEMP_LOW = "templow" + FORECAST_MODE_HOURLY = "hourly" FORECAST_MODE_DAILY = "daily" FORECAST_MODE_FREE_DAILY = "freedaily" @@ -266,7 +267,7 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_PRECIPITATION, + key=ATTR_FORECAST_PRECIPITATION, name="Precipitation", native_unit_of_measurement=LENGTH_MILLIMETERS, ), @@ -276,19 +277,19 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_PRESSURE, + key=ATTR_FORECAST_PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_TEMP, + key=ATTR_FORECAST_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_TEMP_LOW, + key=ATTR_FORECAST_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, From c2072cc92b5da2eefe322bd93269b06c9fd90a4b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Jul 2022 15:48:34 -0500 Subject: [PATCH 914/947] Fix esphome state mapping (#74337) --- homeassistant/components/esphome/__init__.py | 17 +++--- .../components/esphome/entry_data.py | 53 ++++--------------- 2 files changed, 22 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 0c1eac3aa45..ddedaf11ceb 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -558,7 +558,7 @@ async def platform_async_setup_entry( entry_data: RuntimeEntryData = DomainData.get(hass).get_entry_data(entry) entry_data.info[component_key] = {} entry_data.old_info[component_key] = {} - entry_data.state[component_key] = {} + entry_data.state.setdefault(state_type, {}) @callback def async_list_entities(infos: list[EntityInfo]) -> None: @@ -578,7 +578,7 @@ async def platform_async_setup_entry( old_infos.pop(info.key) else: # Create new entity - entity = entity_type(entry_data, component_key, info.key) + entity = entity_type(entry_data, component_key, info.key, state_type) add_entities.append(entity) new_infos[info.key] = info @@ -677,12 +677,17 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): """Define a base esphome entity.""" def __init__( - self, entry_data: RuntimeEntryData, component_key: str, key: int + self, + entry_data: RuntimeEntryData, + component_key: str, + key: int, + state_type: type[_StateT], ) -> None: """Initialize.""" self._entry_data = entry_data self._component_key = component_key self._key = key + self._state_type = state_type async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -707,7 +712,7 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): self.async_on_remove( self._entry_data.async_subscribe_state_update( - self._component_key, self._key, self._on_state_update + self._state_type, self._key, self._on_state_update ) ) @@ -755,11 +760,11 @@ class EsphomeEntity(Entity, Generic[_InfoT, _StateT]): @property def _state(self) -> _StateT: - return cast(_StateT, self._entry_data.state[self._component_key][self._key]) + return cast(_StateT, self._entry_data.state[self._state_type][self._key]) @property def _has_state(self) -> bool: - return self._key in self._entry_data.state[self._component_key] + return self._key in self._entry_data.state[self._state_type] @property def available(self) -> bool: diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 8eb56e6fdb6..41a0e89245e 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -12,34 +12,21 @@ from aioesphomeapi import ( APIClient, APIVersion, BinarySensorInfo, - BinarySensorState, CameraInfo, - CameraState, ClimateInfo, - ClimateState, CoverInfo, - CoverState, DeviceInfo, EntityInfo, EntityState, FanInfo, - FanState, LightInfo, - LightState, LockInfo, - LockState, MediaPlayerInfo, - MediaPlayerState, NumberInfo, - NumberState, SelectInfo, - SelectState, SensorInfo, - SensorState, SwitchInfo, - SwitchState, TextSensorInfo, - TextSensorState, UserService, ) from aioesphomeapi.model import ButtonInfo @@ -56,8 +43,8 @@ _LOGGER = logging.getLogger(__name__) # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { BinarySensorInfo: Platform.BINARY_SENSOR, - ButtonInfo: Platform.BINARY_SENSOR, - CameraInfo: Platform.BINARY_SENSOR, + ButtonInfo: Platform.BUTTON, + CameraInfo: Platform.CAMERA, ClimateInfo: Platform.CLIMATE, CoverInfo: Platform.COVER, FanInfo: Platform.FAN, @@ -71,23 +58,6 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], str] = { TextSensorInfo: Platform.SENSOR, } -STATE_TYPE_TO_COMPONENT_KEY = { - BinarySensorState: Platform.BINARY_SENSOR, - EntityState: Platform.BINARY_SENSOR, - CameraState: Platform.BINARY_SENSOR, - ClimateState: Platform.CLIMATE, - CoverState: Platform.COVER, - FanState: Platform.FAN, - LightState: Platform.LIGHT, - LockState: Platform.LOCK, - MediaPlayerState: Platform.MEDIA_PLAYER, - NumberState: Platform.NUMBER, - SelectState: Platform.SELECT, - SensorState: Platform.SENSOR, - SwitchState: Platform.SWITCH, - TextSensorState: Platform.SENSOR, -} - @dataclass class RuntimeEntryData: @@ -96,7 +66,7 @@ class RuntimeEntryData: entry_id: str client: APIClient store: Store - state: dict[str, dict[int, EntityState]] = field(default_factory=dict) + state: dict[type[EntityState], dict[int, EntityState]] = field(default_factory=dict) info: dict[str, dict[int, EntityInfo]] = field(default_factory=dict) # A second list of EntityInfo objects @@ -111,9 +81,9 @@ class RuntimeEntryData: api_version: APIVersion = field(default_factory=APIVersion) cleanup_callbacks: list[Callable[[], None]] = field(default_factory=list) disconnect_callbacks: list[Callable[[], None]] = field(default_factory=list) - state_subscriptions: dict[tuple[str, int], Callable[[], None]] = field( - default_factory=dict - ) + state_subscriptions: dict[ + tuple[type[EntityState], int], Callable[[], None] + ] = field(default_factory=dict) loaded_platforms: set[str] = field(default_factory=set) platform_load_lock: asyncio.Lock = field(default_factory=asyncio.Lock) _storage_contents: dict[str, Any] | None = None @@ -160,24 +130,23 @@ class RuntimeEntryData: @callback def async_subscribe_state_update( self, - component_key: str, + state_type: type[EntityState], state_key: int, entity_callback: Callable[[], None], ) -> Callable[[], None]: """Subscribe to state updates.""" def _unsubscribe() -> None: - self.state_subscriptions.pop((component_key, state_key)) + self.state_subscriptions.pop((state_type, state_key)) - self.state_subscriptions[(component_key, state_key)] = entity_callback + self.state_subscriptions[(state_type, state_key)] = entity_callback return _unsubscribe @callback def async_update_state(self, state: EntityState) -> None: """Distribute an update of state information to the target.""" - component_key = STATE_TYPE_TO_COMPONENT_KEY[type(state)] - subscription_key = (component_key, state.key) - self.state[component_key][state.key] = state + subscription_key = (type(state), state.key) + self.state[type(state)][state.key] = state _LOGGER.debug( "Dispatching update with key %s: %s", subscription_key, From c530e965f83940412c9a9d92210521bb7c1aab63 Mon Sep 17 00:00:00 2001 From: shbatm Date: Sat, 2 Jul 2022 20:38:48 -0500 Subject: [PATCH 915/947] Onvif: bump onvif-zeep-async to 1.2.1 (#74341) * Update requirements_all.txt * Update requirements_test_all.txt * Update manifest.json --- homeassistant/components/onvif/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index cd220500751..2df7c3004f0 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -2,7 +2,7 @@ "domain": "onvif", "name": "ONVIF", "documentation": "https://www.home-assistant.io/integrations/onvif", - "requirements": ["onvif-zeep-async==1.2.0", "WSDiscovery==2.0.0"], + "requirements": ["onvif-zeep-async==1.2.1", "WSDiscovery==2.0.0"], "dependencies": ["ffmpeg"], "codeowners": ["@hunterjm"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 84501dbce79..8812e1b8cfe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1150,7 +1150,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==1.2.0 +onvif-zeep-async==1.2.1 # homeassistant.components.opengarage open-garage==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de9ca72060d..dd2f423a7cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -794,7 +794,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==1.2.0 +onvif-zeep-async==1.2.1 # homeassistant.components.opengarage open-garage==0.2.0 From b7a02d946589eaa63ff951ff46f81073fdb652a7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Jul 2022 13:56:29 -0700 Subject: [PATCH 916/947] Bumped version to 2022.7.0b3 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index de1148a3932..c07650c8ac1 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index 92a7a962cbb..57707cf8af1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.0b2" +version = "2022.7.0b3" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 3aafa0cf498849593f31ed18d2ff09952e600e2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Jul 2022 14:06:32 +0200 Subject: [PATCH 917/947] Migrate aemet to native_* (#74037) --- homeassistant/components/aemet/__init__.py | 39 ++++++++- homeassistant/components/aemet/const.py | 26 ++++-- homeassistant/components/aemet/sensor.py | 16 ++-- homeassistant/components/aemet/weather.py | 20 +++-- .../aemet/weather_update_coordinator.py | 20 ++--- tests/components/aemet/test_init.py | 84 +++++++++++++++++++ 6 files changed, 168 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index a914a23a0da..7b86a5559e0 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,18 +1,30 @@ """The AEMET OpenData component.""" +from __future__ import annotations + import logging +from typing import Any from aemet_opendata.interface import AEMET from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + Platform, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from .const import ( CONF_STATION_UPDATES, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, + FORECAST_MODES, PLATFORMS, + RENAMED_FORECAST_SENSOR_KEYS, ) from .weather_update_coordinator import WeatherUpdateCoordinator @@ -21,6 +33,8 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AEMET OpenData as config entry.""" + await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) + name = entry.data[CONF_NAME] api_key = entry.data[CONF_API_KEY] latitude = entry.data[CONF_LATITUDE] @@ -60,3 +74,24 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +@callback +def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: + """Migrate AEMET entity entries. + + - Migrates unique ID from old forecast sensors to the new unique ID + """ + if entry.domain != Platform.SENSOR: + return None + for old_key, new_key in RENAMED_FORECAST_SENSOR_KEYS.items(): + for forecast_mode in FORECAST_MODES: + old_suffix = f"-forecast-{forecast_mode}-{old_key}" + if entry.unique_id.endswith(old_suffix): + new_suffix = f"-forecast-{forecast_mode}-{new_key}" + return { + "new_unique_id": entry.unique_id.replace(old_suffix, new_suffix) + } + + # No migration needed + return None diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 4be90011f5a..48e7335934f 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -18,6 +18,10 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TEMP, @@ -159,13 +163,13 @@ CONDITIONS_MAP = { FORECAST_MONITORED_CONDITIONS = [ ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, + ATTR_FORECAST_NATIVE_WIND_SPEED, ] MONITORED_CONDITIONS = [ ATTR_API_CONDITION, @@ -206,7 +210,7 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION, + key=ATTR_FORECAST_NATIVE_PRECIPITATION, name="Precipitation", native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, ), @@ -216,13 +220,13 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP, + key=ATTR_FORECAST_NATIVE_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP_LOW, + key=ATTR_FORECAST_NATIVE_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -238,11 +242,17 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, ), SensorEntityDescription( - key=ATTR_FORECAST_WIND_SPEED, + key=ATTR_FORECAST_NATIVE_WIND_SPEED, name="Wind speed", native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, ), ) +RENAMED_FORECAST_SENSOR_KEYS = { + ATTR_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, +} WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_CONDITION, diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f98e3fff49e..8439b166a47 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -45,17 +45,13 @@ async def async_setup_entry( entities.extend( [ AemetForecastSensor( - name_prefix, - unique_id_prefix, + f"{domain_data[ENTRY_NAME]} {mode} Forecast", + f"{unique_id}-forecast-{mode}", weather_coordinator, mode, description, ) for mode in FORECAST_MODES - if ( - (name_prefix := f"{domain_data[ENTRY_NAME]} {mode} Forecast") - and (unique_id_prefix := f"{unique_id}-forecast-{mode}") - ) for description in FORECAST_SENSOR_TYPES if description.key in FORECAST_MONITORED_CONDITIONS ] @@ -89,14 +85,14 @@ class AemetSensor(AbstractAemetSensor): def __init__( self, name, - unique_id, + unique_id_prefix, weather_coordinator: WeatherUpdateCoordinator, description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id}-{description.key}", + unique_id=f"{unique_id_prefix}-{description.key}", coordinator=weather_coordinator, description=description, ) @@ -113,7 +109,7 @@ class AemetForecastSensor(AbstractAemetSensor): def __init__( self, name, - unique_id, + unique_id_prefix, weather_coordinator: WeatherUpdateCoordinator, forecast_mode, description: SensorEntityDescription, @@ -121,7 +117,7 @@ class AemetForecastSensor(AbstractAemetSensor): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id}-{description.key}", + unique_id=f"{unique_id_prefix}-{description.key}", coordinator=weather_coordinator, description=description, ) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index d05442b621e..a67726d1f51 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,7 +1,12 @@ """Support for the AEMET OpenData service.""" from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -47,9 +52,10 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION - _attr_temperature_unit = TEMP_CELSIUS - _attr_pressure_unit = PRESSURE_HPA - _attr_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR def __init__( self, @@ -83,12 +89,12 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_HUMIDITY] @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.coordinator.data[ATTR_API_PRESSURE] @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data[ATTR_API_TEMPERATURE] @@ -98,6 +104,6 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_WIND_BEARING] @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self.coordinator.data[ATTR_API_WIND_SPEED] diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index c86465ea8f1..4f0bf6ac5ea 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -44,13 +44,13 @@ import async_timeout from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, ) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -406,10 +406,10 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( day ), - ATTR_FORECAST_TEMP: self._get_temperature_day(day), - ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), + ATTR_FORECAST_NATIVE_TEMP: self._get_temperature_day(day), + ATTR_FORECAST_NATIVE_TEMP_LOW: self._get_temperature_low_day(day), ATTR_FORECAST_TIME: dt_util.as_utc(date).isoformat(), - ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), + ATTR_FORECAST_NATIVE_WIND_SPEED: self._get_wind_speed_day(day), ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), } @@ -421,13 +421,13 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return { ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), + ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation(day, hour), ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( day, hour ), - ATTR_FORECAST_TEMP: self._get_temperature(day, hour), + ATTR_FORECAST_NATIVE_TEMP: self._get_temperature(day, hour), ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), - ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), + ATTR_FORECAST_NATIVE_WIND_SPEED: self._get_wind_speed(day, hour), ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), } diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index b1f452c1b46..8dd177a145d 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -2,11 +2,15 @@ from unittest.mock import patch +import pytest import requests_mock from homeassistant.components.aemet.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .util import aemet_requests_mock @@ -42,3 +46,83 @@ async def test_unload_entry(hass): await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize( + "old_unique_id,new_unique_id", + [ + # Sensors which should be migrated + ( + "aemet_unique_id-forecast-daily-precipitation", + "aemet_unique_id-forecast-daily-native_precipitation", + ), + ( + "aemet_unique_id-forecast-daily-temperature", + "aemet_unique_id-forecast-daily-native_temperature", + ), + ( + "aemet_unique_id-forecast-daily-templow", + "aemet_unique_id-forecast-daily-native_templow", + ), + ( + "aemet_unique_id-forecast-daily-wind_speed", + "aemet_unique_id-forecast-daily-native_wind_speed", + ), + ( + "aemet_unique_id-forecast-hourly-precipitation", + "aemet_unique_id-forecast-hourly-native_precipitation", + ), + ( + "aemet_unique_id-forecast-hourly-temperature", + "aemet_unique_id-forecast-hourly-native_temperature", + ), + ( + "aemet_unique_id-forecast-hourly-templow", + "aemet_unique_id-forecast-hourly-native_templow", + ), + ( + "aemet_unique_id-forecast-hourly-wind_speed", + "aemet_unique_id-forecast-hourly-native_wind_speed", + ), + # Already migrated + ( + "aemet_unique_id-forecast-daily-native_templow", + "aemet_unique_id-forecast-daily-native_templow", + ), + # No migration needed + ( + "aemet_unique_id-forecast-daily-condition", + "aemet_unique_id-forecast-daily-condition", + ), + ], +) +async def test_migrate_unique_id_sensor( + hass: HomeAssistant, + old_unique_id: str, + new_unique_id: str, +) -> None: + """Test migration of unique_id.""" + now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") + with patch("homeassistant.util.dt.now", return_value=now), patch( + "homeassistant.util.dt.utcnow", return_value=now + ), requests_mock.mock() as _m: + aemet_requests_mock(_m) + config_entry = MockConfigEntry( + domain=DOMAIN, unique_id="aemet_unique_id", data=CONFIG + ) + config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + entity: er.RegistryEntry = entity_registry.async_get_or_create( + domain=SENSOR_DOMAIN, + platform=DOMAIN, + unique_id=old_unique_id, + config_entry=config_entry, + ) + assert entity.unique_id == old_unique_id + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(entity.entity_id) + assert entity_migrated + assert entity_migrated.unique_id == new_unique_id From a90654bd630b016705557dd33093d9bdce2156e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Br=C3=BCckmann?= Date: Tue, 5 Jul 2022 12:25:20 +0200 Subject: [PATCH 918/947] Fix unreachable DenonAVR reporting as available when polling fails (#74344) --- homeassistant/components/denonavr/media_player.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py index 8d3102c441b..85e28c29d7c 100644 --- a/homeassistant/components/denonavr/media_player.py +++ b/homeassistant/components/denonavr/media_player.py @@ -204,12 +204,14 @@ class DenonDevice(MediaPlayerEntity): ) self._available = False except AvrCommandError as err: + available = False _LOGGER.error( "Command %s failed with error: %s", func.__name__, err, ) except DenonAvrError as err: + available = False _LOGGER.error( "Error %s occurred in method %s for Denon AVR receiver", err, From ce04480e60107ffbcb77141ea67f5aea2df1406a Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Mon, 4 Jul 2022 14:24:21 +0200 Subject: [PATCH 919/947] Support unload for multiple adguard entries (#74360) --- homeassistant/components/adguard/__init__.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py index 1f2645e227c..2a244a5fe80 100644 --- a/homeassistant/components/adguard/__init__.py +++ b/homeassistant/components/adguard/__init__.py @@ -115,14 +115,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload AdGuard Home config entry.""" - hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) - hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) - hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) - hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) - hass.services.async_remove(DOMAIN, SERVICE_REFRESH) - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + if not hass.data[DOMAIN]: + hass.services.async_remove(DOMAIN, SERVICE_ADD_URL) + hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL) + hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL) + hass.services.async_remove(DOMAIN, SERVICE_REFRESH) del hass.data[DOMAIN] return unload_ok From bb844840965b8a866f324595f29486590db10003 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Jul 2022 22:03:13 -0700 Subject: [PATCH 920/947] Guard invalid data sensor significant change (#74369) --- .../components/sensor/significant_change.py | 18 ++++++++++++++---- .../sensor/test_significant_change.py | 2 ++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensor/significant_change.py b/homeassistant/components/sensor/significant_change.py index 31b4f00c37f..6ff23b43508 100644 --- a/homeassistant/components/sensor/significant_change.py +++ b/homeassistant/components/sensor/significant_change.py @@ -63,13 +63,23 @@ def async_check_significant_change( absolute_change = 1.0 percentage_change = 2.0 + try: + # New state is invalid, don't report it + new_state_f = float(new_state) + except ValueError: + return False + + try: + # Old state was invalid, we should report again + old_state_f = float(old_state) + except ValueError: + return True + if absolute_change is not None and percentage_change is not None: return _absolute_and_relative_change( - float(old_state), float(new_state), absolute_change, percentage_change + old_state_f, new_state_f, absolute_change, percentage_change ) if absolute_change is not None: - return check_absolute_change( - float(old_state), float(new_state), absolute_change - ) + return check_absolute_change(old_state_f, new_state_f, absolute_change) return None diff --git a/tests/components/sensor/test_significant_change.py b/tests/components/sensor/test_significant_change.py index 051a92f3b07..bfa01d6eb08 100644 --- a/tests/components/sensor/test_significant_change.py +++ b/tests/components/sensor/test_significant_change.py @@ -52,6 +52,8 @@ TEMP_FREEDOM_ATTRS = { ("12.1", "12.2", TEMP_CELSIUS_ATTRS, False), ("70", "71", TEMP_FREEDOM_ATTRS, True), ("70", "70.5", TEMP_FREEDOM_ATTRS, False), + ("fail", "70", TEMP_FREEDOM_ATTRS, True), + ("70", "fail", TEMP_FREEDOM_ATTRS, False), ], ) async def test_significant_change_temperature(old_state, new_state, attrs, result): From f0993ca4a85a8cc27995d9cd6e17bd5bf3f91e85 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Jul 2022 10:47:59 +0200 Subject: [PATCH 921/947] Migrate knx weather to native_* (#74386) --- homeassistant/components/knx/weather.py | 39 ++++++++++++------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/knx/weather.py b/homeassistant/components/knx/weather.py index 6e71c09501f..32f37ad2ac2 100644 --- a/homeassistant/components/knx/weather.py +++ b/homeassistant/components/knx/weather.py @@ -6,7 +6,14 @@ from xknx.devices import Weather as XknxWeather from homeassistant import config_entries from homeassistant.components.weather import WeatherEntity -from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, TEMP_CELSIUS, Platform +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + PRESSURE_PA, + SPEED_METERS_PER_SECOND, + TEMP_CELSIUS, + Platform, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -68,7 +75,9 @@ class KNXWeather(KnxEntity, WeatherEntity): """Representation of a KNX weather device.""" _device: XknxWeather - _attr_temperature_unit = TEMP_CELSIUS + _attr_native_pressure_unit = PRESSURE_PA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND def __init__(self, xknx: XKNX, config: ConfigType) -> None: """Initialize of a KNX sensor.""" @@ -77,19 +86,14 @@ class KNXWeather(KnxEntity, WeatherEntity): self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) @property - def temperature(self) -> float | None: - """Return current temperature.""" + def native_temperature(self) -> float | None: + """Return current temperature in C.""" return self._device.temperature @property - def pressure(self) -> float | None: - """Return current air pressure.""" - # KNX returns pA - HA requires hPa - return ( - self._device.air_pressure / 100 - if self._device.air_pressure is not None - else None - ) + def native_pressure(self) -> float | None: + """Return current air pressure in Pa.""" + return self._device.air_pressure @property def condition(self) -> str: @@ -107,11 +111,6 @@ class KNXWeather(KnxEntity, WeatherEntity): return self._device.wind_bearing @property - def wind_speed(self) -> float | None: - """Return current wind speed in km/h.""" - # KNX only supports wind speed in m/s - return ( - self._device.wind_speed * 3.6 - if self._device.wind_speed is not None - else None - ) + def native_wind_speed(self) -> float | None: + """Return current wind speed in m/s.""" + return self._device.wind_speed From d0a86b3cd20e89cf83d44472d10df0e0e4752060 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Jul 2022 15:18:57 +0200 Subject: [PATCH 922/947] Migrate ipma weather to native_* (#74387) --- homeassistant/components/ipma/weather.py | 33 ++++++++++++------------ 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index 731c3d7fb60..dd585b88802 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -25,12 +25,12 @@ from homeassistant.components.weather import ( ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity, ) @@ -40,6 +40,8 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MODE, CONF_NAME, + PRESSURE_HPA, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant, callback @@ -174,6 +176,10 @@ async def async_get_location(hass, api, latitude, longitude): class IPMAWeather(WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, location: Location, api: IPMA_API, config): """Initialise the platform with a data instance and station name.""" self._api = api @@ -237,7 +243,7 @@ class IPMAWeather(WeatherEntity): ) @property - def temperature(self): + def native_temperature(self): """Return the current temperature.""" if not self._observation: return None @@ -245,7 +251,7 @@ class IPMAWeather(WeatherEntity): return self._observation.temperature @property - def pressure(self): + def native_pressure(self): """Return the current pressure.""" if not self._observation: return None @@ -261,7 +267,7 @@ class IPMAWeather(WeatherEntity): return self._observation.humidity @property - def wind_speed(self): + def native_wind_speed(self): """Return the current windspeed.""" if not self._observation: return None @@ -276,11 +282,6 @@ class IPMAWeather(WeatherEntity): return self._observation.wind_direction - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def forecast(self): """Return the forecast array.""" @@ -307,13 +308,13 @@ class IPMAWeather(WeatherEntity): ), None, ), - ATTR_FORECAST_TEMP: float(data_in.feels_like_temperature), + ATTR_FORECAST_NATIVE_TEMP: float(data_in.feels_like_temperature), ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( int(float(data_in.precipitation_probability)) if int(float(data_in.precipitation_probability)) >= 0 else None ), - ATTR_FORECAST_WIND_SPEED: data_in.wind_strength, + ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, } for data_in in forecast_filtered @@ -331,10 +332,10 @@ class IPMAWeather(WeatherEntity): ), None, ), - ATTR_FORECAST_TEMP_LOW: data_in.min_temperature, - ATTR_FORECAST_TEMP: data_in.max_temperature, + ATTR_FORECAST_NATIVE_TEMP_LOW: data_in.min_temperature, + ATTR_FORECAST_NATIVE_TEMP: data_in.max_temperature, ATTR_FORECAST_PRECIPITATION_PROBABILITY: data_in.precipitation_probability, - ATTR_FORECAST_WIND_SPEED: data_in.wind_strength, + ATTR_FORECAST_NATIVE_WIND_SPEED: data_in.wind_strength, ATTR_FORECAST_WIND_BEARING: data_in.wind_direction, } for data_in in forecast_filtered From 6ba06c0f5343c2421c998d3d99657a339a3928f6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Jul 2022 14:20:47 +0200 Subject: [PATCH 923/947] Migrate met_eireann weather to native_* (#74391) Co-authored-by: avee87 <6134677+avee87@users.noreply.github.com> Co-authored-by: Franck Nijhof --- homeassistant/components/met_eireann/const.py | 20 +++---- .../components/met_eireann/weather.py | 59 +++++-------------- 2 files changed, 22 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/met_eireann/const.py b/homeassistant/components/met_eireann/const.py index 98d862183c4..efe80cb9d17 100644 --- a/homeassistant/components/met_eireann/const.py +++ b/homeassistant/components/met_eireann/const.py @@ -1,6 +1,4 @@ """Constants for Met Éireann component.""" -import logging - from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -12,13 +10,13 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY_RAINY, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRESSURE, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRESSURE, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, ) @@ -32,17 +30,15 @@ HOME_LOCATION_NAME = "Home" ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.met_eireann_{HOME_LOCATION_NAME}" -_LOGGER = logging.getLogger(".") - FORECAST_MAP = { ATTR_FORECAST_CONDITION: "condition", - ATTR_FORECAST_PRESSURE: "pressure", + ATTR_FORECAST_NATIVE_PRESSURE: "pressure", ATTR_FORECAST_PRECIPITATION: "precipitation", - ATTR_FORECAST_TEMP: "temperature", - ATTR_FORECAST_TEMP_LOW: "templow", + ATTR_FORECAST_NATIVE_TEMP: "temperature", + ATTR_FORECAST_NATIVE_TEMP_LOW: "templow", ATTR_FORECAST_TIME: "datetime", ATTR_FORECAST_WIND_BEARING: "wind_bearing", - ATTR_FORECAST_WIND_SPEED: "wind_speed", + ATTR_FORECAST_NATIVE_WIND_SPEED: "wind_speed", } CONDITION_MAP = { diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index cbf5c99342a..f20f0e1254a 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -3,8 +3,6 @@ import logging from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, WeatherEntity, ) @@ -13,12 +11,9 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, - LENGTH_INCHES, LENGTH_MILLIMETERS, PRESSURE_HPA, - PRESSURE_INHG, SPEED_METERS_PER_SECOND, - SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant @@ -27,9 +22,6 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util -from homeassistant.util.distance import convert as convert_distance -from homeassistant.util.pressure import convert as convert_pressure -from homeassistant.util.speed import convert as convert_speed from .const import ATTRIBUTION, CONDITION_MAP, DEFAULT_NAME, DOMAIN, FORECAST_MAP @@ -54,12 +46,8 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ - MetEireannWeather( - coordinator, config_entry.data, hass.config.units.is_metric, False - ), - MetEireannWeather( - coordinator, config_entry.data, hass.config.units.is_metric, True - ), + MetEireannWeather(coordinator, config_entry.data, False), + MetEireannWeather(coordinator, config_entry.data, True), ] ) @@ -67,11 +55,15 @@ async def async_setup_entry( class MetEireannWeather(CoordinatorEntity, WeatherEntity): """Implementation of a Met Éireann weather condition.""" - def __init__(self, coordinator, config, is_metric, hourly): + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_METERS_PER_SECOND + + def __init__(self, coordinator, config, hourly): """Initialise the platform with a data instance and site.""" super().__init__(coordinator) self._config = config - self._is_metric = is_metric self._hourly = hourly @property @@ -109,23 +101,14 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): ) @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data.current_weather_data.get("temperature") @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - - @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" - pressure_hpa = self.coordinator.data.current_weather_data.get("pressure") - if self._is_metric or pressure_hpa is None: - return pressure_hpa - - return round(convert_pressure(pressure_hpa, PRESSURE_HPA, PRESSURE_INHG), 2) + return self.coordinator.data.current_weather_data.get("pressure") @property def humidity(self): @@ -133,16 +116,9 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): return self.coordinator.data.current_weather_data.get("humidity") @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" - speed_m_s = self.coordinator.data.current_weather_data.get("wind_speed") - if self._is_metric or speed_m_s is None: - return speed_m_s - - speed_mi_h = convert_speed( - speed_m_s, SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR - ) - return int(round(speed_mi_h)) + return self.coordinator.data.current_weather_data.get("wind_speed") @property def wind_bearing(self): @@ -161,7 +137,7 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): me_forecast = self.coordinator.data.hourly_forecast else: me_forecast = self.coordinator.data.daily_forecast - required_keys = {ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME} + required_keys = {"temperature", "datetime"} ha_forecast = [] @@ -171,13 +147,6 @@ class MetEireannWeather(CoordinatorEntity, WeatherEntity): ha_item = { k: item[v] for k, v in FORECAST_MAP.items() if item.get(v) is not None } - if not self._is_metric and ATTR_FORECAST_PRECIPITATION in ha_item: - precip_inches = convert_distance( - ha_item[ATTR_FORECAST_PRECIPITATION], - LENGTH_MILLIMETERS, - LENGTH_INCHES, - ) - ha_item[ATTR_FORECAST_PRECIPITATION] = round(precip_inches, 2) if ha_item.get(ATTR_FORECAST_CONDITION): ha_item[ATTR_FORECAST_CONDITION] = format_condition( ha_item[ATTR_FORECAST_CONDITION] From 25639ccf25b81099a3230133cc4498e65d1e2560 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Jul 2022 13:38:53 +0200 Subject: [PATCH 924/947] Migrate meteoclimatic weather to native_* (#74392) --- .../components/meteoclimatic/weather.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/meteoclimatic/weather.py b/homeassistant/components/meteoclimatic/weather.py index 4faecdaa3ac..8044dd04aa8 100644 --- a/homeassistant/components/meteoclimatic/weather.py +++ b/homeassistant/components/meteoclimatic/weather.py @@ -3,7 +3,7 @@ from meteoclimatic import Condition from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -38,6 +38,10 @@ async def async_setup_entry( class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + def __init__(self, coordinator: DataUpdateCoordinator) -> None: """Initialise the weather platform.""" super().__init__(coordinator) @@ -71,27 +75,22 @@ class MeteoclimaticWeather(CoordinatorEntity, WeatherEntity): return format_condition(self.coordinator.data["weather"].condition) @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data["weather"].temp_current - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return TEMP_CELSIUS - @property def humidity(self): """Return the humidity.""" return self.coordinator.data["weather"].humidity_current @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.coordinator.data["weather"].pressure_current @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self.coordinator.data["weather"].wind_current From db83c784786c4553f61002c25fc8eca50aca52b2 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Mon, 4 Jul 2022 14:14:27 +0300 Subject: [PATCH 925/947] Bump aioimaplib to 1.0.0 (#74393) --- homeassistant/components/imap/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 655590005bf..f4bbadfa6ac 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -2,7 +2,7 @@ "domain": "imap", "name": "IMAP", "documentation": "https://www.home-assistant.io/integrations/imap", - "requirements": ["aioimaplib==0.9.0"], + "requirements": ["aioimaplib==1.0.0"], "codeowners": [], "iot_class": "cloud_push", "loggers": ["aioimaplib"] diff --git a/requirements_all.txt b/requirements_all.txt index 8812e1b8cfe..8956b7ba238 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -178,7 +178,7 @@ aiohttp_cors==0.7.0 aiohue==4.4.2 # homeassistant.components.imap -aioimaplib==0.9.0 +aioimaplib==1.0.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 From 3ff0218326105c16ed634091882013672326ca24 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Jul 2022 15:12:45 +0200 Subject: [PATCH 926/947] Migrate accuweather weather to native_* (#74407) --- .../components/accuweather/weather.py | 57 ++++++++++++------- 1 file changed, 35 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 536f66a3cb9..ae1824aef4a 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -6,19 +6,26 @@ from typing import Any, cast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - 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_NAME, + LENGTH_INCHES, + LENGTH_KILOMETERS, + LENGTH_MILES, + LENGTH_MILLIMETERS, + PRESSURE_HPA, + PRESSURE_INHG, + SPEED_KILOMETERS_PER_HOUR, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -66,19 +73,25 @@ class AccuWeatherEntity( ) -> None: """Initialize.""" super().__init__(coordinator) - self._unit_system = API_METRIC if coordinator.is_metric else API_IMPERIAL - wind_speed_unit = self.coordinator.data["Wind"]["Speed"][self._unit_system][ - "Unit" - ] - if wind_speed_unit == "mi/h": - self._attr_wind_speed_unit = SPEED_MILES_PER_HOUR + # Coordinator data is used also for sensors which don't have units automatically + # converted, hence the weather entity's native units follow the configured unit + # system + if coordinator.is_metric: + self._attr_native_precipitation_unit = LENGTH_MILLIMETERS + self._attr_native_pressure_unit = PRESSURE_HPA + self._attr_native_temperature_unit = TEMP_CELSIUS + self._attr_native_visibility_unit = LENGTH_KILOMETERS + self._attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + self._unit_system = API_METRIC else: - self._attr_wind_speed_unit = wind_speed_unit + self._unit_system = API_IMPERIAL + self._attr_native_precipitation_unit = LENGTH_INCHES + self._attr_native_pressure_unit = PRESSURE_INHG + self._attr_native_temperature_unit = TEMP_FAHRENHEIT + self._attr_native_visibility_unit = LENGTH_MILES + self._attr_native_wind_speed_unit = SPEED_MILES_PER_HOUR self._attr_name = name self._attr_unique_id = coordinator.location_key - self._attr_temperature_unit = ( - TEMP_CELSIUS if coordinator.is_metric else TEMP_FAHRENHEIT - ) self._attr_attribution = ATTRIBUTION self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, @@ -106,14 +119,14 @@ class AccuWeatherEntity( return None @property - def temperature(self) -> float: + def native_temperature(self) -> float: """Return the temperature.""" return cast( float, self.coordinator.data["Temperature"][self._unit_system]["Value"] ) @property - def pressure(self) -> float: + def native_pressure(self) -> float: """Return the pressure.""" return cast( float, self.coordinator.data["Pressure"][self._unit_system]["Value"] @@ -125,7 +138,7 @@ class AccuWeatherEntity( return cast(int, self.coordinator.data["RelativeHumidity"]) @property - def wind_speed(self) -> float: + def native_wind_speed(self) -> float: """Return the wind speed.""" return cast( float, self.coordinator.data["Wind"]["Speed"][self._unit_system]["Value"] @@ -137,7 +150,7 @@ class AccuWeatherEntity( return cast(int, self.coordinator.data["Wind"]["Direction"]["Degrees"]) @property - def visibility(self) -> float: + def native_visibility(self) -> float: """Return the visibility.""" return cast( float, self.coordinator.data["Visibility"][self._unit_system]["Value"] @@ -162,9 +175,9 @@ class AccuWeatherEntity( return [ { ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), - ATTR_FORECAST_TEMP: item["TemperatureMax"]["Value"], - ATTR_FORECAST_TEMP_LOW: item["TemperatureMin"]["Value"], - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(item), + ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"]["Value"], + ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"]["Value"], + ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation(item), ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( mean( [ @@ -173,7 +186,7 @@ class AccuWeatherEntity( ] ) ), - ATTR_FORECAST_WIND_SPEED: item["WindDay"]["Speed"]["Value"], + ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"]["Speed"]["Value"], ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], ATTR_FORECAST_CONDITION: [ k for k, v in CONDITION_CLASSES.items() if item["IconDay"] in v From edb5c7d2c88cb20e1fdd43f6fbbb1e77055f30f3 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Jul 2022 16:59:36 +0200 Subject: [PATCH 927/947] Correct climacell weather migration to native_* (#74409) --- homeassistant/components/climacell/weather.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index 2b284114981..6aee9b54f6c 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -10,13 +10,13 @@ from pyclimacell.const import CURRENT, DAILY, FORECASTS, HOURLY, NOWCAST from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, WeatherEntity, ) from homeassistant.config_entries import ConfigEntry @@ -135,12 +135,12 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): data = { ATTR_FORECAST_TIME: forecast_dt.isoformat(), ATTR_FORECAST_CONDITION: translated_condition, - ATTR_FORECAST_PRECIPITATION: precipitation, + ATTR_FORECAST_NATIVE_PRECIPITATION: precipitation, ATTR_FORECAST_PRECIPITATION_PROBABILITY: precipitation_probability, - ATTR_FORECAST_TEMP: temp, - ATTR_FORECAST_TEMP_LOW: temp_low, + ATTR_FORECAST_NATIVE_TEMP: temp, + ATTR_FORECAST_NATIVE_TEMP_LOW: temp_low, ATTR_FORECAST_WIND_BEARING: wind_direction, - ATTR_FORECAST_WIND_SPEED: wind_speed, + ATTR_FORECAST_NATIVE_WIND_SPEED: wind_speed, } return {k: v for k, v in data.items() if v is not None} @@ -224,7 +224,7 @@ class ClimaCellV3WeatherEntity(BaseClimaCellWeatherEntity): return CONDITIONS_V3[condition] @property - def temperature(self): + def native_temperature(self): """Return the platform temperature.""" return self._get_cc_value( self.coordinator.data[CURRENT], CC_V3_ATTR_TEMPERATURE From 82b9eae88218bb98756bc85acdb188ffeef9d885 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Jul 2022 10:36:56 -0500 Subject: [PATCH 928/947] Bump rflink to 0.0.63 (#74417) --- homeassistant/components/rflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index debc12ae4e0..6cef409a736 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -2,7 +2,7 @@ "domain": "rflink", "name": "RFLink", "documentation": "https://www.home-assistant.io/integrations/rflink", - "requirements": ["rflink==0.0.62"], + "requirements": ["rflink==0.0.63"], "codeowners": ["@javicalle"], "iot_class": "assumed_state", "loggers": ["rflink"] diff --git a/requirements_all.txt b/requirements_all.txt index 8956b7ba238..bcf326c4817 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2077,7 +2077,7 @@ restrictedpython==5.2 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.62 +rflink==0.0.63 # homeassistant.components.ring ring_doorbell==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dd2f423a7cc..3ab1015f872 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1385,7 +1385,7 @@ renault-api==0.1.11 restrictedpython==5.2 # homeassistant.components.rflink -rflink==0.0.62 +rflink==0.0.63 # homeassistant.components.ring ring_doorbell==0.7.2 From 54516ee9392f1ba5e74f027f08d5d332f2bbf82e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Jul 2022 13:53:25 -0500 Subject: [PATCH 929/947] Bump pyunifiprotect to 4.0.9 (#74424) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index da82871d313..9aeb8b48050 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -3,7 +3,7 @@ "name": "UniFi Protect", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifiprotect", - "requirements": ["pyunifiprotect==4.0.8", "unifi-discovery==1.1.4"], + "requirements": ["pyunifiprotect==4.0.9", "unifi-discovery==1.1.4"], "dependencies": ["http"], "codeowners": ["@briis", "@AngellusMortis", "@bdraco"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index bcf326c4817..0fbbfa8a154 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1993,7 +1993,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.8 +pyunifiprotect==4.0.9 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3ab1015f872..72c7ccb569c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1331,7 +1331,7 @@ pytrafikverket==0.2.0.1 pyudev==0.22.0 # homeassistant.components.unifiprotect -pyunifiprotect==4.0.8 +pyunifiprotect==4.0.9 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From 18b3ffbf9932473a8a58625fd28a1dae5aa5b17b Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Mon, 4 Jul 2022 21:10:26 +0200 Subject: [PATCH 930/947] Remove lutron_caseta from mypy ignore list (#74427) --- homeassistant/components/lutron_caseta/__init__.py | 13 +++++++------ homeassistant/components/lutron_caseta/cover.py | 4 ++-- .../components/lutron_caseta/device_trigger.py | 6 ++++-- homeassistant/components/lutron_caseta/models.py | 4 +--- mypy.ini | 9 --------- script/hassfest/mypy_config.py | 3 --- 6 files changed, 14 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py index b4ce82a36c6..c6ad8781478 100644 --- a/homeassistant/components/lutron_caseta/__init__.py +++ b/homeassistant/components/lutron_caseta/__init__.py @@ -215,10 +215,10 @@ def _async_register_button_devices( config_entry_id: str, bridge_device, button_devices_by_id: dict[int, dict], -) -> dict[str, dr.DeviceEntry]: +) -> dict[str, dict]: """Register button devices (Pico Remotes) in the device registry.""" device_registry = dr.async_get(hass) - button_devices_by_dr_id = {} + button_devices_by_dr_id: dict[str, dict] = {} seen = set() for device in button_devices_by_id.values(): @@ -226,7 +226,7 @@ def _async_register_button_devices( continue seen.add(device["serial"]) area, name = _area_and_name_from_name(device["name"]) - device_args = { + device_args: dict[str, Any] = { "name": f"{area} {name}", "manufacturer": MANUFACTURER, "config_entry_id": config_entry_id, @@ -246,7 +246,8 @@ def _async_register_button_devices( def _area_and_name_from_name(device_name: str) -> tuple[str, str]: """Return the area and name from the devices internal name.""" if "_" in device_name: - return device_name.split("_", 1) + area_device_name = device_name.split("_", 1) + return area_device_name[0], area_device_name[1] return UNASSIGNED_AREA, device_name @@ -382,13 +383,13 @@ class LutronCasetaDevice(Entity): class LutronCasetaDeviceUpdatableEntity(LutronCasetaDevice): """A lutron_caseta entity that can update by syncing data from the bridge.""" - async def async_update(self): + async def async_update(self) -> None: """Update when forcing a refresh of the device.""" self._device = self._smartbridge.get_device_by_id(self.device_id) _LOGGER.debug(self._device) -def _id_to_identifier(lutron_id: str) -> None: +def _id_to_identifier(lutron_id: str) -> tuple[str, str]: """Convert a lutron caseta identifier to a device identifier.""" return (DOMAIN, lutron_id) diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py index b74642a8589..d63c1191d57 100644 --- a/homeassistant/components/lutron_caseta/cover.py +++ b/homeassistant/components/lutron_caseta/cover.py @@ -66,13 +66,13 @@ class LutronCasetaCover(LutronCasetaDeviceUpdatableEntity, CoverEntity): async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" await self._smartbridge.lower_cover(self.device_id) - self.async_update() + await self.async_update() self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" await self._smartbridge.raise_cover(self.device_id) - self.async_update() + await self.async_update() self.async_write_ha_state() async def async_set_cover_position(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/lutron_caseta/device_trigger.py b/homeassistant/components/lutron_caseta/device_trigger.py index e762e79a8d7..ed809e0994a 100644 --- a/homeassistant/components/lutron_caseta/device_trigger.py +++ b/homeassistant/components/lutron_caseta/device_trigger.py @@ -386,10 +386,12 @@ async def async_attach_trigger( """Attach a trigger.""" device_registry = dr.async_get(hass) device = device_registry.async_get(config[CONF_DEVICE_ID]) + assert device + assert device.model device_type = _device_model_to_type(device.model) _, serial = list(device.identifiers)[0] - schema = DEVICE_TYPE_SCHEMA_MAP.get(device_type) - valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LIP.get(device_type) + schema = DEVICE_TYPE_SCHEMA_MAP[device_type] + valid_buttons = DEVICE_TYPE_SUBTYPE_MAP_TO_LIP[device_type] config = schema(config) event_config = { event_trigger.CONF_PLATFORM: CONF_EVENT, diff --git a/homeassistant/components/lutron_caseta/models.py b/homeassistant/components/lutron_caseta/models.py index 5845c888a2e..362760b0caf 100644 --- a/homeassistant/components/lutron_caseta/models.py +++ b/homeassistant/components/lutron_caseta/models.py @@ -6,8 +6,6 @@ from typing import Any from pylutron_caseta.smartbridge import Smartbridge -from homeassistant.helpers.device_registry import DeviceEntry - @dataclass class LutronCasetaData: @@ -15,4 +13,4 @@ class LutronCasetaData: bridge: Smartbridge bridge_device: dict[str, Any] - button_devices: dict[str, DeviceEntry] + button_devices: dict[str, dict] diff --git a/mypy.ini b/mypy.ini index fb67983a31c..2b657041865 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2756,15 +2756,6 @@ ignore_errors = true [mypy-homeassistant.components.lovelace.websocket] ignore_errors = true -[mypy-homeassistant.components.lutron_caseta] -ignore_errors = true - -[mypy-homeassistant.components.lutron_caseta.device_trigger] -ignore_errors = true - -[mypy-homeassistant.components.lutron_caseta.switch] -ignore_errors = true - [mypy-homeassistant.components.lyric.climate] ignore_errors = true diff --git a/script/hassfest/mypy_config.py b/script/hassfest/mypy_config.py index bbb628a76bb..b4df9e00495 100644 --- a/script/hassfest/mypy_config.py +++ b/script/hassfest/mypy_config.py @@ -64,9 +64,6 @@ IGNORED_MODULES: Final[list[str]] = [ "homeassistant.components.lovelace.dashboard", "homeassistant.components.lovelace.resources", "homeassistant.components.lovelace.websocket", - "homeassistant.components.lutron_caseta", - "homeassistant.components.lutron_caseta.device_trigger", - "homeassistant.components.lutron_caseta.switch", "homeassistant.components.lyric.climate", "homeassistant.components.lyric.config_flow", "homeassistant.components.lyric.sensor", From c933a49c719b8952ed3e3e9a5c01779015c6477b Mon Sep 17 00:00:00 2001 From: Arne Mauer Date: Tue, 5 Jul 2022 10:35:05 +0200 Subject: [PATCH 931/947] Fix multi_match to match with the IKEA airpurifier channel (#74432) Fix multi_match for FilterLifeTime, device_run_time, filter_run_time sensors for ikea starkvind --- homeassistant/components/zha/number.py | 6 +----- homeassistant/components/zha/sensor.py | 16 ++-------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index c3d7f352318..e1268e29190 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -526,11 +526,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati @CONFIG_DIAGNOSTIC_MATCH( - channel_names="ikea_manufacturer", - manufacturers={ - "IKEA of Sweden", - }, - models={"STARKVIND Air purifier"}, + channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"} ) class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): """Representation of a ZHA timer duration configuration entity.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 2fe38193ecb..4a4700b3c4c 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -810,13 +810,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): _unit = TIME_MINUTES -@MULTI_MATCH( - channel_names="ikea_manufacturer", - manufacturers={ - "IKEA of Sweden", - }, - models={"STARKVIND Air purifier"}, -) +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): """Sensor that displays device run time (in minutes).""" @@ -826,13 +820,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): _unit = TIME_MINUTES -@MULTI_MATCH( - channel_names="ikea_manufacturer", - manufacturers={ - "IKEA of Sweden", - }, - models={"STARKVIND Air purifier"}, -) +@MULTI_MATCH(channel_names="ikea_airpurifier", models={"STARKVIND Air purifier"}) class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): """Sensor that displays run time of the current filter (in minutes).""" From c79b741971cf8633d4e6b6e58f5d36f773db312f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Jul 2022 13:41:33 +0200 Subject: [PATCH 932/947] Re-introduce default scan interval in Scrape sensor (#74455) --- homeassistant/components/scrape/sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index e15f7c5ba97..88c9b564b29 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -1,6 +1,7 @@ """Support for getting data from websites with scraping.""" from __future__ import annotations +from datetime import timedelta import logging from typing import Any @@ -39,6 +40,8 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=10) + CONF_ATTR = "attribute" CONF_SELECT = "select" CONF_INDEX = "index" From 98d5c415b3d5aeff696b5f193e8744a8c1718448 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Jul 2022 08:50:00 -0700 Subject: [PATCH 933/947] Bumped version to 2022.7.0b4 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c07650c8ac1..5a8d761e83b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index 57707cf8af1..27cd670e809 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.0b3" +version = "2022.7.0b4" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From 59aba0bc75d92b4ba332a76ce4409cc811ed8003 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Jul 2022 12:43:38 -0500 Subject: [PATCH 934/947] Bump aiohomekit to 0.7.19 (#74463) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 3b3c5e51cf8..a15c576c313 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.7.18"], + "requirements": ["aiohomekit==0.7.19"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 0fbbfa8a154..d280f382e74 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -168,7 +168,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.18 +aiohomekit==0.7.19 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72c7ccb569c..98e516d4336 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -152,7 +152,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.18 +aiohomekit==0.7.19 # homeassistant.components.emulated_hue # homeassistant.components.http From 43fe351f1bac2a821296986b85bde1d35a63f096 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Jul 2022 12:27:27 -0500 Subject: [PATCH 935/947] Avoid loading mqtt for type checking (#74464) --- homeassistant/helpers/config_entry_flow.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py index 1190e947eba..3617c0b1f29 100644 --- a/homeassistant/helpers/config_entry_flow.py +++ b/homeassistant/helpers/config_entry_flow.py @@ -6,7 +6,7 @@ import logging from typing import TYPE_CHECKING, Any, Generic, TypeVar, Union, cast from homeassistant import config_entries -from homeassistant.components import dhcp, mqtt, onboarding, ssdp, zeroconf +from homeassistant.components import dhcp, onboarding, ssdp, zeroconf from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult @@ -15,6 +15,9 @@ from .typing import UNDEFINED, DiscoveryInfoType, UndefinedType if TYPE_CHECKING: import asyncio + from homeassistant.components import mqtt + + _R = TypeVar("_R", bound="Awaitable[bool] | bool") DiscoveryFunctionType = Callable[[HomeAssistant], _R] From 9cbb684d50664332cff6c41b6605f47f459eec86 Mon Sep 17 00:00:00 2001 From: Zack Barett Date: Tue, 5 Jul 2022 12:43:10 -0500 Subject: [PATCH 936/947] Bump Frontend to 20220705.0 (#74467) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 27ff0a73f20..6b378fe1098 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220630.0"], + "requirements": ["home-assistant-frontend==20220705.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 7ee5a9fe8d3..00f5c776712 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220630.0 +home-assistant-frontend==20220705.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index d280f382e74..726c499b88c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -828,7 +828,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220630.0 +home-assistant-frontend==20220705.0 # homeassistant.components.home_connect homeconnect==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 98e516d4336..0a7d822a5d9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -595,7 +595,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220630.0 +home-assistant-frontend==20220705.0 # homeassistant.components.home_connect homeconnect==0.7.1 From 89360516d74fdb18f43b212e700f24d74f364ff9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 5 Jul 2022 22:24:08 +0200 Subject: [PATCH 937/947] Revert "Migrate aemet to native_*" (#74471) --- homeassistant/components/aemet/__init__.py | 39 +-------- homeassistant/components/aemet/const.py | 26 ++---- homeassistant/components/aemet/sensor.py | 16 ++-- homeassistant/components/aemet/weather.py | 20 ++--- .../aemet/weather_update_coordinator.py | 20 ++--- tests/components/aemet/test_init.py | 84 ------------------- 6 files changed, 37 insertions(+), 168 deletions(-) diff --git a/homeassistant/components/aemet/__init__.py b/homeassistant/components/aemet/__init__.py index 7b86a5559e0..a914a23a0da 100644 --- a/homeassistant/components/aemet/__init__.py +++ b/homeassistant/components/aemet/__init__.py @@ -1,30 +1,18 @@ """The AEMET OpenData component.""" -from __future__ import annotations - import logging -from typing import Any from aemet_opendata.interface import AEMET from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_API_KEY, - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, - Platform, -) -from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.core import HomeAssistant from .const import ( CONF_STATION_UPDATES, DOMAIN, ENTRY_NAME, ENTRY_WEATHER_COORDINATOR, - FORECAST_MODES, PLATFORMS, - RENAMED_FORECAST_SENSOR_KEYS, ) from .weather_update_coordinator import WeatherUpdateCoordinator @@ -33,8 +21,6 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up AEMET OpenData as config entry.""" - await er.async_migrate_entries(hass, entry.entry_id, async_migrate_entity_entry) - name = entry.data[CONF_NAME] api_key = entry.data[CONF_API_KEY] latitude = entry.data[CONF_LATITUDE] @@ -74,24 +60,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -@callback -def async_migrate_entity_entry(entry: er.RegistryEntry) -> dict[str, Any] | None: - """Migrate AEMET entity entries. - - - Migrates unique ID from old forecast sensors to the new unique ID - """ - if entry.domain != Platform.SENSOR: - return None - for old_key, new_key in RENAMED_FORECAST_SENSOR_KEYS.items(): - for forecast_mode in FORECAST_MODES: - old_suffix = f"-forecast-{forecast_mode}-{old_key}" - if entry.unique_id.endswith(old_suffix): - new_suffix = f"-forecast-{forecast_mode}-{new_key}" - return { - "new_unique_id": entry.unique_id.replace(old_suffix, new_suffix) - } - - # No migration needed - return None diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 48e7335934f..4be90011f5a 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -18,10 +18,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SNOWY, ATTR_CONDITION_SUNNY, ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, ATTR_FORECAST_TEMP, @@ -163,13 +159,13 @@ CONDITIONS_MAP = { FORECAST_MONITORED_CONDITIONS = [ ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_WIND_SPEED, ] MONITORED_CONDITIONS = [ ATTR_API_CONDITION, @@ -210,7 +206,7 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_PRECIPITATION, + key=ATTR_FORECAST_PRECIPITATION, name="Precipitation", native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, ), @@ -220,13 +216,13 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_TEMP, + key=ATTR_FORECAST_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_TEMP_LOW, + key=ATTR_FORECAST_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, @@ -242,17 +238,11 @@ FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( native_unit_of_measurement=DEGREE, ), SensorEntityDescription( - key=ATTR_FORECAST_NATIVE_WIND_SPEED, + key=ATTR_FORECAST_WIND_SPEED, name="Wind speed", native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, ), ) -RENAMED_FORECAST_SENSOR_KEYS = { - ATTR_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, -} WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key=ATTR_API_CONDITION, diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index 8439b166a47..f98e3fff49e 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -45,13 +45,17 @@ async def async_setup_entry( entities.extend( [ AemetForecastSensor( - f"{domain_data[ENTRY_NAME]} {mode} Forecast", - f"{unique_id}-forecast-{mode}", + name_prefix, + unique_id_prefix, weather_coordinator, mode, description, ) for mode in FORECAST_MODES + if ( + (name_prefix := f"{domain_data[ENTRY_NAME]} {mode} Forecast") + and (unique_id_prefix := f"{unique_id}-forecast-{mode}") + ) for description in FORECAST_SENSOR_TYPES if description.key in FORECAST_MONITORED_CONDITIONS ] @@ -85,14 +89,14 @@ class AemetSensor(AbstractAemetSensor): def __init__( self, name, - unique_id_prefix, + unique_id, weather_coordinator: WeatherUpdateCoordinator, description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id_prefix}-{description.key}", + unique_id=f"{unique_id}-{description.key}", coordinator=weather_coordinator, description=description, ) @@ -109,7 +113,7 @@ class AemetForecastSensor(AbstractAemetSensor): def __init__( self, name, - unique_id_prefix, + unique_id, weather_coordinator: WeatherUpdateCoordinator, forecast_mode, description: SensorEntityDescription, @@ -117,7 +121,7 @@ class AemetForecastSensor(AbstractAemetSensor): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id_prefix}-{description.key}", + unique_id=f"{unique_id}-{description.key}", coordinator=weather_coordinator, description=description, ) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index a67726d1f51..d05442b621e 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,12 +1,7 @@ """Support for the AEMET OpenData service.""" from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - LENGTH_MILLIMETERS, - PRESSURE_HPA, - SPEED_KILOMETERS_PER_HOUR, - TEMP_CELSIUS, -) +from homeassistant.const import PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -52,10 +47,9 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION - _attr_native_precipitation_unit = LENGTH_MILLIMETERS - _attr_native_pressure_unit = PRESSURE_HPA - _attr_native_temperature_unit = TEMP_CELSIUS - _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + _attr_temperature_unit = TEMP_CELSIUS + _attr_pressure_unit = PRESSURE_HPA + _attr_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR def __init__( self, @@ -89,12 +83,12 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_HUMIDITY] @property - def native_pressure(self): + def pressure(self): """Return the pressure.""" return self.coordinator.data[ATTR_API_PRESSURE] @property - def native_temperature(self): + def temperature(self): """Return the temperature.""" return self.coordinator.data[ATTR_API_TEMPERATURE] @@ -104,6 +98,6 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_WIND_BEARING] @property - def native_wind_speed(self): + def wind_speed(self): """Return the wind speed.""" return self.coordinator.data[ATTR_API_WIND_SPEED] diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 4f0bf6ac5ea..c86465ea8f1 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -44,13 +44,13 @@ import async_timeout from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING, + ATTR_FORECAST_WIND_SPEED, ) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -406,10 +406,10 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( day ), - ATTR_FORECAST_NATIVE_TEMP: self._get_temperature_day(day), - ATTR_FORECAST_NATIVE_TEMP_LOW: self._get_temperature_low_day(day), + ATTR_FORECAST_TEMP: self._get_temperature_day(day), + ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), ATTR_FORECAST_TIME: dt_util.as_utc(date).isoformat(), - ATTR_FORECAST_NATIVE_WIND_SPEED: self._get_wind_speed_day(day), + ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), } @@ -421,13 +421,13 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return { ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation(day, hour), + ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( day, hour ), - ATTR_FORECAST_NATIVE_TEMP: self._get_temperature(day, hour), + ATTR_FORECAST_TEMP: self._get_temperature(day, hour), ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), - ATTR_FORECAST_NATIVE_WIND_SPEED: self._get_wind_speed(day, hour), + ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), } diff --git a/tests/components/aemet/test_init.py b/tests/components/aemet/test_init.py index 8dd177a145d..b1f452c1b46 100644 --- a/tests/components/aemet/test_init.py +++ b/tests/components/aemet/test_init.py @@ -2,15 +2,11 @@ from unittest.mock import patch -import pytest import requests_mock from homeassistant.components.aemet.const import DOMAIN -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .util import aemet_requests_mock @@ -46,83 +42,3 @@ async def test_unload_entry(hass): await hass.config_entries.async_unload(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.NOT_LOADED - - -@pytest.mark.parametrize( - "old_unique_id,new_unique_id", - [ - # Sensors which should be migrated - ( - "aemet_unique_id-forecast-daily-precipitation", - "aemet_unique_id-forecast-daily-native_precipitation", - ), - ( - "aemet_unique_id-forecast-daily-temperature", - "aemet_unique_id-forecast-daily-native_temperature", - ), - ( - "aemet_unique_id-forecast-daily-templow", - "aemet_unique_id-forecast-daily-native_templow", - ), - ( - "aemet_unique_id-forecast-daily-wind_speed", - "aemet_unique_id-forecast-daily-native_wind_speed", - ), - ( - "aemet_unique_id-forecast-hourly-precipitation", - "aemet_unique_id-forecast-hourly-native_precipitation", - ), - ( - "aemet_unique_id-forecast-hourly-temperature", - "aemet_unique_id-forecast-hourly-native_temperature", - ), - ( - "aemet_unique_id-forecast-hourly-templow", - "aemet_unique_id-forecast-hourly-native_templow", - ), - ( - "aemet_unique_id-forecast-hourly-wind_speed", - "aemet_unique_id-forecast-hourly-native_wind_speed", - ), - # Already migrated - ( - "aemet_unique_id-forecast-daily-native_templow", - "aemet_unique_id-forecast-daily-native_templow", - ), - # No migration needed - ( - "aemet_unique_id-forecast-daily-condition", - "aemet_unique_id-forecast-daily-condition", - ), - ], -) -async def test_migrate_unique_id_sensor( - hass: HomeAssistant, - old_unique_id: str, - new_unique_id: str, -) -> None: - """Test migration of unique_id.""" - now = dt_util.parse_datetime("2021-01-09 12:00:00+00:00") - with patch("homeassistant.util.dt.now", return_value=now), patch( - "homeassistant.util.dt.utcnow", return_value=now - ), requests_mock.mock() as _m: - aemet_requests_mock(_m) - config_entry = MockConfigEntry( - domain=DOMAIN, unique_id="aemet_unique_id", data=CONFIG - ) - config_entry.add_to_hass(hass) - - entity_registry = er.async_get(hass) - entity: er.RegistryEntry = entity_registry.async_get_or_create( - domain=SENSOR_DOMAIN, - platform=DOMAIN, - unique_id=old_unique_id, - config_entry=config_entry, - ) - assert entity.unique_id == old_unique_id - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - - entity_migrated = entity_registry.async_get(entity.entity_id) - assert entity_migrated - assert entity_migrated.unique_id == new_unique_id From 56e90dd30b3c4d9883cf6b951839abc9ddd9b184 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Jul 2022 13:56:38 -0700 Subject: [PATCH 938/947] Bumped version to 2022.7.0b5 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5a8d761e83b..69ebbacb0e2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index 27cd670e809..61ff9ba39aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.0b4" +version = "2022.7.0b5" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" From cd4255523881fc7b8a7b221f5f9b9d9b74b020ae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Jul 2022 23:00:40 -0500 Subject: [PATCH 939/947] Fix apple tv not coming online if connected before entity created (#74488) --- homeassistant/components/apple_tv/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index 45250451f37..5177c6f3486 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -123,6 +123,10 @@ class AppleTVEntity(Entity): self.atv = None self.async_write_ha_state() + if self.manager.atv: + # ATV is already connected + _async_connected(self.manager.atv) + self.async_on_remove( async_dispatcher_connect( self.hass, f"{SIGNAL_CONNECTED}_{self.unique_id}", _async_connected From 06aa92b0b6409ced10521ca2b1e298f764675816 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Jul 2022 00:52:41 -0500 Subject: [PATCH 940/947] Bump aiohomekit to 0.7.20 (#74489) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index a15c576c313..955f5e37177 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.7.19"], + "requirements": ["aiohomekit==0.7.20"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 726c499b88c..b0171ae3ee2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -168,7 +168,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.19 +aiohomekit==0.7.20 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0a7d822a5d9..bd0800310a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -152,7 +152,7 @@ aioguardian==2022.03.2 aioharmony==0.2.9 # homeassistant.components.homekit_controller -aiohomekit==0.7.19 +aiohomekit==0.7.20 # homeassistant.components.emulated_hue # homeassistant.components.http From c7c88877191d774af76ef0028cf5b309de8bdff9 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Jul 2022 16:22:45 +0200 Subject: [PATCH 941/947] Migrate aemet weather to native_* (#74494) --- homeassistant/components/aemet/const.py | 48 ++++++------- homeassistant/components/aemet/sensor.py | 20 +++--- homeassistant/components/aemet/weather.py | 69 ++++++++++++++++--- .../aemet/weather_update_coordinator.py | 46 ++++++------- 4 files changed, 114 insertions(+), 69 deletions(-) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index 4be90011f5a..645c1ad0ea2 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -17,14 +17,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_RAINY, ATTR_CONDITION_SNOWY, ATTR_CONDITION_SUNNY, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, ) from homeassistant.const import ( DEGREE, @@ -45,8 +37,16 @@ ENTRY_NAME = "name" ENTRY_WEATHER_COORDINATOR = "weather_coordinator" ATTR_API_CONDITION = "condition" +ATTR_API_FORECAST_CONDITION = "condition" ATTR_API_FORECAST_DAILY = "forecast-daily" ATTR_API_FORECAST_HOURLY = "forecast-hourly" +ATTR_API_FORECAST_PRECIPITATION = "precipitation" +ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" +ATTR_API_FORECAST_TEMP = "temperature" +ATTR_API_FORECAST_TEMP_LOW = "templow" +ATTR_API_FORECAST_TIME = "datetime" +ATTR_API_FORECAST_WIND_BEARING = "wind_bearing" +ATTR_API_FORECAST_WIND_SPEED = "wind_speed" ATTR_API_HUMIDITY = "humidity" ATTR_API_PRESSURE = "pressure" ATTR_API_RAIN = "rain" @@ -158,14 +158,14 @@ CONDITIONS_MAP = { } FORECAST_MONITORED_CONDITIONS = [ - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ] MONITORED_CONDITIONS = [ ATTR_API_CONDITION, @@ -202,43 +202,43 @@ FORECAST_MODE_ATTR_API = { FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key=ATTR_FORECAST_CONDITION, + key=ATTR_API_FORECAST_CONDITION, name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION, + key=ATTR_API_FORECAST_PRECIPITATION, name="Precipitation", native_unit_of_measurement=PRECIPITATION_MILLIMETERS_PER_HOUR, ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION_PROBABILITY, + key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, name="Precipitation probability", native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP, + key=ATTR_API_FORECAST_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP_LOW, + key=ATTR_API_FORECAST_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TIME, + key=ATTR_API_FORECAST_TIME, name="Time", device_class=SensorDeviceClass.TIMESTAMP, ), SensorEntityDescription( - key=ATTR_FORECAST_WIND_BEARING, + key=ATTR_API_FORECAST_WIND_BEARING, name="Wind bearing", native_unit_of_measurement=DEGREE, ), SensorEntityDescription( - key=ATTR_FORECAST_WIND_SPEED, + key=ATTR_API_FORECAST_WIND_SPEED, name="Wind speed", native_unit_of_measurement=SPEED_KILOMETERS_PER_HOUR, ), diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f98e3fff49e..e34583148e1 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -10,7 +10,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.util import dt as dt_util from .const import ( - ATTR_FORECAST_TIME, + ATTR_API_FORECAST_TIME, ATTRIBUTION, DOMAIN, ENTRY_NAME, @@ -45,17 +45,13 @@ async def async_setup_entry( entities.extend( [ AemetForecastSensor( - name_prefix, - unique_id_prefix, + f"{domain_data[ENTRY_NAME]} {mode} Forecast", + f"{unique_id}-forecast-{mode}", weather_coordinator, mode, description, ) for mode in FORECAST_MODES - if ( - (name_prefix := f"{domain_data[ENTRY_NAME]} {mode} Forecast") - and (unique_id_prefix := f"{unique_id}-forecast-{mode}") - ) for description in FORECAST_SENSOR_TYPES if description.key in FORECAST_MONITORED_CONDITIONS ] @@ -89,14 +85,14 @@ class AemetSensor(AbstractAemetSensor): def __init__( self, name, - unique_id, + unique_id_prefix, weather_coordinator: WeatherUpdateCoordinator, description: SensorEntityDescription, ): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id}-{description.key}", + unique_id=f"{unique_id_prefix}-{description.key}", coordinator=weather_coordinator, description=description, ) @@ -113,7 +109,7 @@ class AemetForecastSensor(AbstractAemetSensor): def __init__( self, name, - unique_id, + unique_id_prefix, weather_coordinator: WeatherUpdateCoordinator, forecast_mode, description: SensorEntityDescription, @@ -121,7 +117,7 @@ class AemetForecastSensor(AbstractAemetSensor): """Initialize the sensor.""" super().__init__( name=name, - unique_id=f"{unique_id}-{description.key}", + unique_id=f"{unique_id_prefix}-{description.key}", coordinator=weather_coordinator, description=description, ) @@ -139,6 +135,6 @@ class AemetForecastSensor(AbstractAemetSensor): ) if forecasts: forecast = forecasts[0].get(self.entity_description.key) - if self.entity_description.key == ATTR_FORECAST_TIME: + if self.entity_description.key == ATTR_API_FORECAST_TIME: forecast = dt_util.parse_datetime(forecast) return forecast diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index d05442b621e..a7ff3630e78 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -1,13 +1,36 @@ """Support for the AEMET OpenData service.""" -from homeassistant.components.weather import WeatherEntity +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + WeatherEntity, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import PRESSURE_HPA, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS +from homeassistant.const import ( + LENGTH_MILLIMETERS, + PRESSURE_HPA, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_API_CONDITION, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, @@ -19,10 +42,32 @@ from .const import ( ENTRY_WEATHER_COORDINATOR, FORECAST_MODE_ATTR_API, FORECAST_MODE_DAILY, + FORECAST_MODE_HOURLY, FORECAST_MODES, ) from .weather_update_coordinator import WeatherUpdateCoordinator +FORECAST_MAP = { + FORECAST_MODE_DAILY: { + ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, + ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, + FORECAST_MODE_HOURLY: { + ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, + ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, + }, +} + async def async_setup_entry( hass: HomeAssistant, @@ -47,9 +92,10 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): """Implementation of an AEMET OpenData sensor.""" _attr_attribution = ATTRIBUTION - _attr_temperature_unit = TEMP_CELSIUS - _attr_pressure_unit = PRESSURE_HPA - _attr_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR + _attr_native_precipitation_unit = LENGTH_MILLIMETERS + _attr_native_pressure_unit = PRESSURE_HPA + _attr_native_temperature_unit = TEMP_CELSIUS + _attr_native_wind_speed_unit = SPEED_KILOMETERS_PER_HOUR def __init__( self, @@ -75,7 +121,12 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): @property def forecast(self): """Return the forecast array.""" - return self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]] + forecasts = self.coordinator.data[FORECAST_MODE_ATTR_API[self._forecast_mode]] + forecast_map = FORECAST_MAP[self._forecast_mode] + return [ + {ha_key: forecast[api_key] for api_key, ha_key in forecast_map.items()} + for forecast in forecasts + ] @property def humidity(self): @@ -83,12 +134,12 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_HUMIDITY] @property - def pressure(self): + def native_pressure(self): """Return the pressure.""" return self.coordinator.data[ATTR_API_PRESSURE] @property - def temperature(self): + def native_temperature(self): """Return the temperature.""" return self.coordinator.data[ATTR_API_TEMPERATURE] @@ -98,6 +149,6 @@ class AemetWeather(CoordinatorEntity[WeatherUpdateCoordinator], WeatherEntity): return self.coordinator.data[ATTR_API_WIND_BEARING] @property - def wind_speed(self): + def native_wind_speed(self): """Return the wind speed.""" return self.coordinator.data[ATTR_API_WIND_SPEED] diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index c86465ea8f1..1c64206891c 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -42,23 +42,21 @@ from aemet_opendata.helpers import ( ) import async_timeout -from homeassistant.components.weather import ( - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TEMP, - ATTR_FORECAST_TEMP_LOW, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, - ATTR_FORECAST_WIND_SPEED, -) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util from .const import ( ATTR_API_CONDITION, + ATTR_API_FORECAST_CONDITION, ATTR_API_FORECAST_DAILY, ATTR_API_FORECAST_HOURLY, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_RAIN, @@ -402,15 +400,15 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): return None return { - ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( + ATTR_API_FORECAST_CONDITION: condition, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._get_precipitation_prob_day( day ), - ATTR_FORECAST_TEMP: self._get_temperature_day(day), - ATTR_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), - ATTR_FORECAST_TIME: dt_util.as_utc(date).isoformat(), - ATTR_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), - ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), + ATTR_API_FORECAST_TEMP: self._get_temperature_day(day), + ATTR_API_FORECAST_TEMP_LOW: self._get_temperature_low_day(day), + ATTR_API_FORECAST_TIME: dt_util.as_utc(date).isoformat(), + ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed_day(day), + ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing_day(day), } def _convert_forecast_hour(self, date, day, hour): @@ -420,15 +418,15 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): forecast_dt = date.replace(hour=hour, minute=0, second=0) return { - ATTR_FORECAST_CONDITION: condition, - ATTR_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( + ATTR_API_FORECAST_CONDITION: condition, + ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation(day, hour), + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: self._calc_precipitation_prob( day, hour ), - ATTR_FORECAST_TEMP: self._get_temperature(day, hour), - ATTR_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), - ATTR_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), - ATTR_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), + ATTR_API_FORECAST_TEMP: self._get_temperature(day, hour), + ATTR_API_FORECAST_TIME: dt_util.as_utc(forecast_dt).isoformat(), + ATTR_API_FORECAST_WIND_SPEED: self._get_wind_speed(day, hour), + ATTR_API_FORECAST_WIND_BEARING: self._get_wind_bearing(day, hour), } def _calc_precipitation(self, day, hour): From b277c28ed7227ce8981534e44882de4a48a1f005 Mon Sep 17 00:00:00 2001 From: Marcel van der Veldt Date: Wed, 6 Jul 2022 13:35:25 +0200 Subject: [PATCH 942/947] Bump aioslimproto to 2.1.1 (#74499) --- homeassistant/components/slimproto/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/slimproto/manifest.json b/homeassistant/components/slimproto/manifest.json index 23b3198d7e4..1e076046b44 100644 --- a/homeassistant/components/slimproto/manifest.json +++ b/homeassistant/components/slimproto/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "iot_class": "local_push", "documentation": "https://www.home-assistant.io/integrations/slimproto", - "requirements": ["aioslimproto==2.0.1"], + "requirements": ["aioslimproto==2.1.1"], "codeowners": ["@marcelveldt"], "after_dependencies": ["media_source"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0171ae3ee2..9e6a6aa2b0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ aioshelly==2.0.0 aioskybell==22.6.1 # homeassistant.components.slimproto -aioslimproto==2.0.1 +aioslimproto==2.1.1 # homeassistant.components.steamist aiosteamist==0.3.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bd0800310a0..030ca8ab7ec 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -219,7 +219,7 @@ aioshelly==2.0.0 aioskybell==22.6.1 # homeassistant.components.slimproto -aioslimproto==2.0.1 +aioslimproto==2.1.1 # homeassistant.components.steamist aiosteamist==0.3.2 From 519d15428c488f9ac81d282b2216e0f24dc3e778 Mon Sep 17 00:00:00 2001 From: Gyosa3 <51777889+Gyosa3@users.noreply.github.com> Date: Wed, 6 Jul 2022 17:48:12 +0200 Subject: [PATCH 943/947] Add new alias for valid Celcius temperature units in Tuya (#74511) --- homeassistant/components/tuya/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 8a3e59b1ac9..727e505200b 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -579,7 +579,7 @@ UNITS = ( ), UnitOfMeasurement( unit=TEMP_CELSIUS, - aliases={"°c", "c", "celsius"}, + aliases={"°c", "c", "celsius", "℃"}, device_classes={SensorDeviceClass.TEMPERATURE}, ), UnitOfMeasurement( From 9d3dde60ff1dd6eb4e67798de49f66099ebf95d5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Jul 2022 17:49:06 +0200 Subject: [PATCH 944/947] Fix openweathermap forecast sensors (#74513) --- .../components/openweathermap/const.py | 30 ++++++------- .../components/openweathermap/weather.py | 41 +++++++++++++++++- .../weather_update_coordinator.py | 42 +++++++++---------- 3 files changed, 74 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/openweathermap/const.py b/homeassistant/components/openweathermap/const.py index 06f13daa9c2..836a56c70b2 100644 --- a/homeassistant/components/openweathermap/const.py +++ b/homeassistant/components/openweathermap/const.py @@ -21,9 +21,6 @@ from homeassistant.components.weather import ( ATTR_CONDITION_SUNNY, ATTR_CONDITION_WINDY, ATTR_CONDITION_WINDY_VARIANT, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, ) from homeassistant.const import ( DEGREE, @@ -68,10 +65,15 @@ ATTR_API_FORECAST = "forecast" UPDATE_LISTENER = "update_listener" PLATFORMS = [Platform.SENSOR, Platform.WEATHER] -ATTR_FORECAST_PRECIPITATION = "precipitation" -ATTR_FORECAST_PRESSURE = "pressure" -ATTR_FORECAST_TEMP = "temperature" -ATTR_FORECAST_TEMP_LOW = "templow" +ATTR_API_FORECAST_CONDITION = "condition" +ATTR_API_FORECAST_PRECIPITATION = "precipitation" +ATTR_API_FORECAST_PRECIPITATION_PROBABILITY = "precipitation_probability" +ATTR_API_FORECAST_PRESSURE = "pressure" +ATTR_API_FORECAST_TEMP = "temperature" +ATTR_API_FORECAST_TEMP_LOW = "templow" +ATTR_API_FORECAST_TIME = "datetime" +ATTR_API_FORECAST_WIND_BEARING = "wind_bearing" +ATTR_API_FORECAST_WIND_SPEED = "wind_speed" FORECAST_MODE_HOURLY = "hourly" FORECAST_MODE_DAILY = "daily" @@ -263,39 +265,39 @@ WEATHER_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( ) FORECAST_SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( - key=ATTR_FORECAST_CONDITION, + key=ATTR_API_FORECAST_CONDITION, name="Condition", ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION, + key=ATTR_API_FORECAST_PRECIPITATION, name="Precipitation", native_unit_of_measurement=LENGTH_MILLIMETERS, ), SensorEntityDescription( - key=ATTR_FORECAST_PRECIPITATION_PROBABILITY, + key=ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, name="Precipitation probability", native_unit_of_measurement=PERCENTAGE, ), SensorEntityDescription( - key=ATTR_FORECAST_PRESSURE, + key=ATTR_API_FORECAST_PRESSURE, name="Pressure", native_unit_of_measurement=PRESSURE_HPA, device_class=SensorDeviceClass.PRESSURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP, + key=ATTR_API_FORECAST_TEMP, name="Temperature", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TEMP_LOW, + key=ATTR_API_FORECAST_TEMP_LOW, name="Temperature Low", native_unit_of_measurement=TEMP_CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( - key=ATTR_FORECAST_TIME, + key=ATTR_API_FORECAST_TIME, name="Time", device_class=SensorDeviceClass.TIMESTAMP, ), diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py index fce6efdf3c5..ea439a35586 100644 --- a/homeassistant/components/openweathermap/weather.py +++ b/homeassistant/components/openweathermap/weather.py @@ -1,7 +1,20 @@ """Support for the OpenWeatherMap (OWM) service.""" from __future__ import annotations -from homeassistant.components.weather import Forecast, WeatherEntity +from typing import cast + +from homeassistant.components.weather import ( + ATTR_FORECAST_CONDITION, + ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_FORECAST_NATIVE_TEMP, + ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_FORECAST_NATIVE_WIND_SPEED, + ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_FORECAST_TIME, + ATTR_FORECAST_WIND_BEARING, + Forecast, + WeatherEntity, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( LENGTH_MILLIMETERS, @@ -17,6 +30,14 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import ( ATTR_API_CONDITION, ATTR_API_FORECAST, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, @@ -31,6 +52,17 @@ from .const import ( ) from .weather_update_coordinator import WeatherUpdateCoordinator +FORECAST_MAP = { + ATTR_API_FORECAST_CONDITION: ATTR_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION: ATTR_FORECAST_NATIVE_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ATTR_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_TEMP_LOW: ATTR_FORECAST_NATIVE_TEMP_LOW, + ATTR_API_FORECAST_TEMP: ATTR_FORECAST_NATIVE_TEMP, + ATTR_API_FORECAST_TIME: ATTR_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING: ATTR_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED: ATTR_FORECAST_NATIVE_WIND_SPEED, +} + async def async_setup_entry( hass: HomeAssistant, @@ -109,7 +141,12 @@ class OpenWeatherMapWeather(WeatherEntity): @property def forecast(self) -> list[Forecast] | None: """Return the forecast array.""" - return self._weather_coordinator.data[ATTR_API_FORECAST] + api_forecasts = self._weather_coordinator.data[ATTR_API_FORECAST] + forecasts = [ + {ha_key: forecast[api_key] for api_key, ha_key in FORECAST_MAP.items()} + for forecast in api_forecasts + ] + return cast(list[Forecast], forecasts) @property def available(self) -> bool: diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 36511424737..98c3b56fb5e 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -8,15 +8,6 @@ from pyowm.commons.exceptions import APIRequestError, UnauthorizedError from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_SUNNY, - ATTR_FORECAST_CONDITION, - ATTR_FORECAST_NATIVE_PRECIPITATION, - ATTR_FORECAST_NATIVE_PRESSURE, - ATTR_FORECAST_NATIVE_TEMP, - ATTR_FORECAST_NATIVE_TEMP_LOW, - ATTR_FORECAST_NATIVE_WIND_SPEED, - ATTR_FORECAST_PRECIPITATION_PROBABILITY, - ATTR_FORECAST_TIME, - ATTR_FORECAST_WIND_BEARING, ) from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -29,6 +20,15 @@ from .const import ( ATTR_API_DEW_POINT, ATTR_API_FEELS_LIKE_TEMPERATURE, ATTR_API_FORECAST, + ATTR_API_FORECAST_CONDITION, + ATTR_API_FORECAST_PRECIPITATION, + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY, + ATTR_API_FORECAST_PRESSURE, + ATTR_API_FORECAST_TEMP, + ATTR_API_FORECAST_TEMP_LOW, + ATTR_API_FORECAST_TIME, + ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRECIPITATION_KIND, ATTR_API_PRESSURE, @@ -158,19 +158,19 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _convert_forecast(self, entry): """Convert the forecast data.""" forecast = { - ATTR_FORECAST_TIME: dt.utc_from_timestamp( + ATTR_API_FORECAST_TIME: dt.utc_from_timestamp( entry.reference_time("unix") ).isoformat(), - ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation( + ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation( entry.rain, entry.snow ), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: ( + ATTR_API_FORECAST_PRECIPITATION_PROBABILITY: ( round(entry.precipitation_probability * 100) ), - ATTR_FORECAST_NATIVE_PRESSURE: entry.pressure.get("press"), - ATTR_FORECAST_NATIVE_WIND_SPEED: entry.wind().get("speed"), - ATTR_FORECAST_WIND_BEARING: entry.wind().get("deg"), - ATTR_FORECAST_CONDITION: self._get_condition( + ATTR_API_FORECAST_PRESSURE: entry.pressure.get("press"), + ATTR_API_FORECAST_WIND_SPEED: entry.wind().get("speed"), + ATTR_API_FORECAST_WIND_BEARING: entry.wind().get("deg"), + ATTR_API_FORECAST_CONDITION: self._get_condition( entry.weather_code, entry.reference_time("unix") ), ATTR_API_CLOUDS: entry.clouds, @@ -178,16 +178,12 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): temperature_dict = entry.temperature("celsius") if "max" in temperature_dict and "min" in temperature_dict: - forecast[ATTR_FORECAST_NATIVE_TEMP] = entry.temperature("celsius").get( - "max" - ) - forecast[ATTR_FORECAST_NATIVE_TEMP_LOW] = entry.temperature("celsius").get( + forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("max") + forecast[ATTR_API_FORECAST_TEMP_LOW] = entry.temperature("celsius").get( "min" ) else: - forecast[ATTR_FORECAST_NATIVE_TEMP] = entry.temperature("celsius").get( - "temp" - ) + forecast[ATTR_API_FORECAST_TEMP] = entry.temperature("celsius").get("temp") return forecast From 380244fa7b67e8cccdaa5ca2cc18012d424e851c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Jul 2022 18:34:51 +0200 Subject: [PATCH 945/947] Update homematicip to 1.0.3 (#74516) --- homeassistant/components/homematicip_cloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index b13c8ca19b2..40f7e67fd07 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -3,7 +3,7 @@ "name": "HomematicIP Cloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", - "requirements": ["homematicip==1.0.2"], + "requirements": ["homematicip==1.0.3"], "codeowners": [], "quality_scale": "platinum", "iot_class": "cloud_push", diff --git a/requirements_all.txt b/requirements_all.txt index 9e6a6aa2b0a..769235b30a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -834,7 +834,7 @@ home-assistant-frontend==20220705.0 homeconnect==0.7.1 # homeassistant.components.homematicip_cloud -homematicip==1.0.2 +homematicip==1.0.3 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 030ca8ab7ec..44daeef21fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -601,7 +601,7 @@ home-assistant-frontend==20220705.0 homeconnect==0.7.1 # homeassistant.components.homematicip_cloud -homematicip==1.0.2 +homematicip==1.0.3 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 From 8e5b6ff185872484767f25247163847406e0c244 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Jul 2022 19:31:57 +0200 Subject: [PATCH 946/947] Update Home Assistant Frontend to 20220706.0 (#74520) Bump Home Assistant Frontend to 20220706.0 --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 6b378fe1098..85ead380485 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,7 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", - "requirements": ["home-assistant-frontend==20220705.0"], + "requirements": ["home-assistant-frontend==20220706.0"], "dependencies": [ "api", "auth", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 00f5c776712..c291d969219 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -15,7 +15,7 @@ ciso8601==2.2.0 cryptography==36.0.2 fnvhash==0.1.0 hass-nabucasa==0.54.0 -home-assistant-frontend==20220705.0 +home-assistant-frontend==20220706.0 httpx==0.23.0 ifaddr==0.1.7 jinja2==3.1.2 diff --git a/requirements_all.txt b/requirements_all.txt index 769235b30a1..99be85cc418 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -828,7 +828,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220705.0 +home-assistant-frontend==20220706.0 # homeassistant.components.home_connect homeconnect==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 44daeef21fd..b6a493d37f9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -595,7 +595,7 @@ hole==0.7.0 holidays==0.14.2 # homeassistant.components.frontend -home-assistant-frontend==20220705.0 +home-assistant-frontend==20220706.0 # homeassistant.components.home_connect homeconnect==0.7.1 From 06c6ddb2d63cb41ff075e09919a699e9adf472ec Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Jul 2022 19:33:46 +0200 Subject: [PATCH 947/947] Bumped version to 2022.7.0 --- homeassistant/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 69ebbacb0e2..c2ee7de691f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,7 +7,7 @@ from .backports.enum import StrEnum MAJOR_VERSION: Final = 2022 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 9, 0) diff --git a/pyproject.toml b/pyproject.toml index 61ff9ba39aa..b4994d55edb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2022.7.0b5" +version = "2022.7.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst"